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::{DType, Graph, Node, NodeId, Op, OpExtension, Shape, VjpContext, register_op};
74
75#[cfg(feature = "cpu")]
76use rlx_cpu::op_registry::{CpuKernel, CpuTensorMut, CpuTensorRef, 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
81pub const SPARSE_LU_SOLVE: &str = "rlx_sparse.lu_solve";
82pub const SPARSE_MAT_VEC: &str = "rlx_sparse.mat_vec";
83pub const SPARSE_CG_SOLVE: &str = "rlx_sparse.cg_solve";
84
85/// Outer-product gather op (the `dL/dvalues` building block).
86/// Computes `out[k] = u[row_of(k)] * v[col_idx[k]]` for each non-zero
87/// position `k` in the CSR pattern. Used by `SparseLu`/`SparseMatVec`/
88/// `SparseCg`/`SparseGmres` VJPs to gather the dense outer-product
89/// `u ⊗ v` at the matrix's nonzero positions.
90pub const SPARSE_VALUES_GRAD: &str = "rlx_sparse.values_grad";
91
92/// Non-symmetric LU solve. Forward `x = A⁻¹·b` (uses A only).
93/// VJP `dL/db = solve(Aᵀ, dL/dx)` — needs an explicit transpose
94/// triplet, supplied as the last 3 inputs to keep the IR self-
95/// contained. The 4-input `SPARSE_LU_SOLVE` is the symmetric
96/// specialization.
97pub const SPARSE_LU_SOLVE_GENERAL: &str = "rlx_sparse.lu_solve_general";
98
99/// GMRES solve for non-symmetric systems. Iterative analog of CG
100/// for the asymmetric Maxwell / advection-diffusion regime. Same
101/// 7-input shape as `SPARSE_LU_SOLVE_GENERAL`: forward uses A,
102/// VJP routes the adjoint through Aᵀ.
103pub const SPARSE_GMRES_SOLVE: &str = "rlx_sparse.gmres_solve";
104
105/// Permute a CSR `values` vector into the values vector of `Aᵀ`.
106/// 5 inputs: `(values_A, col_idx_A, row_ptr_A, col_idx_AT, row_ptr_AT)`.
107/// The transposed pattern (`col_idx_AT`, `row_ptr_AT`) is structural
108/// — depends only on the original pattern — so it can be precomputed
109/// once at graph-build time via [`csr_transpose_pattern`] and embedded
110/// as constants. Only the values get permuted per call. Useful for
111/// inverse-design Newton loops where the matrix entries change each
112/// iteration but the sparsity pattern is fixed.
113pub const SPARSE_TRANSPOSE_VALUES: &str = "rlx_sparse.transpose_values";
114
115/// Jacobi-preconditioned CG. Same 4-input shape as `SPARSE_CG_SOLVE`
116/// (values, col_idx, row_ptr, b). The kernel extracts `diag(A)`
117/// internally and uses it as the diagonal preconditioner —
118/// dramatically faster than plain CG on ill-conditioned circuit
119/// matrices where the diagonal magnitudes vary by orders of
120/// magnitude. Convergence requires SPD A like CG.
121pub const SPARSE_PCG_SOLVE: &str = "rlx_sparse.pcg_solve";
122
123/// BiCGSTAB iterative solver for general (non-symmetric) sparse A·x = b.
124/// 4 inputs (values, col_idx, row_ptr, b) + attrs encoding
125/// (max_iter: u32, tol: f64, transpose_a: u8). When `transpose_a` is
126/// set, the kernel solves Aᵀ·x = b — used by VJPs for adjoint solves.
127pub const SPARSE_BICGSTAB_SOLVE: &str = "rlx_sparse.bicgstab_solve";
128
129/// ILU(0)-preconditioned CG. Factors A in-place over its existing
130/// sparsity pattern (zero fill-in) and applies the LU triangular
131/// solves as the preconditioner. Same 4-input shape as PCG; converges
132/// faster than Jacobi-PCG on stiff systems where row-row coupling
133/// dominates the off-diagonal.
134pub const SPARSE_ILU_PCG_SOLVE: &str = "rlx_sparse.ilu_pcg_solve";
135
136/// Direct sparse Cholesky for SPD A·x = b. Densifies A and uses
137/// LAPACK `dpotrf` + triangular solves. Same I/O contract as
138/// `SPARSE_LU_SOLVE` but only valid for SPD matrices — ½× factor cost
139/// of LU and numerically more stable.
140pub const SPARSE_CHOLESKY_SOLVE: &str = "rlx_sparse.cholesky_solve";
141
142/// LSQR for sparse least-squares `min_x ||A·x - b||₂`. 4 inputs
143/// (values, col_idx, row_ptr, b) + attrs encoding (max_iter, tol,
144/// n_cols). Forward only in v1 — VJP returns empty (least-squares
145/// adjoint requires either AᵀA solve or a recursive LSQR call which
146/// is non-trivial; defer until a use case appears).
147pub const SPARSE_LSQR_SOLVE: &str = "rlx_sparse.lsqr_solve";
148
149/// Sparse-sparse matrix multiply (CSR × CSR → CSR). 6 inputs:
150/// (a_values, a_col_idx, a_row_ptr, b_values, b_col_idx, b_row_ptr)
151/// plus attrs encoding (k: u32 = inner dim = b's row count). Output
152/// is a packed buffer `[c_values | c_col_idx_as_f64 | c_row_ptr_as_f64]`
153/// with sizes encoded in attrs alongside k. v1 caps nnz output at
154/// `max_nnz` (attrs); allocate generously for known patterns.
155pub const SPARSE_SPGEMM: &str = "rlx_sparse.spgemm";
156
157// ── Shared algorithms (CPU + Metal kernels both call these) ──────
158//
159// Each function takes the typed slices it needs and returns a
160// `Result<(), String>`. The CpuKernel impls extract typed slices via
161// `CpuTensorRef::expect_*`; the MetalKernel impls extract them by
162// casting raw byte slices (after dtype-checking the accompanying
163// Shape). Both backends end up calling the same arithmetic.
164
165#[cfg(feature = "cpu")]
166mod algos {
167    pub fn lu_solve(
168        values: &[f64],
169        col_idx: &[i32],
170        row_ptr: &[i32],
171        b: &[f64],
172        out: &mut [f64],
173    ) -> Result<(), String> {
174        let n = b.len();
175        if out.len() != n {
176            return Err(format!("sparse_lu: output len {} != b len {n}", out.len()));
177        }
178        if row_ptr.len() != n + 1 {
179            return Err(format!(
180                "sparse_lu: row_ptr len {} != n+1 ({})",
181                row_ptr.len(),
182                n + 1
183            ));
184        }
185        let mut a_dense = vec![0f64; n * n];
186        for r in 0..n {
187            for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
188                a_dense[r * n + col_idx[k] as usize] = values[k];
189            }
190        }
191        let mut b_copy = b.to_vec();
192        let info = rlx_cpu::blas::dgesv(&mut a_dense, &mut b_copy, n, 1);
193        if info != 0 {
194            return Err(format!(
195                "sparse_lu: dgesv returned info={info} (>0 → singular)"
196            ));
197        }
198        out.copy_from_slice(&b_copy);
199        Ok(())
200    }
201
202    pub fn mat_vec(
203        values: &[f64],
204        col_idx: &[i32],
205        row_ptr: &[i32],
206        x: &[f64],
207        out: &mut [f64],
208    ) -> Result<(), String> {
209        let n = x.len();
210        if out.len() != n {
211            return Err(format!("mat_vec: output len {} != x len {n}", out.len()));
212        }
213        if row_ptr.len() != n + 1 {
214            return Err(format!(
215                "mat_vec: row_ptr len {} != n+1 ({})",
216                row_ptr.len(),
217                n + 1
218            ));
219        }
220        for r in 0..n {
221            let mut acc = 0f64;
222            for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
223                acc += values[k] * x[col_idx[k] as usize];
224            }
225            out[r] = acc;
226        }
227        Ok(())
228    }
229
230    /// Outer-product gather at CSR non-zero positions:
231    ///   `out[k] = u[row_of(k)] * v[col_idx[k]]`
232    /// where `row_of(k)` is the row containing the `k`-th non-zero
233    /// (looked up by walking row_ptr; cached as a parallel array
234    /// for O(nnz) lookup time).
235    pub fn values_grad(
236        col_idx: &[i32],
237        row_ptr: &[i32],
238        u: &[f64],
239        v: &[f64],
240        out: &mut [f64],
241    ) -> Result<(), String> {
242        let n = u.len();
243        let nnz = col_idx.len();
244        if out.len() != nnz {
245            return Err(format!("values_grad: out len {} != nnz {nnz}", out.len()));
246        }
247        if row_ptr.len() != n + 1 {
248            return Err(format!(
249                "values_grad: row_ptr len {} != n+1 ({})",
250                row_ptr.len(),
251                n + 1
252            ));
253        }
254        // Build row_of_k by scanning row_ptr.
255        let mut row_of_k = vec![0u32; nnz];
256        for r in 0..n {
257            let s = row_ptr[r] as usize;
258            let e = row_ptr[r + 1] as usize;
259            for k in s..e {
260                row_of_k[k] = r as u32;
261            }
262        }
263        for k in 0..nnz {
264            let r = row_of_k[k] as usize;
265            let c = col_idx[k] as usize;
266            if r >= n || c >= v.len() {
267                return Err(format!(
268                    "values_grad: k={k} (row={r}, col={c}) out of bounds"
269                ));
270            }
271            out[k] = u[r] * v[c];
272        }
273        Ok(())
274    }
275
276    /// GMRES with online Givens-rotation QR on the Hessenberg
277    /// system. Standard textbook Saad-Schultz formulation:
278    ///
279    ///   x = 0; r = b;  β = ||r||;  v_1 = r/β
280    ///   for j in 1..=m:
281    ///     w = A·v_j; orthogonalize w against v_1..v_j (Modified
282    ///     Gram-Schmidt) → h_{i,j} entries; w' = ||w||; v_{j+1} = w/w'
283    ///     apply prior Givens rotations to column j of H, generate
284    ///     new rotation that zeroes the subdiagonal of column j,
285    ///     update transformed RHS β·e_1
286    ///     if |residual| < tol: break
287    ///   solve upper-triangular system → y; x = Σ_j y_j · v_j
288    ///
289    /// Restart not implemented — `max_iter` caps Krylov dimension
290    /// directly. For ill-conditioned systems set `max_iter` to a
291    /// moderate value and re-solve with restart in the application.
292    pub fn gmres_solve(
293        values: &[f64],
294        col_idx: &[i32],
295        row_ptr: &[i32],
296        b: &[f64],
297        out: &mut [f64],
298        max_iter: u32,
299        tol: f64,
300    ) -> Result<(), String> {
301        let n = b.len();
302        if out.len() != n {
303            return Err(format!("gmres_solve: out len {} != n {n}", out.len()));
304        }
305        if row_ptr.len() != n + 1 {
306            return Err(format!(
307                "gmres_solve: row_ptr len {} != n+1 ({})",
308                row_ptr.len(),
309                n + 1
310            ));
311        }
312        let m = max_iter.max(1) as usize;
313
314        let matvec = |x: &[f64], y: &mut [f64]| {
315            for r in 0..n {
316                let mut acc = 0f64;
317                for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
318                    acc += values[k] * x[col_idx[k] as usize];
319                }
320                y[r] = acc;
321            }
322        };
323
324        // x_0 = 0; r_0 = b; β = ||b||
325        let beta_init = b.iter().map(|v| v * v).sum::<f64>().sqrt();
326        if beta_init < tol {
327            for v in out.iter_mut() {
328                *v = 0.0;
329            }
330            return Ok(());
331        }
332
333        // Krylov basis: v_1..v_{m+1}, each length n.
334        let mut v: Vec<Vec<f64>> = Vec::with_capacity(m + 1);
335        v.push(b.iter().map(|x| x / beta_init).collect());
336
337        // Hessenberg matrix H is (m+1)×m; stored as rows-vector for
338        // online QR via Givens. We track c_j, s_j (Givens cos/sin)
339        // and the transformed RHS g (length m+1, initially β·e_1).
340        let mut h: Vec<Vec<f64>> = Vec::with_capacity(m); // h[j] = column j (length j+2)
341        let mut cs: Vec<f64> = Vec::with_capacity(m);
342        let mut sn: Vec<f64> = Vec::with_capacity(m);
343        let mut g: Vec<f64> = vec![0.0; m + 1];
344        g[0] = beta_init;
345
346        let mut converged_at: Option<usize> = None;
347        let mut w = vec![0f64; n];
348
349        for j in 0..m {
350            matvec(&v[j], &mut w);
351            // MGS orthogonalization: build column j of H (entries 0..=j+1).
352            let mut hcol = vec![0f64; j + 2];
353            for i in 0..=j {
354                hcol[i] = w.iter().zip(&v[i]).map(|(a, b)| a * b).sum();
355                for k in 0..n {
356                    w[k] -= hcol[i] * v[i][k];
357                }
358            }
359            hcol[j + 1] = w.iter().map(|x| x * x).sum::<f64>().sqrt();
360            // Apply previous Givens rotations to column j.
361            for i in 0..j {
362                let temp = cs[i] * hcol[i] + sn[i] * hcol[i + 1];
363                hcol[i + 1] = -sn[i] * hcol[i] + cs[i] * hcol[i + 1];
364                hcol[i] = temp;
365            }
366            // Generate Givens rotation to zero hcol[j+1].
367            let denom = (hcol[j] * hcol[j] + hcol[j + 1] * hcol[j + 1]).sqrt();
368            if denom == 0.0 {
369                return Err("gmres_solve: breakdown (denom = 0)".into());
370            }
371            let c = hcol[j] / denom;
372            let s = hcol[j + 1] / denom;
373            cs.push(c);
374            sn.push(s);
375            hcol[j] = c * hcol[j] + s * hcol[j + 1];
376            hcol[j + 1] = 0.0;
377            // Apply rotation to RHS g.
378            let g_temp = c * g[j] + s * g[j + 1];
379            g[j + 1] = -s * g[j] + c * g[j + 1];
380            g[j] = g_temp;
381            h.push(hcol);
382
383            // Convergence: |g[j+1]| is the residual norm.
384            if g[j + 1].abs() < tol {
385                converged_at = Some(j);
386                break;
387            }
388            if hcol_last_zero_check(&h[j]) {
389                // Lucky breakdown: h_{j+1,j} was already 0 → exact solution.
390                converged_at = Some(j);
391                break;
392            }
393            if j + 1 < m {
394                let inv = 1.0 / hcol_subdiag(&h[j], j + 1).max(f64::MIN_POSITIVE);
395                let _ = inv;
396                // Build v_{j+1} = w / h[j+1,j]_pre_rotation. Since we
397                // overwrote h[j+1,j] with 0 above, recompute the
398                // norm we used.
399                let norm_w = w.iter().map(|x| x * x).sum::<f64>().sqrt();
400                if norm_w < f64::MIN_POSITIVE * 64.0 {
401                    converged_at = Some(j);
402                    break;
403                }
404                v.push(w.iter().map(|x| x / norm_w).collect());
405            }
406        }
407
408        // Solve upper-triangular H (truncated to k×k) for y.
409        let k = converged_at.map(|j| j + 1).unwrap_or(m);
410        let mut y = vec![0f64; k];
411        for i in (0..k).rev() {
412            let mut s = g[i];
413            for j in (i + 1)..k {
414                s -= h[j][i] * y[j];
415            }
416            y[i] = s / h[i][i];
417        }
418
419        // x = Σ y_j · v_j.
420        for r in 0..n {
421            out[r] = 0.0;
422        }
423        for j in 0..k {
424            for r in 0..n {
425                out[r] += y[j] * v[j][r];
426            }
427        }
428        Ok(())
429    }
430
431    /// Permute `values_A` into the values vector of `Aᵀ`. The
432    /// transposed pattern `(col_idx_t, row_ptr_t)` is assumed
433    /// already-computed (depends only on the original pattern; see
434    /// [`crate::csr_transpose_pattern`] for the pure-Rust helper).
435    pub fn transpose_values(
436        values: &[f64],
437        col_idx: &[i32],
438        row_ptr: &[i32],
439        _col_idx_t: &[i32],
440        row_ptr_t: &[i32],
441        out: &mut [f64],
442    ) -> Result<(), String> {
443        let n = row_ptr.len().saturating_sub(1);
444        let nnz = values.len();
445        if out.len() != nnz {
446            return Err(format!(
447                "transpose_values: out len {} != nnz {nnz}",
448                out.len()
449            ));
450        }
451        // Cursor into the transposed CSR; starts at each row's
452        // row_ptr_t offset and walks forward as we fill.
453        let mut cursor: Vec<usize> = row_ptr_t.iter().map(|&x| x as usize).collect();
454        for r in 0..n {
455            let s = row_ptr[r] as usize;
456            let e = row_ptr[r + 1] as usize;
457            for k in s..e {
458                let c = col_idx[k] as usize;
459                let pos = cursor[c];
460                if pos >= nnz {
461                    return Err(format!(
462                        "transpose_values: cursor[{c}]={pos} ≥ nnz={nnz} \
463                         (transposed pattern likely inconsistent with input)"
464                    ));
465                }
466                out[pos] = values[k];
467                cursor[c] += 1;
468            }
469        }
470        Ok(())
471    }
472
473    /// Jacobi-preconditioned CG. Identical to `cg_solve` except each
474    /// step applies `M⁻¹` (= 1/diag(A)) to the residual before
475    /// search-direction updates. Converges in fewer iterations on
476    /// ill-conditioned matrices where `diag(A)` captures most of the
477    /// spectrum's magnitude variation (true for circuit MNA matrices
478    /// with mixed-magnitude device parameters).
479    pub fn pcg_solve(
480        values: &[f64],
481        col_idx: &[i32],
482        row_ptr: &[i32],
483        b: &[f64],
484        out: &mut [f64],
485        max_iter: u32,
486        tol: f64,
487    ) -> Result<(), String> {
488        let n = b.len();
489        if out.len() != n {
490            return Err(format!("pcg_solve: out len {} != n {n}", out.len()));
491        }
492        if row_ptr.len() != n + 1 {
493            return Err(format!(
494                "pcg_solve: row_ptr len {} != n+1 ({})",
495                row_ptr.len(),
496                n + 1
497            ));
498        }
499
500        // Extract diag(A) from CSR — one O(nnz) pass. Missing
501        // diagonals (zero or absent entries) get a 1.0 fallback so
502        // the preconditioner is well-defined; for SPD A the diagonal
503        // is strictly positive so this guard only matters for
504        // pathological inputs.
505        let mut diag = vec![1.0f64; n];
506        for r in 0..n {
507            for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
508                if col_idx[k] as usize == r {
509                    diag[r] = values[k].max(f64::MIN_POSITIVE);
510                    break;
511                }
512            }
513        }
514        let inv_diag: Vec<f64> = diag.iter().map(|&d| 1.0 / d).collect();
515
516        let matvec = |x: &[f64], y: &mut [f64]| {
517            for r in 0..n {
518                let mut acc = 0f64;
519                for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
520                    acc += values[k] * x[col_idx[k] as usize];
521                }
522                y[r] = acc;
523            }
524        };
525
526        // PCG with x0 = 0: r0 = b, z0 = M⁻¹·r0
527        let mut x = vec![0f64; n];
528        let mut r = b.to_vec();
529        let mut z: Vec<f64> = r.iter().zip(&inv_diag).map(|(rv, mi)| rv * mi).collect();
530        let mut p = z.clone();
531        let mut ap = vec![0f64; n];
532        let mut rho_old: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
533
534        for _ in 0..max_iter {
535            // Convergence on plain ‖r‖₂ (matches CG's contract).
536            let r_norm: f64 = r.iter().map(|v| v * v).sum::<f64>().sqrt();
537            if r_norm < tol {
538                break;
539            }
540            matvec(&p, &mut ap);
541            let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
542            if pap == 0.0 {
543                return Err("pcg_solve: pᵀ·A·p = 0 (A is singular or not SPD)".into());
544            }
545            let alpha = rho_old / pap;
546            for i in 0..n {
547                x[i] += alpha * p[i];
548            }
549            for i in 0..n {
550                r[i] -= alpha * ap[i];
551            }
552            for i in 0..n {
553                z[i] = r[i] * inv_diag[i];
554            }
555            let rho_new: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
556            let beta = rho_new / rho_old;
557            for i in 0..n {
558                p[i] = z[i] + beta * p[i];
559            }
560            rho_old = rho_new;
561        }
562
563        out.copy_from_slice(&x);
564        Ok(())
565    }
566
567    /// Direct sparse Cholesky for SPD A. Densifies to a dense buffer,
568    /// factors via LAPACK `dpotrf`, then forward+back triangular solve
569    /// via `dtrsm`. The mirror of [`lu_solve`] for SPD matrices —
570    /// faster (factor cost ½× LU) and numerically more stable.
571    pub fn cholesky_solve(
572        values: &[f64],
573        col_idx: &[i32],
574        row_ptr: &[i32],
575        b: &[f64],
576        out: &mut [f64],
577    ) -> Result<(), String> {
578        let n = b.len();
579        if out.len() != n {
580            return Err(format!("cholesky_solve: out len {} != n {n}", out.len()));
581        }
582        if row_ptr.len() != n + 1 {
583            return Err(format!(
584                "cholesky_solve: row_ptr len {} != n+1",
585                row_ptr.len()
586            ));
587        }
588        let mut a_dense = vec![0f64; n * n];
589        for r in 0..n {
590            for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
591                a_dense[r * n + col_idx[k] as usize] = values[k];
592            }
593        }
594        // Factor: A = L·Lᵀ; L stored in lower triangle of a_dense.
595        let info = rlx_cpu::blas::dpotrf(&mut a_dense, n, /*lower=*/ true);
596        if info != 0 {
597            return Err(format!("cholesky_solve: dpotrf info={info} (not SPD?)"));
598        }
599        // Solve L·y = b (forward).
600        let mut x = b.to_vec();
601        rlx_cpu::blas::dtrsm_lower_or_upper(
602            &a_dense, &mut x, n, 1, /*lower=*/ true, /*trans=*/ false,
603        );
604        // Solve Lᵀ·x = y (back).
605        rlx_cpu::blas::dtrsm_lower_or_upper(
606            &a_dense, &mut x, n, 1, /*lower=*/ true, /*trans=*/ true,
607        );
608        out.copy_from_slice(&x);
609        Ok(())
610    }
611
612    /// BiCGSTAB for general non-symmetric A. `transpose_a` lets a single
613    /// op solve either A·x = b or Aᵀ·x = b without materializing the
614    /// transpose CSR — used by VJPs for adjoint solves.
615    pub fn bicgstab(
616        values: &[f64],
617        col_idx: &[i32],
618        row_ptr: &[i32],
619        b: &[f64],
620        out: &mut [f64],
621        max_iter: u32,
622        tol: f64,
623        transpose_a: bool,
624    ) -> Result<(), String> {
625        let n = b.len();
626        if out.len() != n {
627            return Err(format!("bicgstab: out len {} != n {n}", out.len()));
628        }
629        if row_ptr.len() != n + 1 {
630            return Err(format!(
631                "bicgstab: row_ptr len {} != n+1 ({})",
632                row_ptr.len(),
633                n + 1
634            ));
635        }
636        let matvec = |x: &[f64], y: &mut [f64]| {
637            if !transpose_a {
638                for r in 0..n {
639                    let mut acc = 0f64;
640                    for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
641                        acc += values[k] * x[col_idx[k] as usize];
642                    }
643                    y[r] = acc;
644                }
645            } else {
646                for v in y.iter_mut() {
647                    *v = 0.0;
648                }
649                for r in 0..n {
650                    for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
651                        y[col_idx[k] as usize] += values[k] * x[r];
652                    }
653                }
654            }
655        };
656        let mut x = vec![0f64; n];
657        let mut r = b.to_vec();
658        let r_hat = r.clone();
659        let mut p = r.clone();
660        let mut v = vec![0f64; n];
661        let mut s = vec![0f64; n];
662        let mut t = vec![0f64; n];
663        let mut rho_old: f64 = r_hat.iter().zip(&r).map(|(a, b)| a * b).sum();
664
665        for _ in 0..max_iter {
666            let r_norm: f64 = r.iter().map(|v| v * v).sum::<f64>().sqrt();
667            if r_norm < tol {
668                break;
669            }
670            matvec(&p, &mut v);
671            let rh_v: f64 = r_hat.iter().zip(&v).map(|(a, b)| a * b).sum();
672            if rh_v == 0.0 {
673                return Err("bicgstab: breakdown r̂·v = 0".into());
674            }
675            let alpha = rho_old / rh_v;
676            for i in 0..n {
677                s[i] = r[i] - alpha * v[i];
678            }
679            let s_norm: f64 = s.iter().map(|v| v * v).sum::<f64>().sqrt();
680            if s_norm < tol {
681                for i in 0..n {
682                    x[i] += alpha * p[i];
683                }
684                r[..n].copy_from_slice(&s[..n]);
685                break;
686            }
687            matvec(&s, &mut t);
688            let tt: f64 = t.iter().map(|v| v * v).sum();
689            if tt == 0.0 {
690                return Err("bicgstab: breakdown t·t = 0".into());
691            }
692            let ts: f64 = t.iter().zip(&s).map(|(a, b)| a * b).sum();
693            let omega = ts / tt;
694            for i in 0..n {
695                x[i] += alpha * p[i] + omega * s[i];
696                r[i] = s[i] - omega * t[i];
697            }
698            if omega == 0.0 {
699                return Err("bicgstab: ω = 0 (stagnation)".into());
700            }
701            let rho_new: f64 = r_hat.iter().zip(&r).map(|(a, b)| a * b).sum();
702            if rho_old == 0.0 {
703                return Err("bicgstab: ρ_old = 0".into());
704            }
705            let beta = (rho_new / rho_old) * (alpha / omega);
706            for i in 0..n {
707                p[i] = r[i] + beta * (p[i] - omega * v[i]);
708            }
709            rho_old = rho_new;
710        }
711        out.copy_from_slice(&x);
712        Ok(())
713    }
714
715    /// LSQR (Paige-Saunders 1982) for sparse least-squares
716    /// `min_x ||A·x - b||₂`. Works for over-determined (m > n) and
717    /// under-determined (m < n) systems; for the latter returns the
718    /// minimum-norm solution. Numerically stable for ill-conditioned
719    /// A — superior to forming the normal equations AᵀA·x = Aᵀ·b
720    /// (which squares the condition number).
721    ///
722    /// Algorithm: Golub-Kahan bidiagonalization with online Givens
723    /// rotations on the resulting bidiagonal least-squares problem.
724    pub fn lsqr_solve(
725        values: &[f64],
726        col_idx: &[i32],
727        row_ptr: &[i32],
728        b: &[f64],
729        out: &mut [f64],
730        max_iter: u32,
731        tol: f64,
732        n_cols: usize,
733    ) -> Result<(), String> {
734        let m = b.len();
735        let n = n_cols;
736        if out.len() != n {
737            return Err(format!("lsqr: out len {} != n {n}", out.len()));
738        }
739        if row_ptr.len() != m + 1 {
740            return Err(format!(
741                "lsqr: row_ptr len {} != m+1 ({})",
742                row_ptr.len(),
743                m + 1
744            ));
745        }
746
747        // y = A·x  (gather over rows of A)
748        let av = |x: &[f64], y: &mut [f64]| {
749            for r in 0..m {
750                let mut acc = 0f64;
751                for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
752                    acc += values[k] * x[col_idx[k] as usize];
753                }
754                y[r] = acc;
755            }
756        };
757        // y = Aᵀ·u  (scatter over rows)
758        let atv = |u: &[f64], y: &mut [f64]| {
759            for v in y.iter_mut() {
760                *v = 0.0;
761            }
762            for r in 0..m {
763                for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
764                    y[col_idx[k] as usize] += values[k] * u[r];
765                }
766            }
767        };
768
769        let mut u = b.to_vec();
770        let mut beta: f64 = u.iter().map(|v| v * v).sum::<f64>().sqrt();
771        if beta == 0.0 {
772            for v in out.iter_mut() {
773                *v = 0.0;
774            }
775            return Ok(());
776        }
777        for v in u.iter_mut() {
778            *v /= beta;
779        }
780
781        let mut v = vec![0f64; n];
782        atv(&u, &mut v);
783        let mut alpha: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
784        if alpha == 0.0 {
785            for v in out.iter_mut() {
786                *v = 0.0;
787            }
788            return Ok(());
789        }
790        for x in v.iter_mut() {
791            *x /= alpha;
792        }
793
794        let mut x = vec![0f64; n];
795        let mut w = v.clone();
796        let mut phi_bar = beta;
797        let mut rho_bar = alpha;
798
799        let mut tmp_u = vec![0f64; m];
800        let mut tmp_v = vec![0f64; n];
801
802        for _ in 0..max_iter {
803            // Bidiagonalization step.
804            // u_new = A·v - alpha·u; β = ||u_new||
805            av(&v, &mut tmp_u);
806            for i in 0..m {
807                tmp_u[i] -= alpha * u[i];
808            }
809            beta = tmp_u.iter().map(|x| x * x).sum::<f64>().sqrt();
810            if beta != 0.0 {
811                for i in 0..m {
812                    u[i] = tmp_u[i] / beta;
813                }
814                // v_new = Aᵀ·u - β·v; α = ||v_new||
815                atv(&u, &mut tmp_v);
816                for i in 0..n {
817                    tmp_v[i] -= beta * v[i];
818                }
819                alpha = tmp_v.iter().map(|x| x * x).sum::<f64>().sqrt();
820                if alpha != 0.0 {
821                    for i in 0..n {
822                        v[i] = tmp_v[i] / alpha;
823                    }
824                }
825            }
826
827            // Givens rotation to eliminate β below ρ̄.
828            let rho = (rho_bar * rho_bar + beta * beta).sqrt();
829            let c = rho_bar / rho;
830            let s = beta / rho;
831            let theta = s * alpha;
832            rho_bar = -c * alpha;
833            let phi = c * phi_bar;
834            phi_bar *= s;
835
836            // Update x and w.
837            let phi_over_rho = phi / rho;
838            let theta_over_rho = theta / rho;
839            for i in 0..n {
840                x[i] += phi_over_rho * w[i];
841                w[i] = v[i] - theta_over_rho * w[i];
842            }
843
844            if phi_bar.abs() < tol {
845                break;
846            }
847            if alpha == 0.0 || beta == 0.0 {
848                break;
849            }
850        }
851        out.copy_from_slice(&x);
852        Ok(())
853    }
854
855    /// In-place ILU(0): factor `values` over CSR sparsity pattern.
856    /// Returns a new value buffer with L (strict lower) below diag and
857    /// U (incl. diag) on/above. L's unit diagonal is implicit.
858    pub fn ilu0_factor(
859        values: &[f64],
860        col_idx: &[i32],
861        row_ptr: &[i32],
862        n: usize,
863        out_fact: &mut [f64],
864    ) -> Result<(), String> {
865        if out_fact.len() != values.len() {
866            return Err(format!(
867                "ilu0: out len {} != values len {}",
868                out_fact.len(),
869                values.len()
870            ));
871        }
872        out_fact.copy_from_slice(values);
873        for i in 0..n {
874            let row_i_start = row_ptr[i] as usize;
875            let row_i_end = row_ptr[i + 1] as usize;
876            for k in row_i_start..row_i_end {
877                let j = col_idx[k] as usize;
878                if j >= i {
879                    break;
880                }
881                // Find a[j,j] in row j.
882                let row_j_start = row_ptr[j] as usize;
883                let row_j_end = row_ptr[j + 1] as usize;
884                let mut a_jj = 0f64;
885                let mut found = false;
886                for kj in row_j_start..row_j_end {
887                    if col_idx[kj] as usize == j {
888                        a_jj = out_fact[kj];
889                        found = true;
890                        break;
891                    }
892                }
893                if !found || a_jj == 0.0 {
894                    return Err(format!("ilu0: zero/missing diag at row {j}"));
895                }
896                out_fact[k] /= a_jj;
897                let lij = out_fact[k];
898                for kk in (k + 1)..row_i_end {
899                    let m = col_idx[kk] as usize;
900                    for kj in row_j_start..row_j_end {
901                        if col_idx[kj] as usize == m {
902                            out_fact[kk] -= lij * out_fact[kj];
903                            break;
904                        }
905                    }
906                }
907            }
908        }
909        Ok(())
910    }
911
912    /// Apply ILU(0): solve `(L·U)·x = b` on the CSR pattern.
913    /// Forward then back triangular sweep over the existing pattern.
914    pub fn ilu0_apply(
915        fact: &[f64],
916        col_idx: &[i32],
917        row_ptr: &[i32],
918        n: usize,
919        b: &[f64],
920        out: &mut [f64],
921    ) {
922        // Forward: L·y = b (unit-diag L). y reuses out.
923        for i in 0..n {
924            let mut acc = b[i];
925            for k in row_ptr[i] as usize..row_ptr[i + 1] as usize {
926                let j = col_idx[k] as usize;
927                if j < i {
928                    acc -= fact[k] * out[j];
929                } else {
930                    break;
931                }
932            }
933            out[i] = acc;
934        }
935        // Back: U·x = y.
936        for i in (0..n).rev() {
937            let mut acc = out[i];
938            let mut diag = 1f64;
939            for k in row_ptr[i] as usize..row_ptr[i + 1] as usize {
940                let j = col_idx[k] as usize;
941                if j > i {
942                    acc -= fact[k] * out[j];
943                } else if j == i {
944                    diag = fact[k];
945                }
946            }
947            out[i] = acc / diag;
948        }
949    }
950
951    /// ILU(0)-preconditioned CG. Same convergence contract as PCG but
952    /// uses incomplete LU as the preconditioner instead of Jacobi.
953    pub fn ilu_pcg_solve(
954        values: &[f64],
955        col_idx: &[i32],
956        row_ptr: &[i32],
957        b: &[f64],
958        out: &mut [f64],
959        max_iter: u32,
960        tol: f64,
961    ) -> Result<(), String> {
962        let n = b.len();
963        if out.len() != n {
964            return Err(format!("ilu_pcg: out len {} != n {n}", out.len()));
965        }
966        let mut fact = vec![0f64; values.len()];
967        ilu0_factor(values, col_idx, row_ptr, n, &mut fact)?;
968        let matvec = |x: &[f64], y: &mut [f64]| {
969            for r in 0..n {
970                let mut acc = 0f64;
971                for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
972                    acc += values[k] * x[col_idx[k] as usize];
973                }
974                y[r] = acc;
975            }
976        };
977        let mut x = vec![0f64; n];
978        let mut r = b.to_vec();
979        let mut z = vec![0f64; n];
980        ilu0_apply(&fact, col_idx, row_ptr, n, &r, &mut z);
981        let mut p = z.clone();
982        let mut ap = vec![0f64; n];
983        let mut rho_old: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
984        for _ in 0..max_iter {
985            let r_norm: f64 = r.iter().map(|v| v * v).sum::<f64>().sqrt();
986            if r_norm < tol {
987                break;
988            }
989            matvec(&p, &mut ap);
990            let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
991            if pap == 0.0 {
992                return Err("ilu_pcg: pᵀ·A·p = 0".into());
993            }
994            let alpha = rho_old / pap;
995            for i in 0..n {
996                x[i] += alpha * p[i];
997            }
998            for i in 0..n {
999                r[i] -= alpha * ap[i];
1000            }
1001            ilu0_apply(&fact, col_idx, row_ptr, n, &r, &mut z);
1002            let rho_new: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
1003            let beta = rho_new / rho_old;
1004            for i in 0..n {
1005                p[i] = z[i] + beta * p[i];
1006            }
1007            rho_old = rho_new;
1008        }
1009        out.copy_from_slice(&x);
1010        Ok(())
1011    }
1012
1013    /// CSR × CSR → CSR via Gustavson's algorithm. Two-pass:
1014    /// (1) symbolic — count nnz per output row using a row-marker;
1015    /// (2) numeric — accumulate values via a sparse-accumulator (SPA).
1016    /// Returns (c_values, c_col_idx, c_row_ptr).
1017    pub fn spgemm_csr(
1018        a_values: &[f64],
1019        a_col_idx: &[i32],
1020        a_row_ptr: &[i32],
1021        b_values: &[f64],
1022        b_col_idx: &[i32],
1023        b_row_ptr: &[i32],
1024        m: usize,
1025        k: usize,
1026        n: usize,
1027    ) -> Result<(Vec<f64>, Vec<i32>, Vec<i32>), String> {
1028        if a_row_ptr.len() != m + 1 {
1029            return Err(format!("spgemm: a_row_ptr len {} != m+1", a_row_ptr.len()));
1030        }
1031        if b_row_ptr.len() != k + 1 {
1032            return Err(format!("spgemm: b_row_ptr len {} != k+1", b_row_ptr.len()));
1033        }
1034        // Symbolic + numeric in one pass with SPA.
1035        let mut c_row_ptr = vec![0i32; m + 1];
1036        let mut c_col_idx: Vec<i32> = Vec::new();
1037        let mut c_values: Vec<f64> = Vec::new();
1038
1039        // Sparse accumulator: marker[col] = row index where last touched.
1040        let mut marker = vec![-1i32; n];
1041        let mut spa_vals = vec![0f64; n];
1042        let mut spa_cols: Vec<usize> = Vec::with_capacity(n);
1043
1044        for i in 0..m {
1045            spa_cols.clear();
1046            for ka in a_row_ptr[i] as usize..a_row_ptr[i + 1] as usize {
1047                let j = a_col_idx[ka] as usize;
1048                let aij = a_values[ka];
1049                for kb in b_row_ptr[j] as usize..b_row_ptr[j + 1] as usize {
1050                    let l = b_col_idx[kb] as usize;
1051                    let bjl = b_values[kb];
1052                    if marker[l] != i as i32 {
1053                        marker[l] = i as i32;
1054                        spa_vals[l] = aij * bjl;
1055                        spa_cols.push(l);
1056                    } else {
1057                        spa_vals[l] += aij * bjl;
1058                    }
1059                }
1060            }
1061            // Sort columns for canonical CSR ordering.
1062            spa_cols.sort_unstable();
1063            for &l in &spa_cols {
1064                c_col_idx.push(l as i32);
1065                c_values.push(spa_vals[l]);
1066            }
1067            c_row_ptr[i + 1] = c_col_idx.len() as i32;
1068        }
1069        Ok((c_values, c_col_idx, c_row_ptr))
1070    }
1071
1072    fn hcol_last_zero_check(hcol: &[f64]) -> bool {
1073        // After Givens rotation hcol[j+1] is 0 by construction.
1074        // The lucky-breakdown signal is the *un-rotated* subdiagonal
1075        // having been ≈0 — we approximate by checking if every
1076        // h_i value is small. Conservative (slightly over-eager
1077        // termination on near-zero columns).
1078        hcol.iter().all(|v| v.abs() < f64::MIN_POSITIVE * 64.0)
1079    }
1080    fn hcol_subdiag(hcol: &[f64], i: usize) -> f64 {
1081        hcol.get(i).copied().unwrap_or(0.0)
1082    }
1083
1084    pub fn cg_solve(
1085        values: &[f64],
1086        col_idx: &[i32],
1087        row_ptr: &[i32],
1088        b: &[f64],
1089        out: &mut [f64],
1090        max_iter: u32,
1091        tol: f64,
1092    ) -> Result<(), String> {
1093        let n = b.len();
1094        if out.len() != n {
1095            return Err(format!("cg_solve: output len {} != b len {n}", out.len()));
1096        }
1097        if row_ptr.len() != n + 1 {
1098            return Err(format!(
1099                "cg_solve: row_ptr len {} != n+1 ({})",
1100                row_ptr.len(),
1101                n + 1
1102            ));
1103        }
1104        let matvec = |x: &[f64], y: &mut [f64]| {
1105            for r in 0..n {
1106                let mut acc = 0f64;
1107                for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
1108                    acc += values[k] * x[col_idx[k] as usize];
1109                }
1110                y[r] = acc;
1111            }
1112        };
1113        let mut x = vec![0f64; n];
1114        let mut r = b.to_vec();
1115        let mut p = r.clone();
1116        let mut ap = vec![0f64; n];
1117        let mut rs_old: f64 = r.iter().map(|v| v * v).sum();
1118        for _ in 0..max_iter {
1119            if rs_old.sqrt() < tol {
1120                break;
1121            }
1122            matvec(&p, &mut ap);
1123            let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
1124            if pap == 0.0 {
1125                return Err("cg_solve: pᵀ·A·p = 0 (A is singular or not SPD)".into());
1126            }
1127            let alpha = rs_old / pap;
1128            for i in 0..n {
1129                x[i] += alpha * p[i];
1130            }
1131            for i in 0..n {
1132                r[i] -= alpha * ap[i];
1133            }
1134            let rs_new: f64 = r.iter().map(|v| v * v).sum();
1135            let beta = rs_new / rs_old;
1136            for i in 0..n {
1137                p[i] = r[i] + beta * p[i];
1138            }
1139            rs_old = rs_new;
1140        }
1141        out.copy_from_slice(&x);
1142        Ok(())
1143    }
1144}
1145
1146// ── Sparse LU Solve ───────────────────────────────────────────────
1147
1148struct SparseLuExt;
1149
1150impl OpExtension for SparseLuExt {
1151    fn name(&self) -> &str {
1152        SPARSE_LU_SOLVE
1153    }
1154    fn num_inputs(&self) -> usize {
1155        4
1156    }
1157
1158    fn infer_shape(&self, inputs: &[&Shape], _attrs: &[u8]) -> Shape {
1159        let values = inputs[0];
1160        let col_idx = inputs[1];
1161        let row_ptr = inputs[2];
1162        let b = inputs[3];
1163        assert_eq!(values.dtype(), DType::F64, "sparse_lu: values must be F64");
1164        assert_eq!(
1165            col_idx.dtype(),
1166            DType::I32,
1167            "sparse_lu: col_idx must be I32"
1168        );
1169        assert_eq!(
1170            row_ptr.dtype(),
1171            DType::I32,
1172            "sparse_lu: row_ptr must be I32"
1173        );
1174        assert_eq!(b.dtype(), DType::F64, "sparse_lu: b must be F64");
1175        b.clone()
1176    }
1177
1178    fn vjp(&self, node: &Node, ctx: &mut VjpContext) -> Vec<(usize, NodeId)> {
1179        // y = solve(A, b). Closed-form gradients:
1180        //   dL/db = solve(Aᵀ, dL/dy)         [v1 symmetric → reuse A]
1181        //   dL/dvalues[k] = -dL/db[row(k)] · y_fwd[col(k)]   gathered at nonzero k
1182        let vals_b = ctx.fwd_map[&node.inputs[0]];
1183        let cidx_b = ctx.fwd_map[&node.inputs[1]];
1184        let rptr_b = ctx.fwd_map[&node.inputs[2]];
1185
1186        let g_b = ctx.bwd.custom_op(
1187            SPARSE_LU_SOLVE,
1188            Vec::new(),
1189            vec![vals_b, cidx_b, rptr_b, ctx.upstream],
1190        );
1191
1192        // y is the forward solve output, mirrored in the bwd graph
1193        // by `grad_with_loss`'s up-front fwd→bwd Node copy. Look it up
1194        // via ctx.fwd_map[&node.id].
1195        let y_fwd = ctx.fwd_map[&node.id];
1196        let raw_grad = ctx.bwd.custom_op(
1197            SPARSE_VALUES_GRAD,
1198            Vec::new(),
1199            vec![cidx_b, rptr_b, g_b, y_fwd],
1200        );
1201        // The values gradient is `-dL/db ⊗ y`, so negate the gather.
1202        let raw_shape = ctx.bwd.node(raw_grad).shape.clone();
1203        let g_vals = ctx
1204            .bwd
1205            .activation(rlx_ir::op::Activation::Neg, raw_grad, raw_shape);
1206
1207        vec![(0, g_vals), (3, g_b)]
1208    }
1209}
1210
1211#[cfg(feature = "cpu")]
1212struct SparseLuCpu;
1213
1214#[cfg(feature = "cpu")]
1215impl CpuKernel for SparseLuCpu {
1216    fn name(&self) -> &str {
1217        SPARSE_LU_SOLVE
1218    }
1219
1220    fn execute(
1221        &self,
1222        inputs: &[CpuTensorRef<'_>],
1223        output: CpuTensorMut<'_>,
1224        _attrs: &[u8],
1225    ) -> Result<(), String> {
1226        let values = inputs[0].expect_f64("sparse_lu values")?;
1227        let col_idx = inputs[1].expect_i32("sparse_lu col_idx")?;
1228        let row_ptr = inputs[2].expect_i32("sparse_lu row_ptr")?;
1229        let b = inputs[3].expect_f64("sparse_lu b")?;
1230        let out = output.expect_f64_mut("sparse_lu output")?;
1231        algos::lu_solve(values, col_idx, row_ptr, b, out)
1232    }
1233}
1234
1235// ── Sparse Mat-Vec ────────────────────────────────────────────────
1236
1237struct SparseMatVecExt;
1238
1239impl OpExtension for SparseMatVecExt {
1240    fn name(&self) -> &str {
1241        SPARSE_MAT_VEC
1242    }
1243    fn num_inputs(&self) -> usize {
1244        4
1245    }
1246
1247    fn infer_shape(&self, inputs: &[&Shape], _attrs: &[u8]) -> Shape {
1248        // y has the shape of x (A is n×n by convention).
1249        inputs[3].clone()
1250    }
1251
1252    fn vjp(&self, node: &Node, ctx: &mut VjpContext) -> Vec<(usize, NodeId)> {
1253        // y = A·x. Closed-form gradients:
1254        //   dL/dx = Aᵀ · upstream                  [v1 symmetric → mat_vec(A, ...)]
1255        //   dL/dvalues[k] = upstream[row(k)] · x[col(k)]   gathered at nonzero k
1256        let vals_b = ctx.fwd_map[&node.inputs[0]];
1257        let cidx_b = ctx.fwd_map[&node.inputs[1]];
1258        let rptr_b = ctx.fwd_map[&node.inputs[2]];
1259        let x_bwd = ctx.fwd_map[&node.inputs[3]];
1260
1261        let g_x = ctx.bwd.custom_op(
1262            SPARSE_MAT_VEC,
1263            Vec::new(),
1264            vec![vals_b, cidx_b, rptr_b, ctx.upstream],
1265        );
1266        let g_vals = ctx.bwd.custom_op(
1267            SPARSE_VALUES_GRAD,
1268            Vec::new(),
1269            vec![cidx_b, rptr_b, ctx.upstream, x_bwd],
1270        );
1271        vec![(0, g_vals), (3, g_x)]
1272    }
1273}
1274
1275#[cfg(feature = "cpu")]
1276struct SparseMatVecCpu;
1277
1278#[cfg(feature = "cpu")]
1279impl CpuKernel for SparseMatVecCpu {
1280    fn name(&self) -> &str {
1281        SPARSE_MAT_VEC
1282    }
1283    fn execute(
1284        &self,
1285        inputs: &[CpuTensorRef<'_>],
1286        output: CpuTensorMut<'_>,
1287        _attrs: &[u8],
1288    ) -> Result<(), String> {
1289        let values = inputs[0].expect_f64("mat_vec values")?;
1290        let col_idx = inputs[1].expect_i32("mat_vec col_idx")?;
1291        let row_ptr = inputs[2].expect_i32("mat_vec row_ptr")?;
1292        let x = inputs[3].expect_f64("mat_vec x")?;
1293        let out = output.expect_f64_mut("mat_vec y")?;
1294        algos::mat_vec(values, col_idx, row_ptr, x, out)
1295    }
1296}
1297
1298// ── Conjugate Gradient ────────────────────────────────────────────
1299
1300/// Encode CG attrs into the opaque `Vec<u8>` blob carried on
1301/// `Op::Custom`. Layout: `[max_iter:u32 LE, tol:f64 LE]` — 12 bytes.
1302pub fn encode_cg_attrs(max_iter: u32, tol: f64) -> Vec<u8> {
1303    let mut out = Vec::with_capacity(12);
1304    out.extend_from_slice(&max_iter.to_le_bytes());
1305    out.extend_from_slice(&tol.to_le_bytes());
1306    out
1307}
1308
1309fn decode_cg_attrs(attrs: &[u8]) -> Result<(u32, f64), String> {
1310    if attrs.len() != 12 {
1311        return Err(format!(
1312            "cg_solve: attrs must be 12 bytes (u32 max_iter + f64 tol), got {}",
1313            attrs.len()
1314        ));
1315    }
1316    let max_iter = u32::from_le_bytes(attrs[0..4].try_into().unwrap());
1317    let tol = f64::from_le_bytes(attrs[4..12].try_into().unwrap());
1318    Ok((max_iter, tol))
1319}
1320
1321struct SparseCgExt;
1322
1323impl OpExtension for SparseCgExt {
1324    fn name(&self) -> &str {
1325        SPARSE_CG_SOLVE
1326    }
1327    fn num_inputs(&self) -> usize {
1328        4
1329    }
1330
1331    fn infer_shape(&self, inputs: &[&Shape], _attrs: &[u8]) -> Shape {
1332        inputs[3].clone()
1333    }
1334
1335    fn vjp(&self, node: &Node, ctx: &mut VjpContext) -> Vec<(usize, NodeId)> {
1336        // Same shape as SparseLuExt::vjp — iterative solver, same
1337        // closed-form adjoint. The adjoint solve recurses into
1338        // sparse_cg_solve with the forward's attrs (same tolerance
1339        // and iteration cap).
1340        let vals_b = ctx.fwd_map[&node.inputs[0]];
1341        let cidx_b = ctx.fwd_map[&node.inputs[1]];
1342        let rptr_b = ctx.fwd_map[&node.inputs[2]];
1343        let attrs = match &node.op {
1344            Op::Custom { attrs, .. } => attrs.clone(),
1345            _ => Vec::new(),
1346        };
1347        let g_b = ctx.bwd.custom_op(
1348            SPARSE_CG_SOLVE,
1349            attrs,
1350            vec![vals_b, cidx_b, rptr_b, ctx.upstream],
1351        );
1352        let y_fwd = ctx.fwd_map[&node.id];
1353        let raw_grad = ctx.bwd.custom_op(
1354            SPARSE_VALUES_GRAD,
1355            Vec::new(),
1356            vec![cidx_b, rptr_b, g_b, y_fwd],
1357        );
1358        let raw_shape = ctx.bwd.node(raw_grad).shape.clone();
1359        let g_vals = ctx
1360            .bwd
1361            .activation(rlx_ir::op::Activation::Neg, raw_grad, raw_shape);
1362
1363        vec![(0, g_vals), (3, g_b)]
1364    }
1365}
1366
1367#[cfg(feature = "cpu")]
1368struct SparseCgCpu;
1369
1370#[cfg(feature = "cpu")]
1371impl CpuKernel for SparseCgCpu {
1372    fn name(&self) -> &str {
1373        SPARSE_CG_SOLVE
1374    }
1375
1376    fn execute(
1377        &self,
1378        inputs: &[CpuTensorRef<'_>],
1379        output: CpuTensorMut<'_>,
1380        attrs: &[u8],
1381    ) -> Result<(), String> {
1382        let values = inputs[0].expect_f64("cg_solve values")?;
1383        let col_idx = inputs[1].expect_i32("cg_solve col_idx")?;
1384        let row_ptr = inputs[2].expect_i32("cg_solve row_ptr")?;
1385        let b = inputs[3].expect_f64("cg_solve b")?;
1386        let out = output.expect_f64_mut("cg_solve x")?;
1387        let (max_iter, tol) = decode_cg_attrs(attrs)?;
1388        algos::cg_solve(values, col_idx, row_ptr, b, out, max_iter, tol)
1389    }
1390}
1391
1392// ── Sparse Values Gradient (`dL/dvalues` building block) ─────────
1393
1394struct SparseValuesGradExt;
1395
1396impl OpExtension for SparseValuesGradExt {
1397    fn name(&self) -> &str {
1398        SPARSE_VALUES_GRAD
1399    }
1400    fn num_inputs(&self) -> usize {
1401        4
1402    } // col_idx, row_ptr, u, v
1403
1404    fn infer_shape(&self, inputs: &[&Shape], _attrs: &[u8]) -> Shape {
1405        // Output shape == col_idx shape (length nnz, F64).
1406        let col_idx = inputs[0];
1407        assert_eq!(
1408            col_idx.dtype(),
1409            DType::I32,
1410            "values_grad: col_idx must be I32"
1411        );
1412        let nnz = col_idx
1413            .num_elements()
1414            .expect("values_grad: col_idx must have static shape");
1415        Shape::new(&[nnz], DType::F64)
1416    }
1417    // Non-differentiable (it's itself a gradient kernel; second-order
1418    // derivatives are out of v1 scope).
1419}
1420
1421#[cfg(feature = "cpu")]
1422struct SparseValuesGradCpu;
1423#[cfg(feature = "cpu")]
1424impl CpuKernel for SparseValuesGradCpu {
1425    fn name(&self) -> &str {
1426        SPARSE_VALUES_GRAD
1427    }
1428    fn execute(
1429        &self,
1430        inputs: &[CpuTensorRef<'_>],
1431        output: CpuTensorMut<'_>,
1432        _attrs: &[u8],
1433    ) -> Result<(), String> {
1434        let col_idx = inputs[0].expect_i32("values_grad col_idx")?;
1435        let row_ptr = inputs[1].expect_i32("values_grad row_ptr")?;
1436        let u = inputs[2].expect_f64("values_grad u")?;
1437        let v = inputs[3].expect_f64("values_grad v")?;
1438        let out = output.expect_f64_mut("values_grad out")?;
1439        algos::values_grad(col_idx, row_ptr, u, v, out)
1440    }
1441}
1442
1443// ── Sparse LU Solve (general / non-symmetric) ─────────────────────
1444//
1445// 7-input variant of `sparse_lu_solve`. Forward identity to the
1446// symmetric version (kernel only reads inputs 0..=3). The last 3
1447// inputs are the transpose CSR triplet used by the VJP for the
1448// adjoint solve `dL/db = solve(Aᵀ, dL/dx)`. Use this for non-
1449// symmetric matrices where reusing the forward triplet for the
1450// adjoint would be wrong.
1451
1452struct SparseLuGeneralExt;
1453
1454impl OpExtension for SparseLuGeneralExt {
1455    fn name(&self) -> &str {
1456        SPARSE_LU_SOLVE_GENERAL
1457    }
1458    fn num_inputs(&self) -> usize {
1459        7
1460    }
1461    // values_A, col_idx_A, row_ptr_A, b, values_AT, col_idx_AT, row_ptr_AT
1462    fn infer_shape(&self, inputs: &[&Shape], _attrs: &[u8]) -> Shape {
1463        let b = inputs[3];
1464        b.clone()
1465    }
1466    fn vjp(&self, node: &Node, ctx: &mut VjpContext) -> Vec<(usize, NodeId)> {
1467        // dL/db = solve(Aᵀ, dL/dy) — emit ANOTHER lu_solve_general
1468        // call with the roles swapped: the adjoint's forward matrix
1469        // is Aᵀ (already provided in inputs 4..7), so the adjoint
1470        // call's transpose triplet is back to A (inputs 0..3). The
1471        // outer sparse-LU-general's adjoint is itself a sparse-LU-
1472        // general — same recursion as in CG.
1473        let vals_a = ctx.fwd_map[&node.inputs[0]];
1474        let cidx_a = ctx.fwd_map[&node.inputs[1]];
1475        let rptr_a = ctx.fwd_map[&node.inputs[2]];
1476        let vals_at = ctx.fwd_map[&node.inputs[4]];
1477        let cidx_at = ctx.fwd_map[&node.inputs[5]];
1478        let rptr_at = ctx.fwd_map[&node.inputs[6]];
1479
1480        let g_b = ctx.bwd.custom_op(
1481            SPARSE_LU_SOLVE_GENERAL,
1482            Vec::new(),
1483            // forward A is now Aᵀ; transpose for *this* adjoint solve is A.
1484            vec![
1485                vals_at,
1486                cidx_at,
1487                rptr_at,
1488                ctx.upstream,
1489                vals_a,
1490                cidx_a,
1491                rptr_a,
1492            ],
1493        );
1494        let y_fwd = ctx.fwd_map[&node.id];
1495        let raw_grad = ctx.bwd.custom_op(
1496            SPARSE_VALUES_GRAD,
1497            Vec::new(),
1498            vec![cidx_a, rptr_a, g_b, y_fwd],
1499        );
1500        let raw_shape = ctx.bwd.node(raw_grad).shape.clone();
1501        let g_vals = ctx
1502            .bwd
1503            .activation(rlx_ir::op::Activation::Neg, raw_grad, raw_shape);
1504        vec![(0, g_vals), (3, g_b)]
1505    }
1506}
1507
1508#[cfg(feature = "cpu")]
1509struct SparseLuGeneralCpu;
1510#[cfg(feature = "cpu")]
1511impl CpuKernel for SparseLuGeneralCpu {
1512    fn name(&self) -> &str {
1513        SPARSE_LU_SOLVE_GENERAL
1514    }
1515    fn execute(
1516        &self,
1517        inputs: &[CpuTensorRef<'_>],
1518        output: CpuTensorMut<'_>,
1519        _attrs: &[u8],
1520    ) -> Result<(), String> {
1521        // Forward only reads A; the AT triplet rides along for the
1522        // VJP and is unused here. Same algos::lu_solve as the
1523        // symmetric version.
1524        let values = inputs[0].expect_f64("lu_solve_general values")?;
1525        let col_idx = inputs[1].expect_i32("lu_solve_general col_idx")?;
1526        let row_ptr = inputs[2].expect_i32("lu_solve_general row_ptr")?;
1527        let b = inputs[3].expect_f64("lu_solve_general b")?;
1528        let out = output.expect_f64_mut("lu_solve_general out")?;
1529        algos::lu_solve(values, col_idx, row_ptr, b, out)
1530    }
1531}
1532
1533// ── GMRES Solve (non-symmetric iterative) ────────────────────────
1534//
1535// Iterative analog of CG for non-symmetric A. Same 7-input shape as
1536// `sparse_lu_solve_general`. Attrs encode `(max_iter, tol)` exactly
1537// like CG.
1538
1539struct SparseGmresExt;
1540
1541impl OpExtension for SparseGmresExt {
1542    fn name(&self) -> &str {
1543        SPARSE_GMRES_SOLVE
1544    }
1545    fn num_inputs(&self) -> usize {
1546        7
1547    }
1548    fn infer_shape(&self, inputs: &[&Shape], _attrs: &[u8]) -> Shape {
1549        inputs[3].clone()
1550    }
1551    fn vjp(&self, node: &Node, ctx: &mut VjpContext) -> Vec<(usize, NodeId)> {
1552        // Same closed-form adjoint as LU general. Recurse into
1553        // gmres_solve against (Aᵀ, upstream) with the same attrs.
1554        let vals_a = ctx.fwd_map[&node.inputs[0]];
1555        let cidx_a = ctx.fwd_map[&node.inputs[1]];
1556        let rptr_a = ctx.fwd_map[&node.inputs[2]];
1557        let vals_at = ctx.fwd_map[&node.inputs[4]];
1558        let cidx_at = ctx.fwd_map[&node.inputs[5]];
1559        let rptr_at = ctx.fwd_map[&node.inputs[6]];
1560        let attrs = match &node.op {
1561            Op::Custom { attrs, .. } => attrs.clone(),
1562            _ => Vec::new(),
1563        };
1564        let g_b = ctx.bwd.custom_op(
1565            SPARSE_GMRES_SOLVE,
1566            attrs,
1567            vec![
1568                vals_at,
1569                cidx_at,
1570                rptr_at,
1571                ctx.upstream,
1572                vals_a,
1573                cidx_a,
1574                rptr_a,
1575            ],
1576        );
1577        let y_fwd = ctx.fwd_map[&node.id];
1578        let raw_grad = ctx.bwd.custom_op(
1579            SPARSE_VALUES_GRAD,
1580            Vec::new(),
1581            vec![cidx_a, rptr_a, g_b, y_fwd],
1582        );
1583        let raw_shape = ctx.bwd.node(raw_grad).shape.clone();
1584        let g_vals = ctx
1585            .bwd
1586            .activation(rlx_ir::op::Activation::Neg, raw_grad, raw_shape);
1587        vec![(0, g_vals), (3, g_b)]
1588    }
1589}
1590
1591#[cfg(feature = "cpu")]
1592struct SparseGmresCpu;
1593#[cfg(feature = "cpu")]
1594impl CpuKernel for SparseGmresCpu {
1595    fn name(&self) -> &str {
1596        SPARSE_GMRES_SOLVE
1597    }
1598    fn execute(
1599        &self,
1600        inputs: &[CpuTensorRef<'_>],
1601        output: CpuTensorMut<'_>,
1602        attrs: &[u8],
1603    ) -> Result<(), String> {
1604        let values = inputs[0].expect_f64("gmres values")?;
1605        let col_idx = inputs[1].expect_i32("gmres col_idx")?;
1606        let row_ptr = inputs[2].expect_i32("gmres row_ptr")?;
1607        let b = inputs[3].expect_f64("gmres b")?;
1608        let out = output.expect_f64_mut("gmres out")?;
1609        let (max_iter, tol) = decode_cg_attrs(attrs)?;
1610        algos::gmres_solve(values, col_idx, row_ptr, b, out, max_iter, tol)
1611    }
1612}
1613
1614// ── Sparse Transpose Values ───────────────────────────────────────
1615
1616struct SparseTransposeValuesExt;
1617
1618impl OpExtension for SparseTransposeValuesExt {
1619    fn name(&self) -> &str {
1620        SPARSE_TRANSPOSE_VALUES
1621    }
1622    fn num_inputs(&self) -> usize {
1623        5
1624    } // values, col_idx, row_ptr, col_idx_T, row_ptr_T
1625
1626    fn infer_shape(&self, inputs: &[&Shape], _attrs: &[u8]) -> Shape {
1627        // Output shape = same as values (length nnz).
1628        inputs[0].clone()
1629    }
1630
1631    fn vjp(&self, node: &Node, ctx: &mut VjpContext) -> Vec<(usize, NodeId)> {
1632        // VJP of transpose is itself transpose (with patterns swapped).
1633        // dL/d(values_A) = transpose_values(upstream, col_idx_T, row_ptr_T,
1634        //                                    col_idx, row_ptr).
1635        let cidx_a = ctx.fwd_map[&node.inputs[1]];
1636        let rptr_a = ctx.fwd_map[&node.inputs[2]];
1637        let cidx_at = ctx.fwd_map[&node.inputs[3]];
1638        let rptr_at = ctx.fwd_map[&node.inputs[4]];
1639        let g_vals = ctx.bwd.custom_op(
1640            SPARSE_TRANSPOSE_VALUES,
1641            Vec::new(),
1642            vec![ctx.upstream, cidx_at, rptr_at, cidx_a, rptr_a],
1643        );
1644        vec![(0, g_vals)]
1645    }
1646}
1647
1648#[cfg(feature = "cpu")]
1649struct SparseTransposeValuesCpu;
1650#[cfg(feature = "cpu")]
1651impl CpuKernel for SparseTransposeValuesCpu {
1652    fn name(&self) -> &str {
1653        SPARSE_TRANSPOSE_VALUES
1654    }
1655    fn execute(
1656        &self,
1657        inputs: &[CpuTensorRef<'_>],
1658        output: CpuTensorMut<'_>,
1659        _attrs: &[u8],
1660    ) -> Result<(), String> {
1661        let values = inputs[0].expect_f64("transpose_values values")?;
1662        let col_idx = inputs[1].expect_i32("transpose_values col_idx")?;
1663        let row_ptr = inputs[2].expect_i32("transpose_values row_ptr")?;
1664        let col_idx_t = inputs[3].expect_i32("transpose_values col_idx_T")?;
1665        let row_ptr_t = inputs[4].expect_i32("transpose_values row_ptr_T")?;
1666        let out = output.expect_f64_mut("transpose_values out")?;
1667        algos::transpose_values(values, col_idx, row_ptr, col_idx_t, row_ptr_t, out)
1668    }
1669}
1670
1671// ── PCG (Jacobi preconditioner) ───────────────────────────────────
1672
1673struct SparsePcgExt;
1674
1675impl OpExtension for SparsePcgExt {
1676    fn name(&self) -> &str {
1677        SPARSE_PCG_SOLVE
1678    }
1679    fn num_inputs(&self) -> usize {
1680        4
1681    } // values, col_idx, row_ptr, b
1682
1683    fn infer_shape(&self, inputs: &[&Shape], _attrs: &[u8]) -> Shape {
1684        inputs[3].clone()
1685    }
1686
1687    fn vjp(&self, node: &Node, ctx: &mut VjpContext) -> Vec<(usize, NodeId)> {
1688        // Closed-form adjoint identical to CG (same SPD assumption).
1689        // Recurse into pcg_solve with the same attrs.
1690        let vals_b = ctx.fwd_map[&node.inputs[0]];
1691        let cidx_b = ctx.fwd_map[&node.inputs[1]];
1692        let rptr_b = ctx.fwd_map[&node.inputs[2]];
1693        let attrs = match &node.op {
1694            rlx_ir::Op::Custom { attrs, .. } => attrs.clone(),
1695            _ => Vec::new(),
1696        };
1697        let g_b = ctx.bwd.custom_op(
1698            SPARSE_PCG_SOLVE,
1699            attrs,
1700            vec![vals_b, cidx_b, rptr_b, ctx.upstream],
1701        );
1702        let y_fwd = ctx.fwd_map[&node.id];
1703        let raw_grad = ctx.bwd.custom_op(
1704            SPARSE_VALUES_GRAD,
1705            Vec::new(),
1706            vec![cidx_b, rptr_b, g_b, y_fwd],
1707        );
1708        let raw_shape = ctx.bwd.node(raw_grad).shape.clone();
1709        let g_vals = ctx
1710            .bwd
1711            .activation(rlx_ir::op::Activation::Neg, raw_grad, raw_shape);
1712        vec![(0, g_vals), (3, g_b)]
1713    }
1714}
1715
1716#[cfg(feature = "cpu")]
1717struct SparsePcgCpu;
1718#[cfg(feature = "cpu")]
1719impl CpuKernel for SparsePcgCpu {
1720    fn name(&self) -> &str {
1721        SPARSE_PCG_SOLVE
1722    }
1723    fn execute(
1724        &self,
1725        inputs: &[CpuTensorRef<'_>],
1726        output: CpuTensorMut<'_>,
1727        attrs: &[u8],
1728    ) -> Result<(), String> {
1729        let values = inputs[0].expect_f64("pcg values")?;
1730        let col_idx = inputs[1].expect_i32("pcg col_idx")?;
1731        let row_ptr = inputs[2].expect_i32("pcg row_ptr")?;
1732        let b = inputs[3].expect_f64("pcg b")?;
1733        let out = output.expect_f64_mut("pcg x")?;
1734        let (max_iter, tol) = decode_cg_attrs(attrs)?;
1735        algos::pcg_solve(values, col_idx, row_ptr, b, out, max_iter, tol)
1736    }
1737}
1738
1739// ── BiCGSTAB (non-symmetric) ──────────────────────────────────────
1740
1741fn decode_bicgstab_attrs(attrs: &[u8]) -> Result<(u32, f64, bool), String> {
1742    if attrs.len() < 13 {
1743        return Err(format!("bicgstab: attrs len {} < 13", attrs.len()));
1744    }
1745    let max_iter = u32::from_le_bytes(attrs[0..4].try_into().unwrap());
1746    let tol = f64::from_le_bytes(attrs[4..12].try_into().unwrap());
1747    let trans = attrs[12] != 0;
1748    Ok((max_iter, tol, trans))
1749}
1750
1751struct SparseBicgstabExt;
1752
1753impl OpExtension for SparseBicgstabExt {
1754    fn name(&self) -> &str {
1755        SPARSE_BICGSTAB_SOLVE
1756    }
1757    fn num_inputs(&self) -> usize {
1758        4
1759    } // values, col_idx, row_ptr, b
1760    fn infer_shape(&self, inputs: &[&Shape], _attrs: &[u8]) -> Shape {
1761        inputs[3].clone()
1762    }
1763    fn vjp(&self, node: &Node, ctx: &mut VjpContext) -> Vec<(usize, NodeId)> {
1764        // Forward solves A·x = b (or Aᵀ·x = b if transpose_a flag set).
1765        // VJP: dL/db = A⁻ᵀ·G via bicgstab with transpose flag flipped.
1766        //      dL/dvalues = -y_adj ⊗ x  (gathered on CSR pattern).
1767        let vals = ctx.fwd_map[&node.inputs[0]];
1768        let cidx = ctx.fwd_map[&node.inputs[1]];
1769        let rptr = ctx.fwd_map[&node.inputs[2]];
1770        let attrs = match &node.op {
1771            Op::Custom { attrs, .. } => attrs.clone(),
1772            _ => Vec::new(),
1773        };
1774        let (max_iter, tol, trans) = decode_bicgstab_attrs(&attrs).unwrap_or((1, 1e-9, false));
1775        let mut adj_attrs = Vec::with_capacity(13);
1776        adj_attrs.extend_from_slice(&max_iter.to_le_bytes());
1777        adj_attrs.extend_from_slice(&tol.to_le_bytes());
1778        adj_attrs.push(if !trans { 1 } else { 0 });
1779        let g_b = ctx.bwd.custom_op(
1780            SPARSE_BICGSTAB_SOLVE,
1781            adj_attrs,
1782            vec![vals, cidx, rptr, ctx.upstream],
1783        );
1784        let y_fwd = ctx.fwd_map[&node.id];
1785        let raw_grad =
1786            ctx.bwd
1787                .custom_op(SPARSE_VALUES_GRAD, Vec::new(), vec![cidx, rptr, g_b, y_fwd]);
1788        let raw_shape = ctx.bwd.node(raw_grad).shape.clone();
1789        let g_vals = ctx
1790            .bwd
1791            .activation(rlx_ir::op::Activation::Neg, raw_grad, raw_shape);
1792        vec![(0, g_vals), (3, g_b)]
1793    }
1794}
1795
1796#[cfg(feature = "cpu")]
1797struct SparseBicgstabCpu;
1798#[cfg(feature = "cpu")]
1799impl CpuKernel for SparseBicgstabCpu {
1800    fn name(&self) -> &str {
1801        SPARSE_BICGSTAB_SOLVE
1802    }
1803    fn execute(
1804        &self,
1805        inputs: &[CpuTensorRef<'_>],
1806        output: CpuTensorMut<'_>,
1807        attrs: &[u8],
1808    ) -> Result<(), String> {
1809        let values = inputs[0].expect_f64("bicgstab values")?;
1810        let col_idx = inputs[1].expect_i32("bicgstab col_idx")?;
1811        let row_ptr = inputs[2].expect_i32("bicgstab row_ptr")?;
1812        let b = inputs[3].expect_f64("bicgstab b")?;
1813        let out = output.expect_f64_mut("bicgstab x")?;
1814        let (max_iter, tol, trans) = decode_bicgstab_attrs(attrs)?;
1815        algos::bicgstab(values, col_idx, row_ptr, b, out, max_iter, tol, trans)
1816    }
1817}
1818
1819// ── ILU(0)-preconditioned CG ──────────────────────────────────────
1820
1821struct SparseIluPcgExt;
1822
1823impl OpExtension for SparseIluPcgExt {
1824    fn name(&self) -> &str {
1825        SPARSE_ILU_PCG_SOLVE
1826    }
1827    fn num_inputs(&self) -> usize {
1828        4
1829    }
1830    fn infer_shape(&self, inputs: &[&Shape], _attrs: &[u8]) -> Shape {
1831        inputs[3].clone()
1832    }
1833    fn vjp(&self, node: &Node, ctx: &mut VjpContext) -> Vec<(usize, NodeId)> {
1834        // Same SPD adjoint shape as plain CG/PCG — reuse the kernel.
1835        let vals = ctx.fwd_map[&node.inputs[0]];
1836        let cidx = ctx.fwd_map[&node.inputs[1]];
1837        let rptr = ctx.fwd_map[&node.inputs[2]];
1838        let attrs = match &node.op {
1839            Op::Custom { attrs, .. } => attrs.clone(),
1840            _ => Vec::new(),
1841        };
1842        let g_b = ctx.bwd.custom_op(
1843            SPARSE_ILU_PCG_SOLVE,
1844            attrs,
1845            vec![vals, cidx, rptr, ctx.upstream],
1846        );
1847        let y_fwd = ctx.fwd_map[&node.id];
1848        let raw_grad =
1849            ctx.bwd
1850                .custom_op(SPARSE_VALUES_GRAD, Vec::new(), vec![cidx, rptr, g_b, y_fwd]);
1851        let raw_shape = ctx.bwd.node(raw_grad).shape.clone();
1852        let g_vals = ctx
1853            .bwd
1854            .activation(rlx_ir::op::Activation::Neg, raw_grad, raw_shape);
1855        vec![(0, g_vals), (3, g_b)]
1856    }
1857}
1858
1859#[cfg(feature = "cpu")]
1860struct SparseIluPcgCpu;
1861#[cfg(feature = "cpu")]
1862impl CpuKernel for SparseIluPcgCpu {
1863    fn name(&self) -> &str {
1864        SPARSE_ILU_PCG_SOLVE
1865    }
1866    fn execute(
1867        &self,
1868        inputs: &[CpuTensorRef<'_>],
1869        output: CpuTensorMut<'_>,
1870        attrs: &[u8],
1871    ) -> Result<(), String> {
1872        let values = inputs[0].expect_f64("ilu_pcg values")?;
1873        let col_idx = inputs[1].expect_i32("ilu_pcg col_idx")?;
1874        let row_ptr = inputs[2].expect_i32("ilu_pcg row_ptr")?;
1875        let b = inputs[3].expect_f64("ilu_pcg b")?;
1876        let out = output.expect_f64_mut("ilu_pcg x")?;
1877        let (max_iter, tol) = decode_cg_attrs(attrs)?;
1878        algos::ilu_pcg_solve(values, col_idx, row_ptr, b, out, max_iter, tol)
1879    }
1880}
1881
1882// ── Sparse Cholesky (direct) ──────────────────────────────────────
1883
1884struct SparseCholeskyExt;
1885
1886impl OpExtension for SparseCholeskyExt {
1887    fn name(&self) -> &str {
1888        SPARSE_CHOLESKY_SOLVE
1889    }
1890    fn num_inputs(&self) -> usize {
1891        4
1892    } // values, col_idx, row_ptr, b
1893    fn infer_shape(&self, inputs: &[&Shape], _attrs: &[u8]) -> Shape {
1894        inputs[3].clone()
1895    }
1896    fn vjp(&self, node: &Node, ctx: &mut VjpContext) -> Vec<(usize, NodeId)> {
1897        // Same closed form as sparse_lu_solve (SPD ⇒ Aᵀ = A so the
1898        // adjoint solve uses the same kernel).
1899        let vals = ctx.fwd_map[&node.inputs[0]];
1900        let cidx = ctx.fwd_map[&node.inputs[1]];
1901        let rptr = ctx.fwd_map[&node.inputs[2]];
1902        let g_b = ctx.bwd.custom_op(
1903            SPARSE_CHOLESKY_SOLVE,
1904            Vec::new(),
1905            vec![vals, cidx, rptr, ctx.upstream],
1906        );
1907        let y_fwd = ctx.fwd_map[&node.id];
1908        let raw_grad =
1909            ctx.bwd
1910                .custom_op(SPARSE_VALUES_GRAD, Vec::new(), vec![cidx, rptr, g_b, y_fwd]);
1911        let raw_shape = ctx.bwd.node(raw_grad).shape.clone();
1912        let g_vals = ctx
1913            .bwd
1914            .activation(rlx_ir::op::Activation::Neg, raw_grad, raw_shape);
1915        vec![(0, g_vals), (3, g_b)]
1916    }
1917}
1918
1919#[cfg(feature = "cpu")]
1920struct SparseCholeskyCpu;
1921#[cfg(feature = "cpu")]
1922impl CpuKernel for SparseCholeskyCpu {
1923    fn name(&self) -> &str {
1924        SPARSE_CHOLESKY_SOLVE
1925    }
1926    fn execute(
1927        &self,
1928        inputs: &[CpuTensorRef<'_>],
1929        output: CpuTensorMut<'_>,
1930        _attrs: &[u8],
1931    ) -> Result<(), String> {
1932        let values = inputs[0].expect_f64("chol values")?;
1933        let col_idx = inputs[1].expect_i32("chol col_idx")?;
1934        let row_ptr = inputs[2].expect_i32("chol row_ptr")?;
1935        let b = inputs[3].expect_f64("chol b")?;
1936        let out = output.expect_f64_mut("chol x")?;
1937        algos::cholesky_solve(values, col_idx, row_ptr, b, out)
1938    }
1939}
1940
1941// ── LSQR ──────────────────────────────────────────────────────────
1942
1943fn decode_lsqr_attrs(attrs: &[u8]) -> Result<(u32, f64, u32), String> {
1944    if attrs.len() < 16 {
1945        return Err(format!("lsqr: attrs len {} < 16", attrs.len()));
1946    }
1947    let max_iter = u32::from_le_bytes(attrs[0..4].try_into().unwrap());
1948    let tol = f64::from_le_bytes(attrs[4..12].try_into().unwrap());
1949    let n_cols = u32::from_le_bytes(attrs[12..16].try_into().unwrap());
1950    Ok((max_iter, tol, n_cols))
1951}
1952
1953struct SparseLsqrExt;
1954
1955impl OpExtension for SparseLsqrExt {
1956    fn name(&self) -> &str {
1957        SPARSE_LSQR_SOLVE
1958    }
1959    fn num_inputs(&self) -> usize {
1960        4
1961    } // values, col_idx, row_ptr, b
1962    fn infer_shape(&self, inputs: &[&Shape], attrs: &[u8]) -> Shape {
1963        let (_, _, n_cols) =
1964            decode_lsqr_attrs(attrs).expect("lsqr: attrs must encode (max_iter, tol, n_cols)");
1965        Shape::new(&[n_cols as usize], inputs[3].dtype())
1966    }
1967    // VJP deferred — see SPARSE_LSQR_SOLVE doc.
1968}
1969
1970#[cfg(feature = "cpu")]
1971struct SparseLsqrCpu;
1972#[cfg(feature = "cpu")]
1973impl CpuKernel for SparseLsqrCpu {
1974    fn name(&self) -> &str {
1975        SPARSE_LSQR_SOLVE
1976    }
1977    fn execute(
1978        &self,
1979        inputs: &[CpuTensorRef<'_>],
1980        output: CpuTensorMut<'_>,
1981        attrs: &[u8],
1982    ) -> Result<(), String> {
1983        let values = inputs[0].expect_f64("lsqr values")?;
1984        let col_idx = inputs[1].expect_i32("lsqr col_idx")?;
1985        let row_ptr = inputs[2].expect_i32("lsqr row_ptr")?;
1986        let b = inputs[3].expect_f64("lsqr b")?;
1987        let out = output.expect_f64_mut("lsqr x")?;
1988        let (max_iter, tol, n_cols) = decode_lsqr_attrs(attrs)?;
1989        algos::lsqr_solve(
1990            values,
1991            col_idx,
1992            row_ptr,
1993            b,
1994            out,
1995            max_iter,
1996            tol,
1997            n_cols as usize,
1998        )
1999    }
2000}
2001
2002// ── Pure-Rust SpGEMM (CSR × CSR → CSR) ────────────────────────────
2003
2004/// CSR × CSR → CSR via Gustavson's algorithm. Pure-Rust convenience
2005/// wrapper around `algos::spgemm_csr` — exposed outside the IR
2006/// because sparsity patterns are structural (and typically static
2007/// across the differentiable training loop), so a one-shot multiply
2008/// at graph-build time is the natural shape. Returns
2009/// `(c_values, c_col_idx, c_row_ptr)` for the output CSR.
2010#[cfg(feature = "cpu")]
2011pub fn spgemm_csr(
2012    a_values: &[f64],
2013    a_col_idx: &[i32],
2014    a_row_ptr: &[i32],
2015    b_values: &[f64],
2016    b_col_idx: &[i32],
2017    b_row_ptr: &[i32],
2018    m: usize,
2019    k: usize,
2020    n: usize,
2021) -> Result<(Vec<f64>, Vec<i32>, Vec<i32>), String> {
2022    algos::spgemm_csr(
2023        a_values, a_col_idx, a_row_ptr, b_values, b_col_idx, b_row_ptr, m, k, n,
2024    )
2025}
2026
2027// ── Pure-Rust helper for the structural CSR transpose pattern ─────
2028
2029/// Compute `(col_idx_T, row_ptr_T)` — the sparsity pattern of `Aᵀ`
2030/// — from `A`'s pattern. This is the structural step that must
2031/// happen before [`SPARSE_TRANSPOSE_VALUES`] can permute the values
2032/// per Newton iteration. Result is independent of the values, so
2033/// downstream callers compute it once and embed as `Op::Constant`.
2034pub fn csr_transpose_pattern(
2035    col_idx: &[i32],
2036    row_ptr: &[i32],
2037    n_rows: usize,
2038    n_cols: usize,
2039) -> (Vec<i32>, Vec<i32>) {
2040    let nnz = col_idx.len();
2041    // Count entries per output-row (= input column).
2042    let mut t_count = vec![0i32; n_cols];
2043    for &c in col_idx {
2044        t_count[c as usize] += 1;
2045    }
2046    let mut t_row_ptr = vec![0i32; n_cols + 1];
2047    for r in 0..n_cols {
2048        t_row_ptr[r + 1] = t_row_ptr[r] + t_count[r];
2049    }
2050    let mut t_col_idx = vec![0i32; nnz];
2051    let mut cursor = t_row_ptr.clone();
2052    for r in 0..n_rows {
2053        for k in row_ptr[r] as usize..row_ptr[r + 1] as usize {
2054            let c = col_idx[k] as usize;
2055            let pos = cursor[c] as usize;
2056            t_col_idx[pos] = r as i32;
2057            cursor[c] += 1;
2058        }
2059    }
2060    (t_col_idx, t_row_ptr)
2061}
2062
2063// ── SparseTensor: the boundary abstraction ────────────────────────
2064
2065/// CSR-format sparse matrix at the IR level. Bundles the three
2066/// CSR `NodeId`s with structural shape info known at graph-build time.
2067///
2068/// Modeled on `jax.experimental.sparse.BCOO` — a wrapper around
2069/// `(data, indices)` arrays plus a `shape` tuple, with methods that
2070/// expand to the right subgraph for each operation.
2071#[derive(Clone, Copy, Debug)]
2072pub struct SparseTensor {
2073    /// Non-zero values in row-major CSR order. F64.
2074    pub values: NodeId,
2075    /// Column index per non-zero. I32.
2076    pub col_idx: NodeId,
2077    /// Start index in `values` / `col_idx` per row, length `n_rows + 1`. I32.
2078    pub row_ptr: NodeId,
2079    /// Logical row count of A.
2080    pub n_rows: usize,
2081    /// Logical column count of A (`n_rows == n_cols` for square / SPD).
2082    pub n_cols: usize,
2083}
2084
2085impl SparseTensor {
2086    /// Build from existing CSR `NodeId`s. Caller is responsible for
2087    /// the layout invariants (sortedness within rows,
2088    /// `row_ptr.len() == n_rows + 1`, etc.).
2089    pub fn from_csr(
2090        values: NodeId,
2091        col_idx: NodeId,
2092        row_ptr: NodeId,
2093        n_rows: usize,
2094        n_cols: usize,
2095    ) -> Self {
2096        Self {
2097            values,
2098            col_idx,
2099            row_ptr,
2100            n_rows,
2101            n_cols,
2102        }
2103    }
2104
2105    /// `y = A · x` for a length-`n_cols` dense vector.
2106    pub fn mat_vec(&self, g: &mut Graph, x: NodeId) -> NodeId {
2107        g.custom_op(
2108            SPARSE_MAT_VEC,
2109            Vec::new(),
2110            vec![self.values, self.col_idx, self.row_ptr, x],
2111        )
2112    }
2113
2114    /// `x = A⁻¹ · b` via direct LU.
2115    pub fn solve(&self, g: &mut Graph, b: NodeId) -> NodeId {
2116        assert_eq!(
2117            self.n_rows, self.n_cols,
2118            "SparseTensor::solve requires a square matrix"
2119        );
2120        g.custom_op(
2121            SPARSE_LU_SOLVE,
2122            Vec::new(),
2123            vec![self.values, self.col_idx, self.row_ptr, b],
2124        )
2125    }
2126
2127    /// `x = A⁻¹ · b` via Conjugate Gradient. SPD only. `tol` is the
2128    /// absolute residual threshold; `max_iter` caps iteration count.
2129    pub fn cg_solve(&self, g: &mut Graph, b: NodeId, max_iter: u32, tol: f64) -> NodeId {
2130        assert_eq!(
2131            self.n_rows, self.n_cols,
2132            "SparseTensor::cg_solve requires a square matrix"
2133        );
2134        g.custom_op(
2135            SPARSE_CG_SOLVE,
2136            encode_cg_attrs(max_iter, tol),
2137            vec![self.values, self.col_idx, self.row_ptr, b],
2138        )
2139    }
2140
2141    /// `x = A⁻¹ · b` via direct LU for **non-symmetric** A. The
2142    /// caller supplies an explicit transpose `adjoint` (CSR of `Aᵀ`)
2143    /// — for square non-symmetric matrices, `Aᵀ` has the same
2144    /// nnz pattern as `A` after a CSR↔CSC swap. The forward solve
2145    /// uses only `self`; the VJP routes the adjoint solve through
2146    /// `adjoint`. Use this in place of `solve` when the assumption
2147    /// `Aᵀ = A` would be wrong.
2148    pub fn solve_general(&self, g: &mut Graph, b: NodeId, adjoint: &SparseTensor) -> NodeId {
2149        assert_eq!(
2150            self.n_rows, self.n_cols,
2151            "SparseTensor::solve_general requires a square matrix"
2152        );
2153        assert_eq!(
2154            adjoint.n_rows, self.n_cols,
2155            "adjoint shape mismatch: A is {}×{}, Aᵀ should be {}×{}",
2156            self.n_rows, self.n_cols, self.n_cols, self.n_rows
2157        );
2158        g.custom_op(
2159            SPARSE_LU_SOLVE_GENERAL,
2160            Vec::new(),
2161            vec![
2162                self.values,
2163                self.col_idx,
2164                self.row_ptr,
2165                b,
2166                adjoint.values,
2167                adjoint.col_idx,
2168                adjoint.row_ptr,
2169            ],
2170        )
2171    }
2172
2173    /// `x = A⁻¹ · b` via Jacobi-preconditioned CG. SPD only.
2174    /// Convergence dramatically faster than plain CG on ill-
2175    /// conditioned matrices where `diag(A)` captures most of the
2176    /// magnitude variation — typical for circuit MNA matrices with
2177    /// mixed-magnitude device parameters. The preconditioner is
2178    /// extracted from the CSR by the kernel; no separate input.
2179    pub fn pcg_solve(&self, g: &mut Graph, b: NodeId, max_iter: u32, tol: f64) -> NodeId {
2180        assert_eq!(
2181            self.n_rows, self.n_cols,
2182            "SparseTensor::pcg_solve requires a square matrix"
2183        );
2184        g.custom_op(
2185            SPARSE_PCG_SOLVE,
2186            encode_cg_attrs(max_iter, tol),
2187            vec![self.values, self.col_idx, self.row_ptr, b],
2188        )
2189    }
2190
2191    /// Permute this tensor's `values` into the values vector of
2192    /// `Aᵀ`. The transposed pattern `(col_idx_t, row_ptr_t)` is
2193    /// supplied as `NodeId`s — typically computed once via
2194    /// [`crate::csr_transpose_pattern`] and embedded as `Op::Constant`
2195    /// since the pattern is fixed across Newton iterations.
2196    pub fn transpose_values(&self, g: &mut Graph, col_idx_t: NodeId, row_ptr_t: NodeId) -> NodeId {
2197        g.custom_op(
2198            SPARSE_TRANSPOSE_VALUES,
2199            Vec::new(),
2200            vec![
2201                self.values,
2202                self.col_idx,
2203                self.row_ptr,
2204                col_idx_t,
2205                row_ptr_t,
2206            ],
2207        )
2208    }
2209
2210    /// `x = A⁻¹ · b` via direct sparse Cholesky for SPD A. Densifies
2211    /// into a dense buffer and calls LAPACK `dpotrf` + triangular
2212    /// solves. Mirror of `solve` (LU-based) but ½× factor cost and
2213    /// numerically more stable; only valid when A is SPD.
2214    pub fn cholesky_solve(&self, g: &mut Graph, b: NodeId) -> NodeId {
2215        assert_eq!(
2216            self.n_rows, self.n_cols,
2217            "SparseTensor::cholesky_solve requires a square matrix"
2218        );
2219        g.custom_op(
2220            SPARSE_CHOLESKY_SOLVE,
2221            Vec::new(),
2222            vec![self.values, self.col_idx, self.row_ptr, b],
2223        )
2224    }
2225
2226    /// `x = argmin ||A·x - b||₂` via LSQR (Paige-Saunders 1982).
2227    /// Works for any A (square / over-determined / under-determined);
2228    /// returns the minimum-norm solution when A is rank-deficient or
2229    /// under-determined. VJP not implemented in v1.
2230    pub fn lsqr_solve(&self, g: &mut Graph, b: NodeId, max_iter: u32, tol: f64) -> NodeId {
2231        let mut attrs = Vec::with_capacity(16);
2232        attrs.extend_from_slice(&max_iter.to_le_bytes());
2233        attrs.extend_from_slice(&tol.to_le_bytes());
2234        attrs.extend_from_slice(&(self.n_cols as u32).to_le_bytes());
2235        g.custom_op(
2236            SPARSE_LSQR_SOLVE,
2237            attrs,
2238            vec![self.values, self.col_idx, self.row_ptr, b],
2239        )
2240    }
2241
2242    /// `x = A⁻¹ · b` via BiCGSTAB for **non-symmetric** A. Same
2243    /// shape contract as CG/PCG (no explicit adjoint pattern needed —
2244    /// the kernel itself can solve Aᵀ·x = b via a flag, used by VJPs).
2245    pub fn bicgstab_solve(&self, g: &mut Graph, b: NodeId, max_iter: u32, tol: f64) -> NodeId {
2246        assert_eq!(
2247            self.n_rows, self.n_cols,
2248            "SparseTensor::bicgstab_solve requires a square matrix"
2249        );
2250        let mut attrs = Vec::with_capacity(13);
2251        attrs.extend_from_slice(&max_iter.to_le_bytes());
2252        attrs.extend_from_slice(&tol.to_le_bytes());
2253        attrs.push(0); // transpose_a = false
2254        g.custom_op(
2255            SPARSE_BICGSTAB_SOLVE,
2256            attrs,
2257            vec![self.values, self.col_idx, self.row_ptr, b],
2258        )
2259    }
2260
2261    /// `x = A⁻¹ · b` via ILU(0)-preconditioned CG. SPD A required
2262    /// (same contract as CG/PCG). ILU is factored on each call —
2263    /// for static-pattern Newton loops the cost amortizes against the
2264    /// faster convergence vs. Jacobi-PCG.
2265    pub fn ilu_pcg_solve(&self, g: &mut Graph, b: NodeId, max_iter: u32, tol: f64) -> NodeId {
2266        assert_eq!(
2267            self.n_rows, self.n_cols,
2268            "SparseTensor::ilu_pcg_solve requires a square matrix"
2269        );
2270        g.custom_op(
2271            SPARSE_ILU_PCG_SOLVE,
2272            encode_cg_attrs(max_iter, tol),
2273            vec![self.values, self.col_idx, self.row_ptr, b],
2274        )
2275    }
2276
2277    /// `x = A⁻¹ · b` via GMRES for **non-symmetric** A. Same
2278    /// transpose-triplet contract as `solve_general`. `max_iter`
2279    /// caps Krylov dimension; `tol` is the residual norm threshold.
2280    pub fn gmres_solve(
2281        &self,
2282        g: &mut Graph,
2283        b: NodeId,
2284        max_iter: u32,
2285        tol: f64,
2286        adjoint: &SparseTensor,
2287    ) -> NodeId {
2288        assert_eq!(
2289            self.n_rows, self.n_cols,
2290            "SparseTensor::gmres_solve requires a square matrix"
2291        );
2292        assert_eq!(adjoint.n_rows, self.n_cols, "adjoint shape mismatch");
2293        g.custom_op(
2294            SPARSE_GMRES_SOLVE,
2295            encode_cg_attrs(max_iter, tol),
2296            vec![
2297                self.values,
2298                self.col_idx,
2299                self.row_ptr,
2300                b,
2301                adjoint.values,
2302                adjoint.col_idx,
2303                adjoint.row_ptr,
2304            ],
2305        )
2306    }
2307}
2308
2309// ── Metal kernels ─────────────────────────────────────────────────
2310//
2311// Active with the `metal` feature. The MetalKernel trait gives us
2312// raw `(&[u8], &Shape)` pairs; we cast each to its declared dtype
2313// and call the same `algos::*` body the CpuKernel impls use. Apple
2314// Silicon's unified memory means `Buffer::contents()` is host-
2315// accessible — running these kernels on the Metal backend is no
2316// slower than running on CPU, *provided* the rlx-metal executor's
2317// segment-at-CustomOp dispatch is wired (which it is, as of the
2318// owned-encoder refactor).
2319
2320#[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
2321mod metal_kernels {
2322    use super::*;
2323    use rlx_ir::DType;
2324    use rlx_metal::op_registry::MetalKernel;
2325
2326    /// Cast `&[u8]` → `&[T]` after dtype-checking the accompanying Shape.
2327    /// Length is taken from the Shape's element count (must match the
2328    /// byte-slice length). Caller asserts contiguous + aligned data,
2329    /// which the rlx-metal arena delivers.
2330    unsafe fn typed<'a, T: Copy>(
2331        bytes: &'a [u8],
2332        shape: &rlx_ir::Shape,
2333        want: DType,
2334        role: &str,
2335    ) -> Result<&'a [T], String> {
2336        if shape.dtype() != want {
2337            return Err(format!(
2338                "{role}: expected {want:?}, got {:?}",
2339                shape.dtype()
2340            ));
2341        }
2342        let n = shape
2343            .num_elements()
2344            .ok_or_else(|| format!("{role}: dynamic shape not supported"))?;
2345        let need = n * std::mem::size_of::<T>();
2346        if bytes.len() < need {
2347            return Err(format!("{role}: bytes {} < need {need}", bytes.len()));
2348        }
2349        Ok(unsafe { std::slice::from_raw_parts(bytes.as_ptr() as *const T, n) })
2350    }
2351
2352    unsafe fn typed_mut<'a, T: Copy>(
2353        bytes: &'a mut [u8],
2354        shape: &rlx_ir::Shape,
2355        want: DType,
2356        role: &str,
2357    ) -> Result<&'a mut [T], String> {
2358        if shape.dtype() != want {
2359            return Err(format!(
2360                "{role}: expected {want:?}, got {:?}",
2361                shape.dtype()
2362            ));
2363        }
2364        let n = shape
2365            .num_elements()
2366            .ok_or_else(|| format!("{role}: dynamic shape not supported"))?;
2367        let need = n * std::mem::size_of::<T>();
2368        if bytes.len() < need {
2369            return Err(format!("{role}: bytes {} < need {need}", bytes.len()));
2370        }
2371        Ok(unsafe { std::slice::from_raw_parts_mut(bytes.as_mut_ptr() as *mut T, n) })
2372    }
2373
2374    #[derive(Debug)]
2375    pub(super) struct SparseLuMetal;
2376    impl MetalKernel for SparseLuMetal {
2377        fn name(&self) -> &str {
2378            SPARSE_LU_SOLVE
2379        }
2380        fn execute(
2381            &self,
2382            inputs: &[(&[u8], &rlx_ir::Shape)],
2383            output: (&mut [u8], &rlx_ir::Shape),
2384            _attrs: &[u8],
2385        ) -> Result<(), String> {
2386            unsafe {
2387                let values = typed::<f64>(inputs[0].0, inputs[0].1, DType::F64, "values")?;
2388                let col_idx = typed::<i32>(inputs[1].0, inputs[1].1, DType::I32, "col_idx")?;
2389                let row_ptr = typed::<i32>(inputs[2].0, inputs[2].1, DType::I32, "row_ptr")?;
2390                let b = typed::<f64>(inputs[3].0, inputs[3].1, DType::F64, "b")?;
2391                let out = typed_mut::<f64>(output.0, output.1, DType::F64, "out")?;
2392                algos::lu_solve(values, col_idx, row_ptr, b, out)
2393            }
2394        }
2395    }
2396
2397    #[derive(Debug)]
2398    pub(super) struct SparseMatVecMetal;
2399    impl MetalKernel for SparseMatVecMetal {
2400        fn name(&self) -> &str {
2401            SPARSE_MAT_VEC
2402        }
2403        fn execute(
2404            &self,
2405            inputs: &[(&[u8], &rlx_ir::Shape)],
2406            output: (&mut [u8], &rlx_ir::Shape),
2407            _attrs: &[u8],
2408        ) -> Result<(), String> {
2409            unsafe {
2410                let values = typed::<f64>(inputs[0].0, inputs[0].1, DType::F64, "values")?;
2411                let col_idx = typed::<i32>(inputs[1].0, inputs[1].1, DType::I32, "col_idx")?;
2412                let row_ptr = typed::<i32>(inputs[2].0, inputs[2].1, DType::I32, "row_ptr")?;
2413                let x = typed::<f64>(inputs[3].0, inputs[3].1, DType::F64, "x")?;
2414                let out = typed_mut::<f64>(output.0, output.1, DType::F64, "out")?;
2415                algos::mat_vec(values, col_idx, row_ptr, x, out)
2416            }
2417        }
2418    }
2419
2420    #[derive(Debug)]
2421    pub(super) struct SparseCgMetal;
2422    impl MetalKernel for SparseCgMetal {
2423        fn name(&self) -> &str {
2424            SPARSE_CG_SOLVE
2425        }
2426        fn execute(
2427            &self,
2428            inputs: &[(&[u8], &rlx_ir::Shape)],
2429            output: (&mut [u8], &rlx_ir::Shape),
2430            attrs: &[u8],
2431        ) -> Result<(), String> {
2432            let (max_iter, tol) = decode_cg_attrs(attrs)?;
2433            unsafe {
2434                let values = typed::<f64>(inputs[0].0, inputs[0].1, DType::F64, "values")?;
2435                let col_idx = typed::<i32>(inputs[1].0, inputs[1].1, DType::I32, "col_idx")?;
2436                let row_ptr = typed::<i32>(inputs[2].0, inputs[2].1, DType::I32, "row_ptr")?;
2437                let b = typed::<f64>(inputs[3].0, inputs[3].1, DType::F64, "b")?;
2438                let out = typed_mut::<f64>(output.0, output.1, DType::F64, "out")?;
2439                algos::cg_solve(values, col_idx, row_ptr, b, out, max_iter, tol)
2440            }
2441        }
2442    }
2443
2444    #[derive(Debug)]
2445    pub(super) struct SparseValuesGradMetal;
2446    impl MetalKernel for SparseValuesGradMetal {
2447        fn name(&self) -> &str {
2448            SPARSE_VALUES_GRAD
2449        }
2450        fn execute(
2451            &self,
2452            inputs: &[(&[u8], &rlx_ir::Shape)],
2453            output: (&mut [u8], &rlx_ir::Shape),
2454            _attrs: &[u8],
2455        ) -> Result<(), String> {
2456            unsafe {
2457                let col_idx = typed::<i32>(inputs[0].0, inputs[0].1, DType::I32, "col_idx")?;
2458                let row_ptr = typed::<i32>(inputs[1].0, inputs[1].1, DType::I32, "row_ptr")?;
2459                let u = typed::<f64>(inputs[2].0, inputs[2].1, DType::F64, "u")?;
2460                let v = typed::<f64>(inputs[3].0, inputs[3].1, DType::F64, "v")?;
2461                let out = typed_mut::<f64>(output.0, output.1, DType::F64, "out")?;
2462                algos::values_grad(col_idx, row_ptr, u, v, out)
2463            }
2464        }
2465    }
2466
2467    #[derive(Debug)]
2468    pub(super) struct SparseLuGeneralMetal;
2469    impl MetalKernel for SparseLuGeneralMetal {
2470        fn name(&self) -> &str {
2471            SPARSE_LU_SOLVE_GENERAL
2472        }
2473        fn execute(
2474            &self,
2475            inputs: &[(&[u8], &rlx_ir::Shape)],
2476            output: (&mut [u8], &rlx_ir::Shape),
2477            _attrs: &[u8],
2478        ) -> Result<(), String> {
2479            // Forward only reads A; AT triplet (inputs 4..=6) rides
2480            // along for the VJP path and is unused here.
2481            unsafe {
2482                let values = typed::<f64>(inputs[0].0, inputs[0].1, DType::F64, "values")?;
2483                let col_idx = typed::<i32>(inputs[1].0, inputs[1].1, DType::I32, "col_idx")?;
2484                let row_ptr = typed::<i32>(inputs[2].0, inputs[2].1, DType::I32, "row_ptr")?;
2485                let b = typed::<f64>(inputs[3].0, inputs[3].1, DType::F64, "b")?;
2486                let out = typed_mut::<f64>(output.0, output.1, DType::F64, "out")?;
2487                algos::lu_solve(values, col_idx, row_ptr, b, out)
2488            }
2489        }
2490    }
2491
2492    #[derive(Debug)]
2493    pub(super) struct SparseGmresMetal;
2494    impl MetalKernel for SparseGmresMetal {
2495        fn name(&self) -> &str {
2496            SPARSE_GMRES_SOLVE
2497        }
2498        fn execute(
2499            &self,
2500            inputs: &[(&[u8], &rlx_ir::Shape)],
2501            output: (&mut [u8], &rlx_ir::Shape),
2502            attrs: &[u8],
2503        ) -> Result<(), String> {
2504            let (max_iter, tol) = decode_cg_attrs(attrs)?;
2505            unsafe {
2506                let values = typed::<f64>(inputs[0].0, inputs[0].1, DType::F64, "values")?;
2507                let col_idx = typed::<i32>(inputs[1].0, inputs[1].1, DType::I32, "col_idx")?;
2508                let row_ptr = typed::<i32>(inputs[2].0, inputs[2].1, DType::I32, "row_ptr")?;
2509                let b = typed::<f64>(inputs[3].0, inputs[3].1, DType::F64, "b")?;
2510                let out = typed_mut::<f64>(output.0, output.1, DType::F64, "out")?;
2511                algos::gmres_solve(values, col_idx, row_ptr, b, out, max_iter, tol)
2512            }
2513        }
2514    }
2515}
2516
2517// ── MLX kernels ───────────────────────────────────────────────────
2518//
2519// Active with the `mlx` feature. The `MlxKernel` trait gives us
2520// MLX `Array` handles (lazy graph nodes); we read each input's
2521// bytes, run the same `algos::*` body the CPU + Metal kernels use,
2522// and build a new `Array` of the output shape from the result
2523// bytes. The lazy graph absorbs the new `Array` as the value for
2524// this `Op::Custom` node, so consumers downstream see it as just
2525// another operand.
2526//
2527// Same caveat as the Metal kernels: this is a host-callback that
2528// runs f64 LAPACK on the ARM cores, not GPU compute. The point is
2529// that an MLX-shaped graph that *contains* sparse-LU still routes
2530// correctly through MLX's pipeline; surrounding ops still benefit
2531// from MLX's lazy graph optimizer.
2532
2533#[cfg(all(feature = "mlx", target_os = "macos"))]
2534mod mlx_kernels {
2535    use super::*;
2536    use rlx_ir::DType;
2537    use rlx_mlx::array::{Array, MlxError};
2538    use rlx_mlx::op_registry::MlxKernel;
2539
2540    fn shape_dims_static(s: &rlx_ir::Shape) -> Result<Vec<usize>, MlxError> {
2541        s.dims()
2542            .iter()
2543            .map(|d| match d {
2544                rlx_ir::Dim::Static(n) => Ok(*n),
2545                _ => Err(MlxError(
2546                    "rlx-sparse mlx kernel: dynamic shape not supported".into(),
2547                )),
2548            })
2549            .collect()
2550    }
2551
2552    /// Reinterpret a byte buffer as f64. Length is byte-count / 8.
2553    fn bytes_to_f64(b: &[u8]) -> Vec<f64> {
2554        b.chunks_exact(8)
2555            .map(|c| f64::from_le_bytes(c.try_into().unwrap()))
2556            .collect()
2557    }
2558    fn bytes_to_i32(b: &[u8]) -> Vec<i32> {
2559        b.chunks_exact(4)
2560            .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
2561            .collect()
2562    }
2563    fn f64_to_bytes(xs: &[f64]) -> Vec<u8> {
2564        let mut out = Vec::with_capacity(xs.len() * 8);
2565        for x in xs {
2566            out.extend_from_slice(&x.to_le_bytes());
2567        }
2568        out
2569    }
2570
2571    fn run_lu(inputs: &[&Array], output_shape: &rlx_ir::Shape) -> Result<Array, MlxError> {
2572        let values = bytes_to_f64(&inputs[0].to_bytes()?);
2573        let col_idx = bytes_to_i32(&inputs[1].to_bytes()?);
2574        let row_ptr = bytes_to_i32(&inputs[2].to_bytes()?);
2575        let b = bytes_to_f64(&inputs[3].to_bytes()?);
2576        let mut out = vec![0f64; b.len()];
2577        algos::lu_solve(&values, &col_idx, &row_ptr, &b, &mut out).map_err(MlxError)?;
2578        let dims = shape_dims_static(output_shape)?;
2579        Array::from_bytes(&f64_to_bytes(&out), &dims, DType::F64)
2580    }
2581
2582    fn run_mat_vec(inputs: &[&Array], output_shape: &rlx_ir::Shape) -> Result<Array, MlxError> {
2583        let values = bytes_to_f64(&inputs[0].to_bytes()?);
2584        let col_idx = bytes_to_i32(&inputs[1].to_bytes()?);
2585        let row_ptr = bytes_to_i32(&inputs[2].to_bytes()?);
2586        let x = bytes_to_f64(&inputs[3].to_bytes()?);
2587        let mut out = vec![0f64; x.len()];
2588        algos::mat_vec(&values, &col_idx, &row_ptr, &x, &mut out).map_err(MlxError)?;
2589        let dims = shape_dims_static(output_shape)?;
2590        Array::from_bytes(&f64_to_bytes(&out), &dims, DType::F64)
2591    }
2592
2593    fn run_cg(
2594        inputs: &[&Array],
2595        output_shape: &rlx_ir::Shape,
2596        attrs: &[u8],
2597    ) -> Result<Array, MlxError> {
2598        let (max_iter, tol) = decode_cg_attrs(attrs).map_err(MlxError)?;
2599        let values = bytes_to_f64(&inputs[0].to_bytes()?);
2600        let col_idx = bytes_to_i32(&inputs[1].to_bytes()?);
2601        let row_ptr = bytes_to_i32(&inputs[2].to_bytes()?);
2602        let b = bytes_to_f64(&inputs[3].to_bytes()?);
2603        let mut out = vec![0f64; b.len()];
2604        algos::cg_solve(&values, &col_idx, &row_ptr, &b, &mut out, max_iter, tol)
2605            .map_err(MlxError)?;
2606        let dims = shape_dims_static(output_shape)?;
2607        Array::from_bytes(&f64_to_bytes(&out), &dims, DType::F64)
2608    }
2609
2610    pub(super) struct SparseLuMlx;
2611    impl MlxKernel for SparseLuMlx {
2612        fn name(&self) -> &str {
2613            SPARSE_LU_SOLVE
2614        }
2615        fn execute(
2616            &self,
2617            inputs: &[&Array],
2618            out_shape: &rlx_ir::Shape,
2619            _attrs: &[u8],
2620        ) -> Result<Array, MlxError> {
2621            run_lu(inputs, out_shape)
2622        }
2623    }
2624    pub(super) struct SparseMatVecMlx;
2625    impl MlxKernel for SparseMatVecMlx {
2626        fn name(&self) -> &str {
2627            SPARSE_MAT_VEC
2628        }
2629        fn execute(
2630            &self,
2631            inputs: &[&Array],
2632            out_shape: &rlx_ir::Shape,
2633            _attrs: &[u8],
2634        ) -> Result<Array, MlxError> {
2635            run_mat_vec(inputs, out_shape)
2636        }
2637    }
2638    pub(super) struct SparseCgMlx;
2639    impl MlxKernel for SparseCgMlx {
2640        fn name(&self) -> &str {
2641            SPARSE_CG_SOLVE
2642        }
2643        fn execute(
2644            &self,
2645            inputs: &[&Array],
2646            out_shape: &rlx_ir::Shape,
2647            attrs: &[u8],
2648        ) -> Result<Array, MlxError> {
2649            run_cg(inputs, out_shape, attrs)
2650        }
2651    }
2652
2653    fn run_values_grad(inputs: &[&Array], output_shape: &rlx_ir::Shape) -> Result<Array, MlxError> {
2654        let col_idx = bytes_to_i32(&inputs[0].to_bytes()?);
2655        let row_ptr = bytes_to_i32(&inputs[1].to_bytes()?);
2656        let u = bytes_to_f64(&inputs[2].to_bytes()?);
2657        let v = bytes_to_f64(&inputs[3].to_bytes()?);
2658        let mut out = vec![0f64; col_idx.len()];
2659        algos::values_grad(&col_idx, &row_ptr, &u, &v, &mut out).map_err(MlxError)?;
2660        let dims = shape_dims_static(output_shape)?;
2661        Array::from_bytes(&f64_to_bytes(&out), &dims, DType::F64)
2662    }
2663
2664    fn run_lu_general(inputs: &[&Array], output_shape: &rlx_ir::Shape) -> Result<Array, MlxError> {
2665        // Forward reads only inputs 0..=3 (values_A, col_idx_A,
2666        // row_ptr_A, b); inputs 4..=6 are the AT triplet for the VJP.
2667        let values = bytes_to_f64(&inputs[0].to_bytes()?);
2668        let col_idx = bytes_to_i32(&inputs[1].to_bytes()?);
2669        let row_ptr = bytes_to_i32(&inputs[2].to_bytes()?);
2670        let b = bytes_to_f64(&inputs[3].to_bytes()?);
2671        let mut out = vec![0f64; b.len()];
2672        algos::lu_solve(&values, &col_idx, &row_ptr, &b, &mut out).map_err(MlxError)?;
2673        let dims = shape_dims_static(output_shape)?;
2674        Array::from_bytes(&f64_to_bytes(&out), &dims, DType::F64)
2675    }
2676
2677    fn run_gmres(
2678        inputs: &[&Array],
2679        output_shape: &rlx_ir::Shape,
2680        attrs: &[u8],
2681    ) -> Result<Array, MlxError> {
2682        let (max_iter, tol) = decode_cg_attrs(attrs).map_err(MlxError)?;
2683        let values = bytes_to_f64(&inputs[0].to_bytes()?);
2684        let col_idx = bytes_to_i32(&inputs[1].to_bytes()?);
2685        let row_ptr = bytes_to_i32(&inputs[2].to_bytes()?);
2686        let b = bytes_to_f64(&inputs[3].to_bytes()?);
2687        let mut out = vec![0f64; b.len()];
2688        algos::gmres_solve(&values, &col_idx, &row_ptr, &b, &mut out, max_iter, tol)
2689            .map_err(MlxError)?;
2690        let dims = shape_dims_static(output_shape)?;
2691        Array::from_bytes(&f64_to_bytes(&out), &dims, DType::F64)
2692    }
2693
2694    pub(super) struct SparseValuesGradMlx;
2695    impl MlxKernel for SparseValuesGradMlx {
2696        fn name(&self) -> &str {
2697            SPARSE_VALUES_GRAD
2698        }
2699        fn execute(
2700            &self,
2701            inputs: &[&Array],
2702            out_shape: &rlx_ir::Shape,
2703            _attrs: &[u8],
2704        ) -> Result<Array, MlxError> {
2705            run_values_grad(inputs, out_shape)
2706        }
2707    }
2708    pub(super) struct SparseLuGeneralMlx;
2709    impl MlxKernel for SparseLuGeneralMlx {
2710        fn name(&self) -> &str {
2711            SPARSE_LU_SOLVE_GENERAL
2712        }
2713        fn execute(
2714            &self,
2715            inputs: &[&Array],
2716            out_shape: &rlx_ir::Shape,
2717            _attrs: &[u8],
2718        ) -> Result<Array, MlxError> {
2719            run_lu_general(inputs, out_shape)
2720        }
2721    }
2722    pub(super) struct SparseGmresMlx;
2723    impl MlxKernel for SparseGmresMlx {
2724        fn name(&self) -> &str {
2725            SPARSE_GMRES_SOLVE
2726        }
2727        fn execute(
2728            &self,
2729            inputs: &[&Array],
2730            out_shape: &rlx_ir::Shape,
2731            attrs: &[u8],
2732        ) -> Result<Array, MlxError> {
2733            run_gmres(inputs, out_shape, attrs)
2734        }
2735    }
2736}
2737
2738// ── Registration ──────────────────────────────────────────────────
2739
2740/// Register every sparse op's IR-level extension and per-backend
2741/// kernels enabled at compile time. Idempotent — the underlying
2742/// registries already warn on overwrite. Call once at application
2743/// startup.
2744/// Host CG for SPD `A·x = b` given CSR `(values, col_idx, row_ptr)`.
2745pub fn cg_solve(
2746    values: &[f64],
2747    col_idx: &[i32],
2748    row_ptr: &[i32],
2749    b: &[f64],
2750    out: &mut [f64],
2751    max_iter: u32,
2752    tol: f64,
2753) -> Result<(), String> {
2754    algos::cg_solve(values, col_idx, row_ptr, b, out, max_iter, tol)
2755}
2756
2757pub fn register() {
2758    register_op(Arc::new(SparseLuExt));
2759    register_op(Arc::new(SparseMatVecExt));
2760    register_op(Arc::new(SparseCgExt));
2761    register_op(Arc::new(SparseValuesGradExt));
2762    register_op(Arc::new(SparseLuGeneralExt));
2763    register_op(Arc::new(SparseGmresExt));
2764    register_op(Arc::new(SparseTransposeValuesExt));
2765    register_op(Arc::new(SparsePcgExt));
2766    register_op(Arc::new(SparseBicgstabExt));
2767    register_op(Arc::new(SparseIluPcgExt));
2768    register_op(Arc::new(SparseCholeskyExt));
2769    register_op(Arc::new(SparseLsqrExt));
2770
2771    #[cfg(feature = "cpu")]
2772    {
2773        register_cpu_kernel(Arc::new(SparseLuCpu));
2774        register_cpu_kernel(Arc::new(SparseMatVecCpu));
2775        register_cpu_kernel(Arc::new(SparseCgCpu));
2776        register_cpu_kernel(Arc::new(SparseValuesGradCpu));
2777        register_cpu_kernel(Arc::new(SparseLuGeneralCpu));
2778        register_cpu_kernel(Arc::new(SparseGmresCpu));
2779        register_cpu_kernel(Arc::new(SparseTransposeValuesCpu));
2780        register_cpu_kernel(Arc::new(SparsePcgCpu));
2781        register_cpu_kernel(Arc::new(SparseBicgstabCpu));
2782        register_cpu_kernel(Arc::new(SparseIluPcgCpu));
2783        register_cpu_kernel(Arc::new(SparseCholeskyCpu));
2784        register_cpu_kernel(Arc::new(SparseLsqrCpu));
2785    }
2786
2787    #[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
2788    {
2789        use rlx_metal::op_registry::register_metal_kernel;
2790        register_metal_kernel(Arc::new(metal_kernels::SparseLuMetal));
2791        register_metal_kernel(Arc::new(metal_kernels::SparseMatVecMetal));
2792        register_metal_kernel(Arc::new(metal_kernels::SparseCgMetal));
2793        register_metal_kernel(Arc::new(metal_kernels::SparseValuesGradMetal));
2794        register_metal_kernel(Arc::new(metal_kernels::SparseLuGeneralMetal));
2795        register_metal_kernel(Arc::new(metal_kernels::SparseGmresMetal));
2796    }
2797
2798    #[cfg(all(feature = "mlx", target_os = "macos"))]
2799    {
2800        use rlx_mlx::op_registry::register_mlx_kernel;
2801        register_mlx_kernel(Arc::new(mlx_kernels::SparseLuMlx));
2802        register_mlx_kernel(Arc::new(mlx_kernels::SparseMatVecMlx));
2803        register_mlx_kernel(Arc::new(mlx_kernels::SparseCgMlx));
2804        register_mlx_kernel(Arc::new(mlx_kernels::SparseValuesGradMlx));
2805        register_mlx_kernel(Arc::new(mlx_kernels::SparseLuGeneralMlx));
2806        register_mlx_kernel(Arc::new(mlx_kernels::SparseGmresMlx));
2807    }
2808}