Skip to main content

rlx_sparse/
lib.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Sparse linear algebra for RLX — CSR LU, mat-vec, Conjugate Gradient.
17//!
18//! Downstream package modeled on `jax.experimental.sparse`. Registers
19//! against rlx's custom-op scaffold without requiring any edits to the
20//! framework crates. Three ops + a `SparseTensor` boundary abstraction.
21//!
22//! ## Usage
23//!
24//! ```ignore
25//! // At application startup, once.
26//! rlx_sparse::register();
27//!
28//! // Build graph as usual.
29//! let mut g = Graph::new("photonics");
30//! let v  = g.input("values",  Shape::new(&[nnz], DType::F64));
31//! let ci = ...; // I32 col_idx (Op::Constant or Op::Input)
32//! let rp = ...; // I32 row_ptr
33//! let b  = g.input("b", Shape::new(&[n], DType::F64));
34//!
35//! let a = rlx_sparse::SparseTensor::from_csr(v, ci, rp, n, n);
36//! let x = a.solve(&mut g, b);                 // direct LU
37//! let y = a.mat_vec(&mut g, x);               // sparse matvec
38//! let z = a.cg_solve(&mut g, b, 200, 1e-12);  // iterative CG
39//! ```
40//!
41//! ## What's registered
42//!
43//! - `rlx_sparse.lu_solve` — direct LU via host LAPACK dgesv.
44//!   v1 densifies CSR before solving; performance not yet sparse-fast,
45//!   semantics are correct. Swapping for SuiteSparse UMFPACK or KLU is
46//!   a kernel-body change with zero IR diff.
47//! - `rlx_sparse.mat_vec` — `y = A·x` over CSR.
48//! - `rlx_sparse.cg_solve` — Conjugate Gradient for SPD systems with
49//!   `max_iter` + `tol` baked into the op's `attrs` blob.
50//!
51//! ## Adjoint convention (v1)
52//!
53//! All three ops assume `A` is symmetric. The closed-form adjoint
54//! `dL/db = solve(Aᵀ, dL/dx)` reuses the same CSR triplet as the
55//! forward call. Non-symmetric `A` requires an explicit transpose
56//! triplet — sketch in the `vjp` body of each op. `dL/dvalues` is
57//! non-differentiable in v1; it's `gather(-(dL/db) ⊗ x)` and slots
58//! in as a separate gather op.
59//!
60//! ## Backend support
61//!
62//! | Backend | Status |
63//! |---|---|
64//! | CPU    | Full forward + autodiff. Real LAPACK. |
65//! | Metal  | Trait surface only — full executor dispatch is a follow-up. |
66//! | MLX    | Trait surface only — full executor dispatch is a follow-up. |
67//! | Others | `Op::Custom` rejected at legalize; pin graph to `Device::Cpu`. |
68
69#![cfg_attr(not(feature = "cpu"), allow(dead_code))]
70
71use std::sync::Arc;
72
73use rlx_ir::{Graph, NodeId, register_op};
74
75#[cfg(feature = "cpu")]
76use rlx_cpu::op_registry::register_cpu_kernel;
77
78// ── Op names (stable strings; downstream callers use these to look
79//    up the registered op or build `Op::Custom` directly) ─────────
80
81mod op_bicgstab;
82mod op_cg;
83mod op_cholesky;
84mod op_gmres;
85mod op_ilu_pcg;
86mod op_lsqr;
87mod op_lu;
88mod op_lu_general;
89mod op_mat_vec;
90mod op_pcg;
91mod op_transpose_values;
92mod op_values_grad;
93
94use op_bicgstab::*;
95use op_cg::*;
96use op_cholesky::*;
97use op_gmres::*;
98use op_ilu_pcg::*;
99use op_lsqr::*;
100use op_lu::*;
101use op_lu_general::*;
102use op_mat_vec::*;
103use op_pcg::*;
104use op_transpose_values::*;
105use op_values_grad::*;
106
107pub const SPARSE_LU_SOLVE: &str = "rlx_sparse.lu_solve";
108
109pub const SPARSE_MAT_VEC: &str = "rlx_sparse.mat_vec";
110
111pub const SPARSE_CG_SOLVE: &str = "rlx_sparse.cg_solve";
112
113/// Outer-product gather op (the `dL/dvalues` building block).
114/// Computes `out[k] = u[row_of(k)] * v[col_idx[k]]` for each non-zero
115/// position `k` in the CSR pattern. Used by `SparseLu`/`SparseMatVec`/
116/// `SparseCg`/`SparseGmres` VJPs to gather the dense outer-product
117/// `u ⊗ v` at the matrix's nonzero positions.
118pub const SPARSE_VALUES_GRAD: &str = "rlx_sparse.values_grad";
119
120/// Non-symmetric LU solve. Forward `x = A⁻¹·b` (uses A only).
121/// VJP `dL/db = solve(Aᵀ, dL/dx)` — needs an explicit transpose
122/// triplet, supplied as the last 3 inputs to keep the IR self-
123/// contained. The 4-input `SPARSE_LU_SOLVE` is the symmetric
124/// specialization.
125pub const SPARSE_LU_SOLVE_GENERAL: &str = "rlx_sparse.lu_solve_general";
126
127/// GMRES solve for non-symmetric systems. Iterative analog of CG
128/// for the asymmetric Maxwell / advection-diffusion regime. Same
129/// 7-input shape as `SPARSE_LU_SOLVE_GENERAL`: forward uses A,
130/// VJP routes the adjoint through Aᵀ.
131pub const SPARSE_GMRES_SOLVE: &str = "rlx_sparse.gmres_solve";
132
133/// Permute a CSR `values` vector into the values vector of `Aᵀ`.
134/// 5 inputs: `(values_A, col_idx_A, row_ptr_A, col_idx_AT, row_ptr_AT)`.
135/// The transposed pattern (`col_idx_AT`, `row_ptr_AT`) is structural
136/// — depends only on the original pattern — so it can be precomputed
137/// once at graph-build time via [`csr_transpose_pattern`] and embedded
138/// as constants. Only the values get permuted per call. Useful for
139/// inverse-design Newton loops where the matrix entries change each
140/// iteration but the sparsity pattern is fixed.
141pub const SPARSE_TRANSPOSE_VALUES: &str = "rlx_sparse.transpose_values";
142
143/// Jacobi-preconditioned CG. Same 4-input shape as `SPARSE_CG_SOLVE`
144/// (values, col_idx, row_ptr, b). The kernel extracts `diag(A)`
145/// internally and uses it as the diagonal preconditioner —
146/// dramatically faster than plain CG on ill-conditioned circuit
147/// matrices where the diagonal magnitudes vary by orders of
148/// magnitude. Convergence requires SPD A like CG.
149pub const SPARSE_PCG_SOLVE: &str = "rlx_sparse.pcg_solve";
150
151/// BiCGSTAB iterative solver for general (non-symmetric) sparse A·x = b.
152/// 4 inputs (values, col_idx, row_ptr, b) + attrs encoding
153/// (max_iter: u32, tol: f64, transpose_a: u8). When `transpose_a` is
154/// set, the kernel solves Aᵀ·x = b — used by VJPs for adjoint solves.
155pub const SPARSE_BICGSTAB_SOLVE: &str = "rlx_sparse.bicgstab_solve";
156
157/// ILU(0)-preconditioned CG. Factors A in-place over its existing
158/// sparsity pattern (zero fill-in) and applies the LU triangular
159/// solves as the preconditioner. Same 4-input shape as PCG; converges
160/// faster than Jacobi-PCG on stiff systems where row-row coupling
161/// dominates the off-diagonal.
162pub const SPARSE_ILU_PCG_SOLVE: &str = "rlx_sparse.ilu_pcg_solve";
163
164/// Direct sparse Cholesky for SPD A·x = b. Densifies A and uses
165/// LAPACK `dpotrf` + triangular solves. Same I/O contract as
166/// `SPARSE_LU_SOLVE` but only valid for SPD matrices — ½× factor cost
167/// of LU and numerically more stable.
168pub const SPARSE_CHOLESKY_SOLVE: &str = "rlx_sparse.cholesky_solve";
169
170/// LSQR for sparse least-squares `min_x ||A·x - b||₂`. 4 inputs
171/// (values, col_idx, row_ptr, b) + attrs encoding (max_iter, tol,
172/// n_cols). Forward only in v1 — VJP returns empty (least-squares
173/// adjoint requires either AᵀA solve or a recursive LSQR call which
174/// is non-trivial; defer until a use case appears).
175pub const SPARSE_LSQR_SOLVE: &str = "rlx_sparse.lsqr_solve";
176
177/// Sparse-sparse matrix multiply (CSR × CSR → CSR). 6 inputs:
178/// (a_values, a_col_idx, a_row_ptr, b_values, b_col_idx, b_row_ptr)
179/// plus attrs encoding (k: u32 = inner dim = b's row count). Output
180/// is a packed buffer `[c_values | c_col_idx_as_f64 | c_row_ptr_as_f64]`
181/// with sizes encoded in attrs alongside k. v1 caps nnz output at
182/// `max_nnz` (attrs); allocate generously for known patterns.
183pub const SPARSE_SPGEMM: &str = "rlx_sparse.spgemm";
184
185// ── Shared algorithms (CPU + Metal kernels both call these) ──────
186//
187// Each function takes the typed slices it needs and returns a
188// `Result<(), String>`. The CpuKernel impls extract typed slices via
189// `CpuTensorRef::expect_*`; the MetalKernel impls extract them by
190// casting raw byte slices (after dtype-checking the accompanying
191// Shape). Both backends end up calling the same arithmetic.
192
193#[cfg(feature = "cpu")]
194mod algos {
195    pub fn lu_solve(
196        values: &[f64],
197        col_idx: &[i32],
198        row_ptr: &[i32],
199        b: &[f64],
200        out: &mut [f64],
201    ) -> Result<(), String> {
202        let n = b.len();
203        if out.len() != n {
204            return Err(format!("sparse_lu: output len {} != b len {n}", out.len()));
205        }
206        if row_ptr.len() != n + 1 {
207            return Err(format!(
208                "sparse_lu: row_ptr len {} != n+1 ({})",
209                row_ptr.len(),
210                n + 1
211            ));
212        }
213        let mut a_dense = vec![0f64; n * n];
214        for r in 0..n {
215            for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
216                a_dense[r * n + col_idx[k] as usize] = values[k];
217            }
218        }
219        let mut b_copy = b.to_vec();
220        let info = rlx_cpu::blas::dgesv(&mut a_dense, &mut b_copy, n, 1);
221        if info != 0 {
222            return Err(format!(
223                "sparse_lu: dgesv returned info={info} (>0 → singular)"
224            ));
225        }
226        out.copy_from_slice(&b_copy);
227        Ok(())
228    }
229
230    pub fn mat_vec(
231        values: &[f64],
232        col_idx: &[i32],
233        row_ptr: &[i32],
234        x: &[f64],
235        out: &mut [f64],
236    ) -> Result<(), String> {
237        let n = x.len();
238        if out.len() != n {
239            return Err(format!("mat_vec: output len {} != x len {n}", out.len()));
240        }
241        if row_ptr.len() != n + 1 {
242            return Err(format!(
243                "mat_vec: row_ptr len {} != n+1 ({})",
244                row_ptr.len(),
245                n + 1
246            ));
247        }
248        for r in 0..n {
249            let mut acc = 0f64;
250            for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
251                acc += values[k] * x[col_idx[k] as usize];
252            }
253            out[r] = acc;
254        }
255        Ok(())
256    }
257
258    /// Outer-product gather at CSR non-zero positions:
259    ///   `out[k] = u[row_of(k)] * v[col_idx[k]]`
260    /// where `row_of(k)` is the row containing the `k`-th non-zero
261    /// (looked up by walking row_ptr; cached as a parallel array
262    /// for O(nnz) lookup time).
263    pub fn values_grad(
264        col_idx: &[i32],
265        row_ptr: &[i32],
266        u: &[f64],
267        v: &[f64],
268        out: &mut [f64],
269    ) -> Result<(), String> {
270        let n = u.len();
271        let nnz = col_idx.len();
272        if out.len() != nnz {
273            return Err(format!("values_grad: out len {} != nnz {nnz}", out.len()));
274        }
275        if row_ptr.len() != n + 1 {
276            return Err(format!(
277                "values_grad: row_ptr len {} != n+1 ({})",
278                row_ptr.len(),
279                n + 1
280            ));
281        }
282        // Build row_of_k by scanning row_ptr.
283        let mut row_of_k = vec![0u32; nnz];
284        for r in 0..n {
285            let s = row_ptr[r] as usize;
286            let e = row_ptr[r + 1] as usize;
287            for k in s..e {
288                row_of_k[k] = r as u32;
289            }
290        }
291        for k in 0..nnz {
292            let r = row_of_k[k] as usize;
293            let c = col_idx[k] as usize;
294            if r >= n || c >= v.len() {
295                return Err(format!(
296                    "values_grad: k={k} (row={r}, col={c}) out of bounds"
297                ));
298            }
299            out[k] = u[r] * v[c];
300        }
301        Ok(())
302    }
303
304    /// GMRES with online Givens-rotation QR on the Hessenberg
305    /// system. Standard textbook Saad-Schultz formulation:
306    ///
307    ///   x = 0; r = b;  β = ||r||;  v_1 = r/β
308    ///   for j in 1..=m:
309    ///     w = A·v_j; orthogonalize w against v_1..v_j (Modified
310    ///     Gram-Schmidt) → h_{i,j} entries; w' = ||w||; v_{j+1} = w/w'
311    ///     apply prior Givens rotations to column j of H, generate
312    ///     new rotation that zeroes the subdiagonal of column j,
313    ///     update transformed RHS β·e_1
314    ///     if |residual| < tol: break
315    ///   solve upper-triangular system → y; x = Σ_j y_j · v_j
316    ///
317    /// Restart not implemented — `max_iter` caps Krylov dimension
318    /// directly. For ill-conditioned systems set `max_iter` to a
319    /// moderate value and re-solve with restart in the application.
320    pub fn gmres_solve(
321        values: &[f64],
322        col_idx: &[i32],
323        row_ptr: &[i32],
324        b: &[f64],
325        out: &mut [f64],
326        max_iter: u32,
327        tol: f64,
328    ) -> Result<(), String> {
329        let n = b.len();
330        if out.len() != n {
331            return Err(format!("gmres_solve: out len {} != n {n}", out.len()));
332        }
333        if row_ptr.len() != n + 1 {
334            return Err(format!(
335                "gmres_solve: row_ptr len {} != n+1 ({})",
336                row_ptr.len(),
337                n + 1
338            ));
339        }
340        let m = max_iter.max(1) as usize;
341
342        let matvec = |x: &[f64], y: &mut [f64]| {
343            for r in 0..n {
344                let mut acc = 0f64;
345                for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
346                    acc += values[k] * x[col_idx[k] as usize];
347                }
348                y[r] = acc;
349            }
350        };
351
352        // x_0 = 0; r_0 = b; β = ||b||
353        let beta_init = b.iter().map(|v| v * v).sum::<f64>().sqrt();
354        if beta_init < tol {
355            for v in out.iter_mut() {
356                *v = 0.0;
357            }
358            return Ok(());
359        }
360
361        // Krylov basis: v_1..v_{m+1}, each length n.
362        let mut v: Vec<Vec<f64>> = Vec::with_capacity(m + 1);
363        v.push(b.iter().map(|x| x / beta_init).collect());
364
365        // Hessenberg matrix H is (m+1)×m; stored as rows-vector for
366        // online QR via Givens. We track c_j, s_j (Givens cos/sin)
367        // and the transformed RHS g (length m+1, initially β·e_1).
368        let mut h: Vec<Vec<f64>> = Vec::with_capacity(m); // h[j] = column j (length j+2)
369        let mut cs: Vec<f64> = Vec::with_capacity(m);
370        let mut sn: Vec<f64> = Vec::with_capacity(m);
371        let mut g: Vec<f64> = vec![0.0; m + 1];
372        g[0] = beta_init;
373
374        let mut converged_at: Option<usize> = None;
375        let mut w = vec![0f64; n];
376
377        for j in 0..m {
378            matvec(&v[j], &mut w);
379            // MGS orthogonalization: build column j of H (entries 0..=j+1).
380            let mut hcol = vec![0f64; j + 2];
381            for i in 0..=j {
382                hcol[i] = w.iter().zip(&v[i]).map(|(a, b)| a * b).sum();
383                for k in 0..n {
384                    w[k] -= hcol[i] * v[i][k];
385                }
386            }
387            hcol[j + 1] = w.iter().map(|x| x * x).sum::<f64>().sqrt();
388            // Apply previous Givens rotations to column j.
389            for i in 0..j {
390                let temp = cs[i] * hcol[i] + sn[i] * hcol[i + 1];
391                hcol[i + 1] = -sn[i] * hcol[i] + cs[i] * hcol[i + 1];
392                hcol[i] = temp;
393            }
394            // Generate Givens rotation to zero hcol[j+1].
395            let denom = (hcol[j] * hcol[j] + hcol[j + 1] * hcol[j + 1]).sqrt();
396            if denom == 0.0 {
397                return Err("gmres_solve: breakdown (denom = 0)".into());
398            }
399            let c = hcol[j] / denom;
400            let s = hcol[j + 1] / denom;
401            cs.push(c);
402            sn.push(s);
403            hcol[j] = c * hcol[j] + s * hcol[j + 1];
404            hcol[j + 1] = 0.0;
405            // Apply rotation to RHS g.
406            let g_temp = c * g[j] + s * g[j + 1];
407            g[j + 1] = -s * g[j] + c * g[j + 1];
408            g[j] = g_temp;
409            h.push(hcol);
410
411            // Convergence: |g[j+1]| is the residual norm.
412            if g[j + 1].abs() < tol {
413                converged_at = Some(j);
414                break;
415            }
416            if hcol_last_zero_check(&h[j]) {
417                // Lucky breakdown: h_{j+1,j} was already 0 → exact solution.
418                converged_at = Some(j);
419                break;
420            }
421            if j + 1 < m {
422                let inv = 1.0 / hcol_subdiag(&h[j], j + 1).max(f64::MIN_POSITIVE);
423                let _ = inv;
424                // Build v_{j+1} = w / h[j+1,j]_pre_rotation. Since we
425                // overwrote h[j+1,j] with 0 above, recompute the
426                // norm we used.
427                let norm_w = w.iter().map(|x| x * x).sum::<f64>().sqrt();
428                if norm_w < f64::MIN_POSITIVE * 64.0 {
429                    converged_at = Some(j);
430                    break;
431                }
432                v.push(w.iter().map(|x| x / norm_w).collect());
433            }
434        }
435
436        // Solve upper-triangular H (truncated to k×k) for y.
437        let k = converged_at.map(|j| j + 1).unwrap_or(m);
438        let mut y = vec![0f64; k];
439        for i in (0..k).rev() {
440            let mut s = g[i];
441            for j in (i + 1)..k {
442                s -= h[j][i] * y[j];
443            }
444            y[i] = s / h[i][i];
445        }
446
447        // x = Σ y_j · v_j.
448        for r in 0..n {
449            out[r] = 0.0;
450        }
451        for j in 0..k {
452            for r in 0..n {
453                out[r] += y[j] * v[j][r];
454            }
455        }
456        Ok(())
457    }
458
459    /// Permute `values_A` into the values vector of `Aᵀ`. The
460    /// transposed pattern `(col_idx_t, row_ptr_t)` is assumed
461    /// already-computed (depends only on the original pattern; see
462    /// [`crate::csr_transpose_pattern`] for the pure-Rust helper).
463    pub fn transpose_values(
464        values: &[f64],
465        col_idx: &[i32],
466        row_ptr: &[i32],
467        _col_idx_t: &[i32],
468        row_ptr_t: &[i32],
469        out: &mut [f64],
470    ) -> Result<(), String> {
471        let n = row_ptr.len().saturating_sub(1);
472        let nnz = values.len();
473        if out.len() != nnz {
474            return Err(format!(
475                "transpose_values: out len {} != nnz {nnz}",
476                out.len()
477            ));
478        }
479        // Cursor into the transposed CSR; starts at each row's
480        // row_ptr_t offset and walks forward as we fill.
481        let mut cursor: Vec<usize> = row_ptr_t.iter().map(|&x| x as usize).collect();
482        for r in 0..n {
483            let s = row_ptr[r] as usize;
484            let e = row_ptr[r + 1] as usize;
485            for k in s..e {
486                let c = col_idx[k] as usize;
487                let pos = cursor[c];
488                if pos >= nnz {
489                    return Err(format!(
490                        "transpose_values: cursor[{c}]={pos} ≥ nnz={nnz} \
491                         (transposed pattern likely inconsistent with input)"
492                    ));
493                }
494                out[pos] = values[k];
495                cursor[c] += 1;
496            }
497        }
498        Ok(())
499    }
500
501    /// Jacobi-preconditioned CG. Identical to `cg_solve` except each
502    /// step applies `M⁻¹` (= 1/diag(A)) to the residual before
503    /// search-direction updates. Converges in fewer iterations on
504    /// ill-conditioned matrices where `diag(A)` captures most of the
505    /// spectrum's magnitude variation (true for circuit MNA matrices
506    /// with mixed-magnitude device parameters).
507    pub fn pcg_solve(
508        values: &[f64],
509        col_idx: &[i32],
510        row_ptr: &[i32],
511        b: &[f64],
512        out: &mut [f64],
513        max_iter: u32,
514        tol: f64,
515    ) -> Result<(), String> {
516        let n = b.len();
517        if out.len() != n {
518            return Err(format!("pcg_solve: out len {} != n {n}", out.len()));
519        }
520        if row_ptr.len() != n + 1 {
521            return Err(format!(
522                "pcg_solve: row_ptr len {} != n+1 ({})",
523                row_ptr.len(),
524                n + 1
525            ));
526        }
527
528        // Extract diag(A) from CSR — one O(nnz) pass. Missing
529        // diagonals (zero or absent entries) get a 1.0 fallback so
530        // the preconditioner is well-defined; for SPD A the diagonal
531        // is strictly positive so this guard only matters for
532        // pathological inputs.
533        let mut diag = vec![1.0f64; n];
534        for r in 0..n {
535            for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
536                if col_idx[k] as usize == r {
537                    diag[r] = values[k].max(f64::MIN_POSITIVE);
538                    break;
539                }
540            }
541        }
542        let inv_diag: Vec<f64> = diag.iter().map(|&d| 1.0 / d).collect();
543
544        let matvec = |x: &[f64], y: &mut [f64]| {
545            for r in 0..n {
546                let mut acc = 0f64;
547                for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
548                    acc += values[k] * x[col_idx[k] as usize];
549                }
550                y[r] = acc;
551            }
552        };
553
554        // PCG with x0 = 0: r0 = b, z0 = M⁻¹·r0
555        let mut x = vec![0f64; n];
556        let mut r = b.to_vec();
557        let mut z: Vec<f64> = r.iter().zip(&inv_diag).map(|(rv, mi)| rv * mi).collect();
558        let mut p = z.clone();
559        let mut ap = vec![0f64; n];
560        let mut rho_old: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
561
562        for _ in 0..max_iter {
563            // Convergence on plain ‖r‖₂ (matches CG's contract).
564            let r_norm: f64 = r.iter().map(|v| v * v).sum::<f64>().sqrt();
565            if r_norm < tol {
566                break;
567            }
568            matvec(&p, &mut ap);
569            let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
570            if pap == 0.0 {
571                return Err("pcg_solve: pᵀ·A·p = 0 (A is singular or not SPD)".into());
572            }
573            let alpha = rho_old / pap;
574            for i in 0..n {
575                x[i] += alpha * p[i];
576            }
577            for i in 0..n {
578                r[i] -= alpha * ap[i];
579            }
580            for i in 0..n {
581                z[i] = r[i] * inv_diag[i];
582            }
583            let rho_new: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
584            let beta = rho_new / rho_old;
585            for i in 0..n {
586                p[i] = z[i] + beta * p[i];
587            }
588            rho_old = rho_new;
589        }
590
591        out.copy_from_slice(&x);
592        Ok(())
593    }
594
595    /// Direct sparse Cholesky for SPD A. Densifies to a dense buffer,
596    /// factors via LAPACK `dpotrf`, then forward+back triangular solve
597    /// via `dtrsm`. The mirror of [`lu_solve`] for SPD matrices —
598    /// faster (factor cost ½× LU) and numerically more stable.
599    pub fn cholesky_solve(
600        values: &[f64],
601        col_idx: &[i32],
602        row_ptr: &[i32],
603        b: &[f64],
604        out: &mut [f64],
605    ) -> Result<(), String> {
606        let n = b.len();
607        if out.len() != n {
608            return Err(format!("cholesky_solve: out len {} != n {n}", out.len()));
609        }
610        if row_ptr.len() != n + 1 {
611            return Err(format!(
612                "cholesky_solve: row_ptr len {} != n+1",
613                row_ptr.len()
614            ));
615        }
616        let mut a_dense = vec![0f64; n * n];
617        for r in 0..n {
618            for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
619                a_dense[r * n + col_idx[k] as usize] = values[k];
620            }
621        }
622        // Factor: A = L·Lᵀ; L stored in lower triangle of a_dense.
623        let info = rlx_cpu::blas::dpotrf(&mut a_dense, n, /*lower=*/ true);
624        if info != 0 {
625            return Err(format!("cholesky_solve: dpotrf info={info} (not SPD?)"));
626        }
627        // Solve L·y = b (forward).
628        let mut x = b.to_vec();
629        rlx_cpu::blas::dtrsm_lower_or_upper(
630            &a_dense, &mut x, n, 1, /*lower=*/ true, /*trans=*/ false,
631        );
632        // Solve Lᵀ·x = y (back).
633        rlx_cpu::blas::dtrsm_lower_or_upper(
634            &a_dense, &mut x, n, 1, /*lower=*/ true, /*trans=*/ true,
635        );
636        out.copy_from_slice(&x);
637        Ok(())
638    }
639
640    /// BiCGSTAB for general non-symmetric A. `transpose_a` lets a single
641    /// op solve either A·x = b or Aᵀ·x = b without materializing the
642    /// transpose CSR — used by VJPs for adjoint solves.
643    pub fn bicgstab(
644        values: &[f64],
645        col_idx: &[i32],
646        row_ptr: &[i32],
647        b: &[f64],
648        out: &mut [f64],
649        max_iter: u32,
650        tol: f64,
651        transpose_a: bool,
652    ) -> Result<(), String> {
653        let n = b.len();
654        if out.len() != n {
655            return Err(format!("bicgstab: out len {} != n {n}", out.len()));
656        }
657        if row_ptr.len() != n + 1 {
658            return Err(format!(
659                "bicgstab: row_ptr len {} != n+1 ({})",
660                row_ptr.len(),
661                n + 1
662            ));
663        }
664        let matvec = |x: &[f64], y: &mut [f64]| {
665            if !transpose_a {
666                for r in 0..n {
667                    let mut acc = 0f64;
668                    for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
669                        acc += values[k] * x[col_idx[k] as usize];
670                    }
671                    y[r] = acc;
672                }
673            } else {
674                for v in y.iter_mut() {
675                    *v = 0.0;
676                }
677                for r in 0..n {
678                    for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
679                        y[col_idx[k] as usize] += values[k] * x[r];
680                    }
681                }
682            }
683        };
684        let mut x = vec![0f64; n];
685        let mut r = b.to_vec();
686        let r_hat = r.clone();
687        let mut p = r.clone();
688        let mut v = vec![0f64; n];
689        let mut s = vec![0f64; n];
690        let mut t = vec![0f64; n];
691        let mut rho_old: f64 = r_hat.iter().zip(&r).map(|(a, b)| a * b).sum();
692
693        for _ in 0..max_iter {
694            let r_norm: f64 = r.iter().map(|v| v * v).sum::<f64>().sqrt();
695            if r_norm < tol {
696                break;
697            }
698            matvec(&p, &mut v);
699            let rh_v: f64 = r_hat.iter().zip(&v).map(|(a, b)| a * b).sum();
700            if rh_v == 0.0 {
701                return Err("bicgstab: breakdown r̂·v = 0".into());
702            }
703            let alpha = rho_old / rh_v;
704            for i in 0..n {
705                s[i] = r[i] - alpha * v[i];
706            }
707            let s_norm: f64 = s.iter().map(|v| v * v).sum::<f64>().sqrt();
708            if s_norm < tol {
709                for i in 0..n {
710                    x[i] += alpha * p[i];
711                }
712                r[..n].copy_from_slice(&s[..n]);
713                break;
714            }
715            matvec(&s, &mut t);
716            let tt: f64 = t.iter().map(|v| v * v).sum();
717            if tt == 0.0 {
718                return Err("bicgstab: breakdown t·t = 0".into());
719            }
720            let ts: f64 = t.iter().zip(&s).map(|(a, b)| a * b).sum();
721            let omega = ts / tt;
722            for i in 0..n {
723                x[i] += alpha * p[i] + omega * s[i];
724                r[i] = s[i] - omega * t[i];
725            }
726            if omega == 0.0 {
727                return Err("bicgstab: ω = 0 (stagnation)".into());
728            }
729            let rho_new: f64 = r_hat.iter().zip(&r).map(|(a, b)| a * b).sum();
730            if rho_old == 0.0 {
731                return Err("bicgstab: ρ_old = 0".into());
732            }
733            let beta = (rho_new / rho_old) * (alpha / omega);
734            for i in 0..n {
735                p[i] = r[i] + beta * (p[i] - omega * v[i]);
736            }
737            rho_old = rho_new;
738        }
739        out.copy_from_slice(&x);
740        Ok(())
741    }
742
743    /// LSQR (Paige-Saunders 1982) for sparse least-squares
744    /// `min_x ||A·x - b||₂`. Works for over-determined (m > n) and
745    /// under-determined (m < n) systems; for the latter returns the
746    /// minimum-norm solution. Numerically stable for ill-conditioned
747    /// A — superior to forming the normal equations AᵀA·x = Aᵀ·b
748    /// (which squares the condition number).
749    ///
750    /// Algorithm: Golub-Kahan bidiagonalization with online Givens
751    /// rotations on the resulting bidiagonal least-squares problem.
752    pub fn lsqr_solve(
753        values: &[f64],
754        col_idx: &[i32],
755        row_ptr: &[i32],
756        b: &[f64],
757        out: &mut [f64],
758        max_iter: u32,
759        tol: f64,
760        n_cols: usize,
761    ) -> Result<(), String> {
762        let m = b.len();
763        let n = n_cols;
764        if out.len() != n {
765            return Err(format!("lsqr: out len {} != n {n}", out.len()));
766        }
767        if row_ptr.len() != m + 1 {
768            return Err(format!(
769                "lsqr: row_ptr len {} != m+1 ({})",
770                row_ptr.len(),
771                m + 1
772            ));
773        }
774
775        // y = A·x  (gather over rows of A)
776        let av = |x: &[f64], y: &mut [f64]| {
777            for r in 0..m {
778                let mut acc = 0f64;
779                for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
780                    acc += values[k] * x[col_idx[k] as usize];
781                }
782                y[r] = acc;
783            }
784        };
785        // y = Aᵀ·u  (scatter over rows)
786        let atv = |u: &[f64], y: &mut [f64]| {
787            for v in y.iter_mut() {
788                *v = 0.0;
789            }
790            for r in 0..m {
791                for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
792                    y[col_idx[k] as usize] += values[k] * u[r];
793                }
794            }
795        };
796
797        let mut u = b.to_vec();
798        let mut beta: f64 = u.iter().map(|v| v * v).sum::<f64>().sqrt();
799        if beta == 0.0 {
800            for v in out.iter_mut() {
801                *v = 0.0;
802            }
803            return Ok(());
804        }
805        for v in u.iter_mut() {
806            *v /= beta;
807        }
808
809        let mut v = vec![0f64; n];
810        atv(&u, &mut v);
811        let mut alpha: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
812        if alpha == 0.0 {
813            for v in out.iter_mut() {
814                *v = 0.0;
815            }
816            return Ok(());
817        }
818        for x in v.iter_mut() {
819            *x /= alpha;
820        }
821
822        let mut x = vec![0f64; n];
823        let mut w = v.clone();
824        let mut phi_bar = beta;
825        let mut rho_bar = alpha;
826
827        let mut tmp_u = vec![0f64; m];
828        let mut tmp_v = vec![0f64; n];
829
830        for _ in 0..max_iter {
831            // Bidiagonalization step.
832            // u_new = A·v - alpha·u; β = ||u_new||
833            av(&v, &mut tmp_u);
834            for i in 0..m {
835                tmp_u[i] -= alpha * u[i];
836            }
837            beta = tmp_u.iter().map(|x| x * x).sum::<f64>().sqrt();
838            if beta != 0.0 {
839                for i in 0..m {
840                    u[i] = tmp_u[i] / beta;
841                }
842                // v_new = Aᵀ·u - β·v; α = ||v_new||
843                atv(&u, &mut tmp_v);
844                for i in 0..n {
845                    tmp_v[i] -= beta * v[i];
846                }
847                alpha = tmp_v.iter().map(|x| x * x).sum::<f64>().sqrt();
848                if alpha != 0.0 {
849                    for i in 0..n {
850                        v[i] = tmp_v[i] / alpha;
851                    }
852                }
853            }
854
855            // Givens rotation to eliminate β below ρ̄.
856            let rho = (rho_bar * rho_bar + beta * beta).sqrt();
857            let c = rho_bar / rho;
858            let s = beta / rho;
859            let theta = s * alpha;
860            rho_bar = -c * alpha;
861            let phi = c * phi_bar;
862            phi_bar *= s;
863
864            // Update x and w.
865            let phi_over_rho = phi / rho;
866            let theta_over_rho = theta / rho;
867            for i in 0..n {
868                x[i] += phi_over_rho * w[i];
869                w[i] = v[i] - theta_over_rho * w[i];
870            }
871
872            if phi_bar.abs() < tol {
873                break;
874            }
875            if alpha == 0.0 || beta == 0.0 {
876                break;
877            }
878        }
879        out.copy_from_slice(&x);
880        Ok(())
881    }
882
883    /// In-place ILU(0): factor `values` over CSR sparsity pattern.
884    /// Returns a new value buffer with L (strict lower) below diag and
885    /// U (incl. diag) on/above. L's unit diagonal is implicit.
886    pub fn ilu0_factor(
887        values: &[f64],
888        col_idx: &[i32],
889        row_ptr: &[i32],
890        n: usize,
891        out_fact: &mut [f64],
892    ) -> Result<(), String> {
893        if out_fact.len() != values.len() {
894            return Err(format!(
895                "ilu0: out len {} != values len {}",
896                out_fact.len(),
897                values.len()
898            ));
899        }
900        out_fact.copy_from_slice(values);
901        for i in 0..n {
902            let row_i_start = row_ptr[i] as usize;
903            let row_i_end = row_ptr[i + 1] as usize;
904            for k in row_i_start..row_i_end {
905                let j = col_idx[k] as usize;
906                if j >= i {
907                    break;
908                }
909                // Find a[j,j] in row j.
910                let row_j_start = row_ptr[j] as usize;
911                let row_j_end = row_ptr[j + 1] as usize;
912                let mut a_jj = 0f64;
913                let mut found = false;
914                for kj in row_j_start..row_j_end {
915                    if col_idx[kj] as usize == j {
916                        a_jj = out_fact[kj];
917                        found = true;
918                        break;
919                    }
920                }
921                if !found || a_jj == 0.0 {
922                    return Err(format!("ilu0: zero/missing diag at row {j}"));
923                }
924                out_fact[k] /= a_jj;
925                let lij = out_fact[k];
926                for kk in (k + 1)..row_i_end {
927                    let m = col_idx[kk] as usize;
928                    for kj in row_j_start..row_j_end {
929                        if col_idx[kj] as usize == m {
930                            out_fact[kk] -= lij * out_fact[kj];
931                            break;
932                        }
933                    }
934                }
935            }
936        }
937        Ok(())
938    }
939
940    /// Apply ILU(0): solve `(L·U)·x = b` on the CSR pattern.
941    /// Forward then back triangular sweep over the existing pattern.
942    pub fn ilu0_apply(
943        fact: &[f64],
944        col_idx: &[i32],
945        row_ptr: &[i32],
946        n: usize,
947        b: &[f64],
948        out: &mut [f64],
949    ) {
950        // Forward: L·y = b (unit-diag L). y reuses out.
951        for i in 0..n {
952            let mut acc = b[i];
953            for k in row_ptr[i] as usize..row_ptr[i + 1] as usize {
954                let j = col_idx[k] as usize;
955                if j < i {
956                    acc -= fact[k] * out[j];
957                } else {
958                    break;
959                }
960            }
961            out[i] = acc;
962        }
963        // Back: U·x = y.
964        for i in (0..n).rev() {
965            let mut acc = out[i];
966            let mut diag = 1f64;
967            for k in row_ptr[i] as usize..row_ptr[i + 1] as usize {
968                let j = col_idx[k] as usize;
969                if j > i {
970                    acc -= fact[k] * out[j];
971                } else if j == i {
972                    diag = fact[k];
973                }
974            }
975            out[i] = acc / diag;
976        }
977    }
978
979    /// ILU(0)-preconditioned CG. Same convergence contract as PCG but
980    /// uses incomplete LU as the preconditioner instead of Jacobi.
981    pub fn ilu_pcg_solve(
982        values: &[f64],
983        col_idx: &[i32],
984        row_ptr: &[i32],
985        b: &[f64],
986        out: &mut [f64],
987        max_iter: u32,
988        tol: f64,
989    ) -> Result<(), String> {
990        let n = b.len();
991        if out.len() != n {
992            return Err(format!("ilu_pcg: out len {} != n {n}", out.len()));
993        }
994        let mut fact = vec![0f64; values.len()];
995        ilu0_factor(values, col_idx, row_ptr, n, &mut fact)?;
996        let matvec = |x: &[f64], y: &mut [f64]| {
997            for r in 0..n {
998                let mut acc = 0f64;
999                for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
1000                    acc += values[k] * x[col_idx[k] as usize];
1001                }
1002                y[r] = acc;
1003            }
1004        };
1005        let mut x = vec![0f64; n];
1006        let mut r = b.to_vec();
1007        let mut z = vec![0f64; n];
1008        ilu0_apply(&fact, col_idx, row_ptr, n, &r, &mut z);
1009        let mut p = z.clone();
1010        let mut ap = vec![0f64; n];
1011        let mut rho_old: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
1012        for _ in 0..max_iter {
1013            let r_norm: f64 = r.iter().map(|v| v * v).sum::<f64>().sqrt();
1014            if r_norm < tol {
1015                break;
1016            }
1017            matvec(&p, &mut ap);
1018            let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
1019            if pap == 0.0 {
1020                return Err("ilu_pcg: pᵀ·A·p = 0".into());
1021            }
1022            let alpha = rho_old / pap;
1023            for i in 0..n {
1024                x[i] += alpha * p[i];
1025            }
1026            for i in 0..n {
1027                r[i] -= alpha * ap[i];
1028            }
1029            ilu0_apply(&fact, col_idx, row_ptr, n, &r, &mut z);
1030            let rho_new: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
1031            let beta = rho_new / rho_old;
1032            for i in 0..n {
1033                p[i] = z[i] + beta * p[i];
1034            }
1035            rho_old = rho_new;
1036        }
1037        out.copy_from_slice(&x);
1038        Ok(())
1039    }
1040
1041    /// CSR × CSR → CSR via Gustavson's algorithm. Two-pass:
1042    /// (1) symbolic — count nnz per output row using a row-marker;
1043    /// (2) numeric — accumulate values via a sparse-accumulator (SPA).
1044    /// Returns (c_values, c_col_idx, c_row_ptr).
1045    pub fn spgemm_csr(
1046        a_values: &[f64],
1047        a_col_idx: &[i32],
1048        a_row_ptr: &[i32],
1049        b_values: &[f64],
1050        b_col_idx: &[i32],
1051        b_row_ptr: &[i32],
1052        m: usize,
1053        k: usize,
1054        n: usize,
1055    ) -> Result<(Vec<f64>, Vec<i32>, Vec<i32>), String> {
1056        if a_row_ptr.len() != m + 1 {
1057            return Err(format!("spgemm: a_row_ptr len {} != m+1", a_row_ptr.len()));
1058        }
1059        if b_row_ptr.len() != k + 1 {
1060            return Err(format!("spgemm: b_row_ptr len {} != k+1", b_row_ptr.len()));
1061        }
1062        // Symbolic + numeric in one pass with SPA.
1063        let mut c_row_ptr = vec![0i32; m + 1];
1064        let mut c_col_idx: Vec<i32> = Vec::new();
1065        let mut c_values: Vec<f64> = Vec::new();
1066
1067        // Sparse accumulator: marker[col] = row index where last touched.
1068        let mut marker = vec![-1i32; n];
1069        let mut spa_vals = vec![0f64; n];
1070        let mut spa_cols: Vec<usize> = Vec::with_capacity(n);
1071
1072        for i in 0..m {
1073            spa_cols.clear();
1074            for ka in a_row_ptr[i] as usize..a_row_ptr[i + 1] as usize {
1075                let j = a_col_idx[ka] as usize;
1076                let aij = a_values[ka];
1077                for kb in b_row_ptr[j] as usize..b_row_ptr[j + 1] as usize {
1078                    let l = b_col_idx[kb] as usize;
1079                    let bjl = b_values[kb];
1080                    if marker[l] != i as i32 {
1081                        marker[l] = i as i32;
1082                        spa_vals[l] = aij * bjl;
1083                        spa_cols.push(l);
1084                    } else {
1085                        spa_vals[l] += aij * bjl;
1086                    }
1087                }
1088            }
1089            // Sort columns for canonical CSR ordering.
1090            spa_cols.sort_unstable();
1091            for &l in &spa_cols {
1092                c_col_idx.push(l as i32);
1093                c_values.push(spa_vals[l]);
1094            }
1095            c_row_ptr[i + 1] = c_col_idx.len() as i32;
1096        }
1097        Ok((c_values, c_col_idx, c_row_ptr))
1098    }
1099
1100    fn hcol_last_zero_check(hcol: &[f64]) -> bool {
1101        // After Givens rotation hcol[j+1] is 0 by construction.
1102        // The lucky-breakdown signal is the *un-rotated* subdiagonal
1103        // having been ≈0 — we approximate by checking if every
1104        // h_i value is small. Conservative (slightly over-eager
1105        // termination on near-zero columns).
1106        hcol.iter().all(|v| v.abs() < f64::MIN_POSITIVE * 64.0)
1107    }
1108    fn hcol_subdiag(hcol: &[f64], i: usize) -> f64 {
1109        hcol.get(i).copied().unwrap_or(0.0)
1110    }
1111
1112    pub fn cg_solve(
1113        values: &[f64],
1114        col_idx: &[i32],
1115        row_ptr: &[i32],
1116        b: &[f64],
1117        out: &mut [f64],
1118        max_iter: u32,
1119        tol: f64,
1120    ) -> Result<(), String> {
1121        let n = b.len();
1122        if out.len() != n {
1123            return Err(format!("cg_solve: output len {} != b len {n}", out.len()));
1124        }
1125        if row_ptr.len() != n + 1 {
1126            return Err(format!(
1127                "cg_solve: row_ptr len {} != n+1 ({})",
1128                row_ptr.len(),
1129                n + 1
1130            ));
1131        }
1132        let matvec = |x: &[f64], y: &mut [f64]| {
1133            for r in 0..n {
1134                let mut acc = 0f64;
1135                for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
1136                    acc += values[k] * x[col_idx[k] as usize];
1137                }
1138                y[r] = acc;
1139            }
1140        };
1141        let mut x = vec![0f64; n];
1142        let mut r = b.to_vec();
1143        let mut p = r.clone();
1144        let mut ap = vec![0f64; n];
1145        let mut rs_old: f64 = r.iter().map(|v| v * v).sum();
1146        for _ in 0..max_iter {
1147            if rs_old.sqrt() < tol {
1148                break;
1149            }
1150            matvec(&p, &mut ap);
1151            let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
1152            if pap == 0.0 {
1153                return Err("cg_solve: pᵀ·A·p = 0 (A is singular or not SPD)".into());
1154            }
1155            let alpha = rs_old / pap;
1156            for i in 0..n {
1157                x[i] += alpha * p[i];
1158            }
1159            for i in 0..n {
1160                r[i] -= alpha * ap[i];
1161            }
1162            let rs_new: f64 = r.iter().map(|v| v * v).sum();
1163            let beta = rs_new / rs_old;
1164            for i in 0..n {
1165                p[i] = r[i] + beta * p[i];
1166            }
1167            rs_old = rs_new;
1168        }
1169        out.copy_from_slice(&x);
1170        Ok(())
1171    }
1172}
1173
1174// ── Sparse LU Solve ───────────────────────────────────────────────
1175
1176/// Encode CG attrs into the opaque `Vec<u8>` blob carried on
1177/// `Op::Custom`. Layout: `[max_iter:u32 LE, tol:f64 LE]` — 12 bytes.
1178pub fn encode_cg_attrs(max_iter: u32, tol: f64) -> Vec<u8> {
1179    let mut out = Vec::with_capacity(12);
1180    out.extend_from_slice(&max_iter.to_le_bytes());
1181    out.extend_from_slice(&tol.to_le_bytes());
1182    out
1183}
1184
1185/// CSR × CSR → CSR via Gustavson's algorithm. Pure-Rust convenience
1186/// wrapper around `algos::spgemm_csr` — exposed outside the IR
1187/// because sparsity patterns are structural (and typically static
1188/// across the differentiable training loop), so a one-shot multiply
1189/// at graph-build time is the natural shape. Returns
1190/// `(c_values, c_col_idx, c_row_ptr)` for the output CSR.
1191#[cfg(feature = "cpu")]
1192pub fn spgemm_csr(
1193    a_values: &[f64],
1194    a_col_idx: &[i32],
1195    a_row_ptr: &[i32],
1196    b_values: &[f64],
1197    b_col_idx: &[i32],
1198    b_row_ptr: &[i32],
1199    m: usize,
1200    k: usize,
1201    n: usize,
1202) -> Result<(Vec<f64>, Vec<i32>, Vec<i32>), String> {
1203    algos::spgemm_csr(
1204        a_values, a_col_idx, a_row_ptr, b_values, b_col_idx, b_row_ptr, m, k, n,
1205    )
1206}
1207
1208// ── Pure-Rust helper for the structural CSR transpose pattern ─────
1209
1210/// Compute `(col_idx_T, row_ptr_T)` — the sparsity pattern of `Aᵀ`
1211/// — from `A`'s pattern. This is the structural step that must
1212/// happen before [`SPARSE_TRANSPOSE_VALUES`] can permute the values
1213/// per Newton iteration. Result is independent of the values, so
1214/// downstream callers compute it once and embed as `Op::Constant`.
1215pub fn csr_transpose_pattern(
1216    col_idx: &[i32],
1217    row_ptr: &[i32],
1218    n_rows: usize,
1219    n_cols: usize,
1220) -> (Vec<i32>, Vec<i32>) {
1221    let nnz = col_idx.len();
1222    // Count entries per output-row (= input column).
1223    let mut t_count = vec![0i32; n_cols];
1224    for &c in col_idx {
1225        t_count[c as usize] += 1;
1226    }
1227    let mut t_row_ptr = vec![0i32; n_cols + 1];
1228    for r in 0..n_cols {
1229        t_row_ptr[r + 1] = t_row_ptr[r] + t_count[r];
1230    }
1231    let mut t_col_idx = vec![0i32; nnz];
1232    let mut cursor = t_row_ptr.clone();
1233    for r in 0..n_rows {
1234        for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
1235            let c = col_idx[k] as usize;
1236            let pos = cursor[c] as usize;
1237            t_col_idx[pos] = r as i32;
1238            cursor[c] += 1;
1239        }
1240    }
1241    (t_col_idx, t_row_ptr)
1242}
1243
1244// ── SparseTensor: the boundary abstraction ────────────────────────
1245
1246/// CSR-format sparse matrix at the IR level. Bundles the three
1247/// CSR `NodeId`s with structural shape info known at graph-build time.
1248///
1249/// Modeled on `jax.experimental.sparse.BCOO` — a wrapper around
1250/// `(data, indices)` arrays plus a `shape` tuple, with methods that
1251/// expand to the right subgraph for each operation.
1252#[derive(Clone, Copy, Debug)]
1253pub struct SparseTensor {
1254    /// Non-zero values in row-major CSR order. F64.
1255    pub values: NodeId,
1256    /// Column index per non-zero. I32.
1257    pub col_idx: NodeId,
1258    /// Start index in `values` / `col_idx` per row, length `n_rows + 1`. I32.
1259    pub row_ptr: NodeId,
1260    /// Logical row count of A.
1261    pub n_rows: usize,
1262    /// Logical column count of A (`n_rows == n_cols` for square / SPD).
1263    pub n_cols: usize,
1264}
1265
1266impl SparseTensor {
1267    /// Build from existing CSR `NodeId`s. Caller is responsible for
1268    /// the layout invariants (sortedness within rows,
1269    /// `row_ptr.len() == n_rows + 1`, etc.).
1270    pub fn from_csr(
1271        values: NodeId,
1272        col_idx: NodeId,
1273        row_ptr: NodeId,
1274        n_rows: usize,
1275        n_cols: usize,
1276    ) -> Self {
1277        Self {
1278            values,
1279            col_idx,
1280            row_ptr,
1281            n_rows,
1282            n_cols,
1283        }
1284    }
1285
1286    /// `y = A · x` for a length-`n_cols` dense vector.
1287    pub fn mat_vec(&self, g: &mut Graph, x: NodeId) -> NodeId {
1288        g.custom_op(
1289            SPARSE_MAT_VEC,
1290            Vec::new(),
1291            vec![self.values, self.col_idx, self.row_ptr, x],
1292        )
1293    }
1294
1295    /// `x = A⁻¹ · b` via direct LU.
1296    pub fn solve(&self, g: &mut Graph, b: NodeId) -> NodeId {
1297        assert_eq!(
1298            self.n_rows, self.n_cols,
1299            "SparseTensor::solve requires a square matrix"
1300        );
1301        g.custom_op(
1302            SPARSE_LU_SOLVE,
1303            Vec::new(),
1304            vec![self.values, self.col_idx, self.row_ptr, b],
1305        )
1306    }
1307
1308    /// `x = A⁻¹ · b` via Conjugate Gradient. SPD only. `tol` is the
1309    /// absolute residual threshold; `max_iter` caps iteration count.
1310    pub fn cg_solve(&self, g: &mut Graph, b: NodeId, max_iter: u32, tol: f64) -> NodeId {
1311        assert_eq!(
1312            self.n_rows, self.n_cols,
1313            "SparseTensor::cg_solve requires a square matrix"
1314        );
1315        g.custom_op(
1316            SPARSE_CG_SOLVE,
1317            encode_cg_attrs(max_iter, tol),
1318            vec![self.values, self.col_idx, self.row_ptr, b],
1319        )
1320    }
1321
1322    /// `x = A⁻¹ · b` via direct LU for **non-symmetric** A. The
1323    /// caller supplies an explicit transpose `adjoint` (CSR of `Aᵀ`)
1324    /// — for square non-symmetric matrices, `Aᵀ` has the same
1325    /// nnz pattern as `A` after a CSR↔CSC swap. The forward solve
1326    /// uses only `self`; the VJP routes the adjoint solve through
1327    /// `adjoint`. Use this in place of `solve` when the assumption
1328    /// `Aᵀ = A` would be wrong.
1329    pub fn solve_general(&self, g: &mut Graph, b: NodeId, adjoint: &SparseTensor) -> NodeId {
1330        assert_eq!(
1331            self.n_rows, self.n_cols,
1332            "SparseTensor::solve_general requires a square matrix"
1333        );
1334        assert_eq!(
1335            adjoint.n_rows, self.n_cols,
1336            "adjoint shape mismatch: A is {}×{}, Aᵀ should be {}×{}",
1337            self.n_rows, self.n_cols, self.n_cols, self.n_rows
1338        );
1339        g.custom_op(
1340            SPARSE_LU_SOLVE_GENERAL,
1341            Vec::new(),
1342            vec![
1343                self.values,
1344                self.col_idx,
1345                self.row_ptr,
1346                b,
1347                adjoint.values,
1348                adjoint.col_idx,
1349                adjoint.row_ptr,
1350            ],
1351        )
1352    }
1353
1354    /// `x = A⁻¹ · b` via Jacobi-preconditioned CG. SPD only.
1355    /// Convergence dramatically faster than plain CG on ill-
1356    /// conditioned matrices where `diag(A)` captures most of the
1357    /// magnitude variation — typical for circuit MNA matrices with
1358    /// mixed-magnitude device parameters. The preconditioner is
1359    /// extracted from the CSR by the kernel; no separate input.
1360    pub fn pcg_solve(&self, g: &mut Graph, b: NodeId, max_iter: u32, tol: f64) -> NodeId {
1361        assert_eq!(
1362            self.n_rows, self.n_cols,
1363            "SparseTensor::pcg_solve requires a square matrix"
1364        );
1365        g.custom_op(
1366            SPARSE_PCG_SOLVE,
1367            encode_cg_attrs(max_iter, tol),
1368            vec![self.values, self.col_idx, self.row_ptr, b],
1369        )
1370    }
1371
1372    /// Permute this tensor's `values` into the values vector of
1373    /// `Aᵀ`. The transposed pattern `(col_idx_t, row_ptr_t)` is
1374    /// supplied as `NodeId`s — typically computed once via
1375    /// [`crate::csr_transpose_pattern`] and embedded as `Op::Constant`
1376    /// since the pattern is fixed across Newton iterations.
1377    pub fn transpose_values(&self, g: &mut Graph, col_idx_t: NodeId, row_ptr_t: NodeId) -> NodeId {
1378        g.custom_op(
1379            SPARSE_TRANSPOSE_VALUES,
1380            Vec::new(),
1381            vec![
1382                self.values,
1383                self.col_idx,
1384                self.row_ptr,
1385                col_idx_t,
1386                row_ptr_t,
1387            ],
1388        )
1389    }
1390
1391    /// `x = A⁻¹ · b` via direct sparse Cholesky for SPD A. Densifies
1392    /// into a dense buffer and calls LAPACK `dpotrf` + triangular
1393    /// solves. Mirror of `solve` (LU-based) but ½× factor cost and
1394    /// numerically more stable; only valid when A is SPD.
1395    pub fn cholesky_solve(&self, g: &mut Graph, b: NodeId) -> NodeId {
1396        assert_eq!(
1397            self.n_rows, self.n_cols,
1398            "SparseTensor::cholesky_solve requires a square matrix"
1399        );
1400        g.custom_op(
1401            SPARSE_CHOLESKY_SOLVE,
1402            Vec::new(),
1403            vec![self.values, self.col_idx, self.row_ptr, b],
1404        )
1405    }
1406
1407    /// `x = argmin ||A·x - b||₂` via LSQR (Paige-Saunders 1982).
1408    /// Works for any A (square / over-determined / under-determined);
1409    /// returns the minimum-norm solution when A is rank-deficient or
1410    /// under-determined. VJP not implemented in v1.
1411    pub fn lsqr_solve(&self, g: &mut Graph, b: NodeId, max_iter: u32, tol: f64) -> NodeId {
1412        let mut attrs = Vec::with_capacity(16);
1413        attrs.extend_from_slice(&max_iter.to_le_bytes());
1414        attrs.extend_from_slice(&tol.to_le_bytes());
1415        attrs.extend_from_slice(&(self.n_cols as u32).to_le_bytes());
1416        g.custom_op(
1417            SPARSE_LSQR_SOLVE,
1418            attrs,
1419            vec![self.values, self.col_idx, self.row_ptr, b],
1420        )
1421    }
1422
1423    /// `x = A⁻¹ · b` via BiCGSTAB for **non-symmetric** A. Same
1424    /// shape contract as CG/PCG (no explicit adjoint pattern needed —
1425    /// the kernel itself can solve Aᵀ·x = b via a flag, used by VJPs).
1426    pub fn bicgstab_solve(&self, g: &mut Graph, b: NodeId, max_iter: u32, tol: f64) -> NodeId {
1427        assert_eq!(
1428            self.n_rows, self.n_cols,
1429            "SparseTensor::bicgstab_solve requires a square matrix"
1430        );
1431        let mut attrs = Vec::with_capacity(13);
1432        attrs.extend_from_slice(&max_iter.to_le_bytes());
1433        attrs.extend_from_slice(&tol.to_le_bytes());
1434        attrs.push(0); // transpose_a = false
1435        g.custom_op(
1436            SPARSE_BICGSTAB_SOLVE,
1437            attrs,
1438            vec![self.values, self.col_idx, self.row_ptr, b],
1439        )
1440    }
1441
1442    /// `x = A⁻¹ · b` via ILU(0)-preconditioned CG. SPD A required
1443    /// (same contract as CG/PCG). ILU is factored on each call —
1444    /// for static-pattern Newton loops the cost amortizes against the
1445    /// faster convergence vs. Jacobi-PCG.
1446    pub fn ilu_pcg_solve(&self, g: &mut Graph, b: NodeId, max_iter: u32, tol: f64) -> NodeId {
1447        assert_eq!(
1448            self.n_rows, self.n_cols,
1449            "SparseTensor::ilu_pcg_solve requires a square matrix"
1450        );
1451        g.custom_op(
1452            SPARSE_ILU_PCG_SOLVE,
1453            encode_cg_attrs(max_iter, tol),
1454            vec![self.values, self.col_idx, self.row_ptr, b],
1455        )
1456    }
1457
1458    /// `x = A⁻¹ · b` via GMRES for **non-symmetric** A. Same
1459    /// transpose-triplet contract as `solve_general`. `max_iter`
1460    /// caps Krylov dimension; `tol` is the residual norm threshold.
1461    pub fn gmres_solve(
1462        &self,
1463        g: &mut Graph,
1464        b: NodeId,
1465        max_iter: u32,
1466        tol: f64,
1467        adjoint: &SparseTensor,
1468    ) -> NodeId {
1469        assert_eq!(
1470            self.n_rows, self.n_cols,
1471            "SparseTensor::gmres_solve requires a square matrix"
1472        );
1473        assert_eq!(adjoint.n_rows, self.n_cols, "adjoint shape mismatch");
1474        g.custom_op(
1475            SPARSE_GMRES_SOLVE,
1476            encode_cg_attrs(max_iter, tol),
1477            vec![
1478                self.values,
1479                self.col_idx,
1480                self.row_ptr,
1481                b,
1482                adjoint.values,
1483                adjoint.col_idx,
1484                adjoint.row_ptr,
1485            ],
1486        )
1487    }
1488}
1489
1490// ── Metal kernels ─────────────────────────────────────────────────
1491//
1492// Active with the `metal` feature. The MetalKernel trait gives us
1493// raw `(&[u8], &Shape)` pairs; we cast each to its declared dtype
1494// and call the same `algos::*` body the CpuKernel impls use. Apple
1495// Silicon's unified memory means `Buffer::contents()` is host-
1496// accessible — running these kernels on the Metal backend is no
1497// slower than running on CPU, *provided* the rlx-metal executor's
1498// segment-at-CustomOp dispatch is wired (which it is, as of the
1499// owned-encoder refactor).
1500
1501#[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
1502mod metal_kernels {
1503    use super::*;
1504    use rlx_ir::DType;
1505    use rlx_metal::op_registry::MetalKernel;
1506
1507    /// Cast `&[u8]` → `&[T]` after dtype-checking the accompanying Shape.
1508    /// Length is taken from the Shape's element count (must match the
1509    /// byte-slice length). Caller asserts contiguous + aligned data,
1510    /// which the rlx-metal arena delivers.
1511    unsafe fn typed<'a, T: Copy>(
1512        bytes: &'a [u8],
1513        shape: &rlx_ir::Shape,
1514        want: DType,
1515        role: &str,
1516    ) -> Result<&'a [T], String> {
1517        if shape.dtype() != want {
1518            return Err(format!(
1519                "{role}: expected {want:?}, got {:?}",
1520                shape.dtype()
1521            ));
1522        }
1523        let n = shape
1524            .num_elements()
1525            .ok_or_else(|| format!("{role}: dynamic shape not supported"))?;
1526        let need = n * std::mem::size_of::<T>();
1527        if bytes.len() < need {
1528            return Err(format!("{role}: bytes {} < need {need}", bytes.len()));
1529        }
1530        Ok(unsafe { std::slice::from_raw_parts(bytes.as_ptr() as *const T, n) })
1531    }
1532
1533    unsafe fn typed_mut<'a, T: Copy>(
1534        bytes: &'a mut [u8],
1535        shape: &rlx_ir::Shape,
1536        want: DType,
1537        role: &str,
1538    ) -> Result<&'a mut [T], String> {
1539        if shape.dtype() != want {
1540            return Err(format!(
1541                "{role}: expected {want:?}, got {:?}",
1542                shape.dtype()
1543            ));
1544        }
1545        let n = shape
1546            .num_elements()
1547            .ok_or_else(|| format!("{role}: dynamic shape not supported"))?;
1548        let need = n * std::mem::size_of::<T>();
1549        if bytes.len() < need {
1550            return Err(format!("{role}: bytes {} < need {need}", bytes.len()));
1551        }
1552        Ok(unsafe { std::slice::from_raw_parts_mut(bytes.as_mut_ptr() as *mut T, n) })
1553    }
1554
1555    #[derive(Debug)]
1556    pub(super) struct SparseLuMetal;
1557    impl MetalKernel for SparseLuMetal {
1558        fn name(&self) -> &str {
1559            SPARSE_LU_SOLVE
1560        }
1561        fn execute(
1562            &self,
1563            inputs: &[(&[u8], &rlx_ir::Shape)],
1564            output: (&mut [u8], &rlx_ir::Shape),
1565            _attrs: &[u8],
1566        ) -> Result<(), String> {
1567            unsafe {
1568                let values = typed::<f64>(inputs[0].0, inputs[0].1, DType::F64, "values")?;
1569                let col_idx = typed::<i32>(inputs[1].0, inputs[1].1, DType::I32, "col_idx")?;
1570                let row_ptr = typed::<i32>(inputs[2].0, inputs[2].1, DType::I32, "row_ptr")?;
1571                let b = typed::<f64>(inputs[3].0, inputs[3].1, DType::F64, "b")?;
1572                let out = typed_mut::<f64>(output.0, output.1, DType::F64, "out")?;
1573                algos::lu_solve(values, col_idx, row_ptr, b, out)
1574            }
1575        }
1576    }
1577
1578    #[derive(Debug)]
1579    pub(super) struct SparseMatVecMetal;
1580    impl MetalKernel for SparseMatVecMetal {
1581        fn name(&self) -> &str {
1582            SPARSE_MAT_VEC
1583        }
1584        fn execute(
1585            &self,
1586            inputs: &[(&[u8], &rlx_ir::Shape)],
1587            output: (&mut [u8], &rlx_ir::Shape),
1588            _attrs: &[u8],
1589        ) -> Result<(), String> {
1590            unsafe {
1591                let values = typed::<f64>(inputs[0].0, inputs[0].1, DType::F64, "values")?;
1592                let col_idx = typed::<i32>(inputs[1].0, inputs[1].1, DType::I32, "col_idx")?;
1593                let row_ptr = typed::<i32>(inputs[2].0, inputs[2].1, DType::I32, "row_ptr")?;
1594                let x = typed::<f64>(inputs[3].0, inputs[3].1, DType::F64, "x")?;
1595                let out = typed_mut::<f64>(output.0, output.1, DType::F64, "out")?;
1596                algos::mat_vec(values, col_idx, row_ptr, x, out)
1597            }
1598        }
1599    }
1600
1601    #[derive(Debug)]
1602    pub(super) struct SparseCgMetal;
1603    impl MetalKernel for SparseCgMetal {
1604        fn name(&self) -> &str {
1605            SPARSE_CG_SOLVE
1606        }
1607        fn execute(
1608            &self,
1609            inputs: &[(&[u8], &rlx_ir::Shape)],
1610            output: (&mut [u8], &rlx_ir::Shape),
1611            attrs: &[u8],
1612        ) -> Result<(), String> {
1613            let (max_iter, tol) = decode_cg_attrs(attrs)?;
1614            unsafe {
1615                let values = typed::<f64>(inputs[0].0, inputs[0].1, DType::F64, "values")?;
1616                let col_idx = typed::<i32>(inputs[1].0, inputs[1].1, DType::I32, "col_idx")?;
1617                let row_ptr = typed::<i32>(inputs[2].0, inputs[2].1, DType::I32, "row_ptr")?;
1618                let b = typed::<f64>(inputs[3].0, inputs[3].1, DType::F64, "b")?;
1619                let out = typed_mut::<f64>(output.0, output.1, DType::F64, "out")?;
1620                algos::cg_solve(values, col_idx, row_ptr, b, out, max_iter, tol)
1621            }
1622        }
1623    }
1624
1625    #[derive(Debug)]
1626    pub(super) struct SparseValuesGradMetal;
1627    impl MetalKernel for SparseValuesGradMetal {
1628        fn name(&self) -> &str {
1629            SPARSE_VALUES_GRAD
1630        }
1631        fn execute(
1632            &self,
1633            inputs: &[(&[u8], &rlx_ir::Shape)],
1634            output: (&mut [u8], &rlx_ir::Shape),
1635            _attrs: &[u8],
1636        ) -> Result<(), String> {
1637            unsafe {
1638                let col_idx = typed::<i32>(inputs[0].0, inputs[0].1, DType::I32, "col_idx")?;
1639                let row_ptr = typed::<i32>(inputs[1].0, inputs[1].1, DType::I32, "row_ptr")?;
1640                let u = typed::<f64>(inputs[2].0, inputs[2].1, DType::F64, "u")?;
1641                let v = typed::<f64>(inputs[3].0, inputs[3].1, DType::F64, "v")?;
1642                let out = typed_mut::<f64>(output.0, output.1, DType::F64, "out")?;
1643                algos::values_grad(col_idx, row_ptr, u, v, out)
1644            }
1645        }
1646    }
1647
1648    #[derive(Debug)]
1649    pub(super) struct SparseLuGeneralMetal;
1650    impl MetalKernel for SparseLuGeneralMetal {
1651        fn name(&self) -> &str {
1652            SPARSE_LU_SOLVE_GENERAL
1653        }
1654        fn execute(
1655            &self,
1656            inputs: &[(&[u8], &rlx_ir::Shape)],
1657            output: (&mut [u8], &rlx_ir::Shape),
1658            _attrs: &[u8],
1659        ) -> Result<(), String> {
1660            // Forward only reads A; AT triplet (inputs 4..=6) rides
1661            // along for the VJP path and is unused here.
1662            unsafe {
1663                let values = typed::<f64>(inputs[0].0, inputs[0].1, DType::F64, "values")?;
1664                let col_idx = typed::<i32>(inputs[1].0, inputs[1].1, DType::I32, "col_idx")?;
1665                let row_ptr = typed::<i32>(inputs[2].0, inputs[2].1, DType::I32, "row_ptr")?;
1666                let b = typed::<f64>(inputs[3].0, inputs[3].1, DType::F64, "b")?;
1667                let out = typed_mut::<f64>(output.0, output.1, DType::F64, "out")?;
1668                algos::lu_solve(values, col_idx, row_ptr, b, out)
1669            }
1670        }
1671    }
1672
1673    #[derive(Debug)]
1674    pub(super) struct SparseGmresMetal;
1675    impl MetalKernel for SparseGmresMetal {
1676        fn name(&self) -> &str {
1677            SPARSE_GMRES_SOLVE
1678        }
1679        fn execute(
1680            &self,
1681            inputs: &[(&[u8], &rlx_ir::Shape)],
1682            output: (&mut [u8], &rlx_ir::Shape),
1683            attrs: &[u8],
1684        ) -> Result<(), String> {
1685            let (max_iter, tol) = decode_cg_attrs(attrs)?;
1686            unsafe {
1687                let values = typed::<f64>(inputs[0].0, inputs[0].1, DType::F64, "values")?;
1688                let col_idx = typed::<i32>(inputs[1].0, inputs[1].1, DType::I32, "col_idx")?;
1689                let row_ptr = typed::<i32>(inputs[2].0, inputs[2].1, DType::I32, "row_ptr")?;
1690                let b = typed::<f64>(inputs[3].0, inputs[3].1, DType::F64, "b")?;
1691                let out = typed_mut::<f64>(output.0, output.1, DType::F64, "out")?;
1692                algos::gmres_solve(values, col_idx, row_ptr, b, out, max_iter, tol)
1693            }
1694        }
1695    }
1696}
1697
1698// ── MLX kernels ───────────────────────────────────────────────────
1699//
1700// Active with the `mlx` feature. The `MlxKernel` trait gives us
1701// MLX `Array` handles (lazy graph nodes); we read each input's
1702// bytes, run the same `algos::*` body the CPU + Metal kernels use,
1703// and build a new `Array` of the output shape from the result
1704// bytes. The lazy graph absorbs the new `Array` as the value for
1705// this `Op::Custom` node, so consumers downstream see it as just
1706// another operand.
1707//
1708// Same caveat as the Metal kernels: this is a host-callback that
1709// runs f64 LAPACK on the ARM cores, not GPU compute. The point is
1710// that an MLX-shaped graph that *contains* sparse-LU still routes
1711// correctly through MLX's pipeline; surrounding ops still benefit
1712// from MLX's lazy graph optimizer.
1713
1714#[cfg(all(feature = "mlx", target_os = "macos"))]
1715mod mlx_kernels {
1716    use super::*;
1717    use rlx_ir::DType;
1718    use rlx_mlx::array::{Array, MlxError};
1719    use rlx_mlx::op_registry::MlxKernel;
1720
1721    fn shape_dims_static(s: &rlx_ir::Shape) -> Result<Vec<usize>, MlxError> {
1722        s.dims()
1723            .iter()
1724            .map(|d| match d {
1725                rlx_ir::Dim::Static(n) => Ok(*n),
1726                _ => Err(MlxError(
1727                    "rlx-sparse mlx kernel: dynamic shape not supported".into(),
1728                )),
1729            })
1730            .collect()
1731    }
1732
1733    /// Reinterpret a byte buffer as f64. Length is byte-count / 8.
1734    fn bytes_to_f64(b: &[u8]) -> Vec<f64> {
1735        b.chunks_exact(8)
1736            .map(|c| f64::from_le_bytes(c.try_into().unwrap()))
1737            .collect()
1738    }
1739    fn bytes_to_i32(b: &[u8]) -> Vec<i32> {
1740        b.chunks_exact(4)
1741            .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
1742            .collect()
1743    }
1744    fn f64_to_bytes(xs: &[f64]) -> Vec<u8> {
1745        let mut out = Vec::with_capacity(xs.len() * 8);
1746        for x in xs {
1747            out.extend_from_slice(&x.to_le_bytes());
1748        }
1749        out
1750    }
1751
1752    fn run_lu(inputs: &[&Array], output_shape: &rlx_ir::Shape) -> Result<Array, MlxError> {
1753        let values = bytes_to_f64(&inputs[0].to_bytes()?);
1754        let col_idx = bytes_to_i32(&inputs[1].to_bytes()?);
1755        let row_ptr = bytes_to_i32(&inputs[2].to_bytes()?);
1756        let b = bytes_to_f64(&inputs[3].to_bytes()?);
1757        let mut out = vec![0f64; b.len()];
1758        algos::lu_solve(&values, &col_idx, &row_ptr, &b, &mut out).map_err(MlxError)?;
1759        let dims = shape_dims_static(output_shape)?;
1760        Array::from_bytes(&f64_to_bytes(&out), &dims, DType::F64)
1761    }
1762
1763    fn run_mat_vec(inputs: &[&Array], output_shape: &rlx_ir::Shape) -> Result<Array, MlxError> {
1764        let values = bytes_to_f64(&inputs[0].to_bytes()?);
1765        let col_idx = bytes_to_i32(&inputs[1].to_bytes()?);
1766        let row_ptr = bytes_to_i32(&inputs[2].to_bytes()?);
1767        let x = bytes_to_f64(&inputs[3].to_bytes()?);
1768        let mut out = vec![0f64; x.len()];
1769        algos::mat_vec(&values, &col_idx, &row_ptr, &x, &mut out).map_err(MlxError)?;
1770        let dims = shape_dims_static(output_shape)?;
1771        Array::from_bytes(&f64_to_bytes(&out), &dims, DType::F64)
1772    }
1773
1774    fn run_cg(
1775        inputs: &[&Array],
1776        output_shape: &rlx_ir::Shape,
1777        attrs: &[u8],
1778    ) -> Result<Array, MlxError> {
1779        let (max_iter, tol) = decode_cg_attrs(attrs).map_err(MlxError)?;
1780        let values = bytes_to_f64(&inputs[0].to_bytes()?);
1781        let col_idx = bytes_to_i32(&inputs[1].to_bytes()?);
1782        let row_ptr = bytes_to_i32(&inputs[2].to_bytes()?);
1783        let b = bytes_to_f64(&inputs[3].to_bytes()?);
1784        let mut out = vec![0f64; b.len()];
1785        algos::cg_solve(&values, &col_idx, &row_ptr, &b, &mut out, max_iter, tol)
1786            .map_err(MlxError)?;
1787        let dims = shape_dims_static(output_shape)?;
1788        Array::from_bytes(&f64_to_bytes(&out), &dims, DType::F64)
1789    }
1790
1791    pub(super) struct SparseLuMlx;
1792    impl MlxKernel for SparseLuMlx {
1793        fn name(&self) -> &str {
1794            SPARSE_LU_SOLVE
1795        }
1796        fn execute(
1797            &self,
1798            inputs: &[&Array],
1799            out_shape: &rlx_ir::Shape,
1800            _attrs: &[u8],
1801        ) -> Result<Array, MlxError> {
1802            run_lu(inputs, out_shape)
1803        }
1804    }
1805    pub(super) struct SparseMatVecMlx;
1806    impl MlxKernel for SparseMatVecMlx {
1807        fn name(&self) -> &str {
1808            SPARSE_MAT_VEC
1809        }
1810        fn execute(
1811            &self,
1812            inputs: &[&Array],
1813            out_shape: &rlx_ir::Shape,
1814            _attrs: &[u8],
1815        ) -> Result<Array, MlxError> {
1816            run_mat_vec(inputs, out_shape)
1817        }
1818    }
1819    pub(super) struct SparseCgMlx;
1820    impl MlxKernel for SparseCgMlx {
1821        fn name(&self) -> &str {
1822            SPARSE_CG_SOLVE
1823        }
1824        fn execute(
1825            &self,
1826            inputs: &[&Array],
1827            out_shape: &rlx_ir::Shape,
1828            attrs: &[u8],
1829        ) -> Result<Array, MlxError> {
1830            run_cg(inputs, out_shape, attrs)
1831        }
1832    }
1833
1834    fn run_values_grad(inputs: &[&Array], output_shape: &rlx_ir::Shape) -> Result<Array, MlxError> {
1835        let col_idx = bytes_to_i32(&inputs[0].to_bytes()?);
1836        let row_ptr = bytes_to_i32(&inputs[1].to_bytes()?);
1837        let u = bytes_to_f64(&inputs[2].to_bytes()?);
1838        let v = bytes_to_f64(&inputs[3].to_bytes()?);
1839        let mut out = vec![0f64; col_idx.len()];
1840        algos::values_grad(&col_idx, &row_ptr, &u, &v, &mut out).map_err(MlxError)?;
1841        let dims = shape_dims_static(output_shape)?;
1842        Array::from_bytes(&f64_to_bytes(&out), &dims, DType::F64)
1843    }
1844
1845    fn run_lu_general(inputs: &[&Array], output_shape: &rlx_ir::Shape) -> Result<Array, MlxError> {
1846        // Forward reads only inputs 0..=3 (values_A, col_idx_A,
1847        // row_ptr_A, b); inputs 4..=6 are the AT triplet for the VJP.
1848        let values = bytes_to_f64(&inputs[0].to_bytes()?);
1849        let col_idx = bytes_to_i32(&inputs[1].to_bytes()?);
1850        let row_ptr = bytes_to_i32(&inputs[2].to_bytes()?);
1851        let b = bytes_to_f64(&inputs[3].to_bytes()?);
1852        let mut out = vec![0f64; b.len()];
1853        algos::lu_solve(&values, &col_idx, &row_ptr, &b, &mut out).map_err(MlxError)?;
1854        let dims = shape_dims_static(output_shape)?;
1855        Array::from_bytes(&f64_to_bytes(&out), &dims, DType::F64)
1856    }
1857
1858    fn run_gmres(
1859        inputs: &[&Array],
1860        output_shape: &rlx_ir::Shape,
1861        attrs: &[u8],
1862    ) -> Result<Array, MlxError> {
1863        let (max_iter, tol) = decode_cg_attrs(attrs).map_err(MlxError)?;
1864        let values = bytes_to_f64(&inputs[0].to_bytes()?);
1865        let col_idx = bytes_to_i32(&inputs[1].to_bytes()?);
1866        let row_ptr = bytes_to_i32(&inputs[2].to_bytes()?);
1867        let b = bytes_to_f64(&inputs[3].to_bytes()?);
1868        let mut out = vec![0f64; b.len()];
1869        algos::gmres_solve(&values, &col_idx, &row_ptr, &b, &mut out, max_iter, tol)
1870            .map_err(MlxError)?;
1871        let dims = shape_dims_static(output_shape)?;
1872        Array::from_bytes(&f64_to_bytes(&out), &dims, DType::F64)
1873    }
1874
1875    pub(super) struct SparseValuesGradMlx;
1876    impl MlxKernel for SparseValuesGradMlx {
1877        fn name(&self) -> &str {
1878            SPARSE_VALUES_GRAD
1879        }
1880        fn execute(
1881            &self,
1882            inputs: &[&Array],
1883            out_shape: &rlx_ir::Shape,
1884            _attrs: &[u8],
1885        ) -> Result<Array, MlxError> {
1886            run_values_grad(inputs, out_shape)
1887        }
1888    }
1889    pub(super) struct SparseLuGeneralMlx;
1890    impl MlxKernel for SparseLuGeneralMlx {
1891        fn name(&self) -> &str {
1892            SPARSE_LU_SOLVE_GENERAL
1893        }
1894        fn execute(
1895            &self,
1896            inputs: &[&Array],
1897            out_shape: &rlx_ir::Shape,
1898            _attrs: &[u8],
1899        ) -> Result<Array, MlxError> {
1900            run_lu_general(inputs, out_shape)
1901        }
1902    }
1903    pub(super) struct SparseGmresMlx;
1904    impl MlxKernel for SparseGmresMlx {
1905        fn name(&self) -> &str {
1906            SPARSE_GMRES_SOLVE
1907        }
1908        fn execute(
1909            &self,
1910            inputs: &[&Array],
1911            out_shape: &rlx_ir::Shape,
1912            attrs: &[u8],
1913        ) -> Result<Array, MlxError> {
1914            run_gmres(inputs, out_shape, attrs)
1915        }
1916    }
1917}
1918
1919// ── Registration ──────────────────────────────────────────────────
1920
1921/// Register every sparse op's IR-level extension and per-backend
1922/// kernels enabled at compile time. Idempotent — the underlying
1923/// registries already warn on overwrite. Call once at application
1924/// startup.
1925/// Host CG for SPD `A·x = b` given CSR `(values, col_idx, row_ptr)`.
1926pub fn cg_solve(
1927    values: &[f64],
1928    col_idx: &[i32],
1929    row_ptr: &[i32],
1930    b: &[f64],
1931    out: &mut [f64],
1932    max_iter: u32,
1933    tol: f64,
1934) -> Result<(), String> {
1935    algos::cg_solve(values, col_idx, row_ptr, b, out, max_iter, tol)
1936}
1937
1938pub fn register() {
1939    register_op(Arc::new(SparseLuExt));
1940    register_op(Arc::new(SparseMatVecExt));
1941    register_op(Arc::new(SparseCgExt));
1942    register_op(Arc::new(SparseValuesGradExt));
1943    register_op(Arc::new(SparseLuGeneralExt));
1944    register_op(Arc::new(SparseGmresExt));
1945    register_op(Arc::new(SparseTransposeValuesExt));
1946    register_op(Arc::new(SparsePcgExt));
1947    register_op(Arc::new(SparseBicgstabExt));
1948    register_op(Arc::new(SparseIluPcgExt));
1949    register_op(Arc::new(SparseCholeskyExt));
1950    register_op(Arc::new(SparseLsqrExt));
1951
1952    #[cfg(feature = "cpu")]
1953    {
1954        register_cpu_kernel(Arc::new(SparseLuCpu));
1955        register_cpu_kernel(Arc::new(SparseMatVecCpu));
1956        register_cpu_kernel(Arc::new(SparseCgCpu));
1957        register_cpu_kernel(Arc::new(SparseValuesGradCpu));
1958        register_cpu_kernel(Arc::new(SparseLuGeneralCpu));
1959        register_cpu_kernel(Arc::new(SparseGmresCpu));
1960        register_cpu_kernel(Arc::new(SparseTransposeValuesCpu));
1961        register_cpu_kernel(Arc::new(SparsePcgCpu));
1962        register_cpu_kernel(Arc::new(SparseBicgstabCpu));
1963        register_cpu_kernel(Arc::new(SparseIluPcgCpu));
1964        register_cpu_kernel(Arc::new(SparseCholeskyCpu));
1965        register_cpu_kernel(Arc::new(SparseLsqrCpu));
1966    }
1967
1968    #[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
1969    {
1970        use rlx_metal::op_registry::register_metal_kernel;
1971        register_metal_kernel(Arc::new(metal_kernels::SparseLuMetal));
1972        register_metal_kernel(Arc::new(metal_kernels::SparseMatVecMetal));
1973        register_metal_kernel(Arc::new(metal_kernels::SparseCgMetal));
1974        register_metal_kernel(Arc::new(metal_kernels::SparseValuesGradMetal));
1975        register_metal_kernel(Arc::new(metal_kernels::SparseLuGeneralMetal));
1976        register_metal_kernel(Arc::new(metal_kernels::SparseGmresMetal));
1977    }
1978
1979    #[cfg(all(feature = "mlx", target_os = "macos"))]
1980    {
1981        use rlx_mlx::op_registry::register_mlx_kernel;
1982        register_mlx_kernel(Arc::new(mlx_kernels::SparseLuMlx));
1983        register_mlx_kernel(Arc::new(mlx_kernels::SparseMatVecMlx));
1984        register_mlx_kernel(Arc::new(mlx_kernels::SparseCgMlx));
1985        register_mlx_kernel(Arc::new(mlx_kernels::SparseValuesGradMlx));
1986        register_mlx_kernel(Arc::new(mlx_kernels::SparseLuGeneralMlx));
1987        register_mlx_kernel(Arc::new(mlx_kernels::SparseGmresMlx));
1988    }
1989}