Skip to main content

scirs2_sparse/learned_smoother/
integration.rs

1//! Hybrid multigrid solver with learned smoother integration.
2//!
3//! Implements a simple 2-level multigrid V-cycle where the fine-level
4//! smoother is replaced with a learned smoother (linear or MLP).
5//! Falls back to classical Jacobi if the learned smoother increases
6//! the residual.
7
8use crate::error::{SparseError, SparseResult};
9use crate::learned_smoother::types::{LearnedSmootherConfig, Smoother, SmootherMetrics};
10
11/// Type alias for geometric coarsening result:
12/// (R_values, R_col_indices, R_row_ptr, P_values, P_col_indices, P_row_ptr, coarse_n).
13type CoarseningResult = (
14    Vec<f64>,
15    Vec<usize>,
16    Vec<usize>,
17    Vec<f64>,
18    Vec<usize>,
19    Vec<usize>,
20    usize,
21);
22
23// ---------------------------------------------------------------------------
24// CSR helpers
25// ---------------------------------------------------------------------------
26
27/// y = A x (CSR).
28fn csr_matvec(a_values: &[f64], a_row_ptr: &[usize], a_col_idx: &[usize], x: &[f64]) -> Vec<f64> {
29    let n = a_row_ptr.len().saturating_sub(1);
30    let mut y = vec![0.0; n];
31    for i in 0..n {
32        let start = a_row_ptr[i];
33        let end = a_row_ptr[i + 1];
34        let mut sum = 0.0;
35        for pos in start..end {
36            sum += a_values[pos] * x[a_col_idx[pos]];
37        }
38        y[i] = sum;
39    }
40    y
41}
42
43/// r = b - A x.
44fn compute_residual(
45    a_values: &[f64],
46    a_row_ptr: &[usize],
47    a_col_idx: &[usize],
48    x: &[f64],
49    b: &[f64],
50) -> Vec<f64> {
51    let ax = csr_matvec(a_values, a_row_ptr, a_col_idx, x);
52    b.iter()
53        .zip(ax.iter())
54        .map(|(&bi, &axi)| bi - axi)
55        .collect()
56}
57
58/// Euclidean norm.
59fn vec_norm(v: &[f64]) -> f64 {
60    v.iter().map(|x| x * x).sum::<f64>().sqrt()
61}
62
63/// Extract diagonal of A (CSR).
64fn extract_diagonal(
65    a_values: &[f64],
66    a_row_ptr: &[usize],
67    a_col_idx: &[usize],
68    n: usize,
69) -> Vec<f64> {
70    let mut diag = vec![0.0; n];
71    for i in 0..n {
72        let start = a_row_ptr[i];
73        let end = a_row_ptr[i + 1];
74        for pos in start..end {
75            if a_col_idx[pos] == i {
76                diag[i] = a_values[pos];
77                break;
78            }
79        }
80    }
81    diag
82}
83
84/// Weighted Jacobi smoother: x += omega * D^{-1} * r.
85fn jacobi_smooth(
86    a_values: &[f64],
87    a_row_ptr: &[usize],
88    a_col_idx: &[usize],
89    x: &mut [f64],
90    b: &[f64],
91    diag_inv: &[f64],
92    omega: f64,
93    n_sweeps: usize,
94) {
95    let n = x.len();
96    for _ in 0..n_sweeps {
97        let r = compute_residual(a_values, a_row_ptr, a_col_idx, x, b);
98        for i in 0..n {
99            x[i] += omega * diag_inv[i] * r[i];
100        }
101    }
102}
103
104// ---------------------------------------------------------------------------
105// Simple coarsening
106// ---------------------------------------------------------------------------
107
108/// Build a simple geometric coarsening: keep every other node.
109/// Returns (restriction, prolongation, coarse_n).
110///
111/// Restriction R is n_c x n injection: R[c, 2c] = 1.
112/// Prolongation P is n x n_c linear interpolation.
113fn build_coarsening(n: usize) -> CoarseningResult {
114    let n_c = (n + 1) / 2;
115
116    // --- Restriction (n_c x n) in CSR ---
117    let mut r_values = Vec::new();
118    let mut r_col_idx = Vec::new();
119    let mut r_row_ptr = vec![0usize];
120
121    for c in 0..n_c {
122        let fine = 2 * c;
123        if fine > 0 {
124            r_values.push(0.25);
125            r_col_idx.push(fine - 1);
126        }
127        r_values.push(0.5);
128        r_col_idx.push(fine);
129        if fine + 1 < n {
130            r_values.push(0.25);
131            r_col_idx.push(fine + 1);
132        }
133        r_row_ptr.push(r_values.len());
134    }
135
136    // --- Prolongation (n x n_c) in CSR ---
137    let mut p_values = Vec::new();
138    let mut p_col_idx = Vec::new();
139    let mut p_row_ptr = vec![0usize];
140
141    for i in 0..n {
142        if i % 2 == 0 {
143            // Coarse point: direct injection
144            let c = i / 2;
145            p_values.push(1.0);
146            p_col_idx.push(c);
147        } else {
148            // Fine point: average of two coarse neighbors
149            let c_left = i / 2;
150            let c_right = c_left + 1;
151            p_values.push(0.5);
152            p_col_idx.push(c_left);
153            if c_right < n_c {
154                p_values.push(0.5);
155                p_col_idx.push(c_right);
156            }
157        }
158        p_row_ptr.push(p_values.len());
159    }
160
161    (
162        r_values, r_row_ptr, r_col_idx, p_values, p_row_ptr, p_col_idx, n_c,
163    )
164}
165
166/// Compute A_c = R A P (Galerkin coarse-grid operator) using raw CSR operations.
167///
168/// This is a simplified implementation for the 2-level multigrid.
169fn compute_coarse_operator(
170    a_values: &[f64],
171    a_row_ptr: &[usize],
172    a_col_idx: &[usize],
173    n: usize,
174    r_values: &[f64],
175    r_row_ptr: &[usize],
176    r_col_idx: &[usize],
177    p_values: &[f64],
178    p_row_ptr: &[usize],
179    p_col_idx: &[usize],
180    n_c: usize,
181) -> (Vec<f64>, Vec<usize>, Vec<usize>) {
182    // Build A_c row by row using dense accumulation (fine for small coarse grids)
183    let mut ac_dense = vec![vec![0.0; n_c]; n_c];
184
185    for ic in 0..n_c {
186        // e_c = unit vector in coarse space
187        let mut e_c = vec![0.0; n_c];
188        e_c[ic] = 1.0;
189
190        // p_e = P * e_c (prolongation)
191        let p_e = csr_matvec(p_values, p_row_ptr, p_col_idx, &e_c);
192
193        // a_p_e = A * P * e_c
194        let a_p_e = csr_matvec(a_values, a_row_ptr, a_col_idx, &p_e);
195
196        // r_a_p_e = R * A * P * e_c
197        let r_a_p_e = csr_matvec(r_values, r_row_ptr, r_col_idx, &a_p_e);
198
199        for jc in 0..n_c {
200            ac_dense[jc][ic] = r_a_p_e[jc];
201        }
202    }
203
204    // Convert dense to CSR
205    let mut ac_values = Vec::new();
206    let mut ac_col_idx = Vec::new();
207    let mut ac_row_ptr = vec![0usize];
208
209    for i in 0..n_c {
210        for j in 0..n_c {
211            if ac_dense[i][j].abs() > 1e-14 {
212                ac_values.push(ac_dense[i][j]);
213                ac_col_idx.push(j);
214            }
215        }
216        ac_row_ptr.push(ac_values.len());
217    }
218
219    (ac_values, ac_row_ptr, ac_col_idx)
220}
221
222/// Direct solve for small dense system (Gaussian elimination).
223fn direct_solve(
224    a_values: &[f64],
225    a_row_ptr: &[usize],
226    a_col_idx: &[usize],
227    b: &[f64],
228    n: usize,
229) -> SparseResult<Vec<f64>> {
230    if n == 0 {
231        return Ok(Vec::new());
232    }
233
234    // Convert CSR to dense
235    let mut dense = vec![vec![0.0; n]; n];
236    for i in 0..n {
237        let start = a_row_ptr[i];
238        let end = a_row_ptr[i + 1];
239        for pos in start..end {
240            dense[i][a_col_idx[pos]] = a_values[pos];
241        }
242    }
243
244    // Augment with RHS
245    let mut aug: Vec<Vec<f64>> = dense
246        .iter()
247        .enumerate()
248        .map(|(i, row)| {
249            let mut r = row.clone();
250            r.push(b[i]);
251            r
252        })
253        .collect();
254
255    // Forward elimination with partial pivoting
256    for col in 0..n {
257        // Find pivot
258        let mut max_val = aug[col][col].abs();
259        let mut max_row = col;
260        for row in (col + 1)..n {
261            if aug[row][col].abs() > max_val {
262                max_val = aug[row][col].abs();
263                max_row = row;
264            }
265        }
266        if max_val < 1e-14 {
267            return Err(SparseError::SingularMatrix(
268                "Coarse grid operator is singular".to_string(),
269            ));
270        }
271        aug.swap(col, max_row);
272
273        let pivot = aug[col][col];
274        for row in (col + 1)..n {
275            let factor = aug[row][col] / pivot;
276            for j in col..=n {
277                let val = aug[col][j];
278                aug[row][j] -= factor * val;
279            }
280        }
281    }
282
283    // Back substitution
284    let mut x = vec![0.0; n];
285    for i in (0..n).rev() {
286        let mut sum = aug[i][n];
287        for j in (i + 1)..n {
288            sum -= aug[i][j] * x[j];
289        }
290        x[i] = sum / aug[i][i];
291    }
292
293    Ok(x)
294}
295
296// ---------------------------------------------------------------------------
297// HybridMultigridSolver
298// ---------------------------------------------------------------------------
299
300/// Hybrid 2-level multigrid solver with a learned smoother at the fine level.
301///
302/// The solver performs V-cycles:
303/// 1. Pre-smooth (learned smoother)
304/// 2. Restrict residual to coarse grid
305/// 3. Solve coarse system (direct solve)
306/// 4. Prolongate correction to fine grid
307/// 5. Post-smooth (learned smoother)
308///
309/// If the learned smoother increases the residual, the solver falls back
310/// to classical weighted Jacobi.
311pub struct HybridMultigridSolver {
312    // Fine grid operator (CSR)
313    a_values: Vec<f64>,
314    a_row_ptr: Vec<usize>,
315    a_col_idx: Vec<usize>,
316    n: usize,
317
318    // Coarse grid operator (CSR)
319    ac_values: Vec<f64>,
320    ac_row_ptr: Vec<usize>,
321    ac_col_idx: Vec<usize>,
322    n_c: usize,
323
324    // Transfer operators (CSR)
325    r_values: Vec<f64>,
326    r_row_ptr: Vec<usize>,
327    r_col_idx: Vec<usize>,
328    p_values: Vec<f64>,
329    p_row_ptr: Vec<usize>,
330    p_col_idx: Vec<usize>,
331
332    // Smoother
333    smoother: Box<dyn Smoother>,
334
335    // Fallback diagonal inverse for Jacobi
336    diag_inv: Vec<f64>,
337
338    // Configuration
339    config: LearnedSmootherConfig,
340}
341
342impl HybridMultigridSolver {
343    /// Create a new hybrid multigrid solver.
344    ///
345    /// # Arguments
346    /// - `a_values`, `a_row_ptr`, `a_col_idx`: fine-grid operator in CSR
347    /// - `smoother`: a trained (or untrained) learned smoother
348    /// - `config`: solver configuration
349    pub fn new(
350        a_values: Vec<f64>,
351        a_row_ptr: Vec<usize>,
352        a_col_idx: Vec<usize>,
353        smoother: Box<dyn Smoother>,
354        config: LearnedSmootherConfig,
355    ) -> SparseResult<Self> {
356        let n = a_row_ptr.len().saturating_sub(1);
357        if n == 0 {
358            return Err(SparseError::ValueError(
359                "Matrix dimension must be positive".to_string(),
360            ));
361        }
362
363        // Build coarsening operators
364        let (r_values, r_row_ptr, r_col_idx, p_values, p_row_ptr, p_col_idx, n_c) =
365            build_coarsening(n);
366
367        // Build coarse operator
368        let (ac_values, ac_row_ptr, ac_col_idx) = compute_coarse_operator(
369            &a_values, &a_row_ptr, &a_col_idx, n, &r_values, &r_row_ptr, &r_col_idx, &p_values,
370            &p_row_ptr, &p_col_idx, n_c,
371        );
372
373        // Compute diagonal inverse for Jacobi fallback
374        let diag = extract_diagonal(&a_values, &a_row_ptr, &a_col_idx, n);
375        let diag_inv: Vec<f64> = diag
376            .iter()
377            .map(|&d| if d.abs() > f64::EPSILON { 1.0 / d } else { 1.0 })
378            .collect();
379
380        Ok(Self {
381            a_values,
382            a_row_ptr,
383            a_col_idx,
384            n,
385            ac_values,
386            ac_row_ptr,
387            ac_col_idx,
388            n_c,
389            r_values,
390            r_row_ptr,
391            r_col_idx,
392            p_values,
393            p_row_ptr,
394            p_col_idx,
395            smoother,
396            diag_inv,
397            config,
398        })
399    }
400
401    /// Perform one V-cycle: pre-smooth → restrict → coarse-solve → prolongate → post-smooth.
402    fn v_cycle(&self, x: &mut [f64], b: &[f64]) -> SparseResult<()> {
403        // 1. Pre-smooth
404        self.smoother.smooth(
405            &self.a_values,
406            &self.a_row_ptr,
407            &self.a_col_idx,
408            x,
409            b,
410            self.config.pre_sweeps,
411        )?;
412
413        // 2. Compute fine-grid residual
414        let r_fine = compute_residual(&self.a_values, &self.a_row_ptr, &self.a_col_idx, x, b);
415
416        // 3. Restrict residual to coarse grid: r_c = R * r_fine
417        let r_coarse = csr_matvec(&self.r_values, &self.r_row_ptr, &self.r_col_idx, &r_fine);
418
419        // 4. Solve coarse system: A_c * e_c = r_c
420        let e_coarse = direct_solve(
421            &self.ac_values,
422            &self.ac_row_ptr,
423            &self.ac_col_idx,
424            &r_coarse,
425            self.n_c,
426        )?;
427
428        // 5. Prolongate correction: e_fine = P * e_c
429        let e_fine = csr_matvec(&self.p_values, &self.p_row_ptr, &self.p_col_idx, &e_coarse);
430
431        // 6. Apply correction
432        for i in 0..self.n {
433            x[i] += e_fine[i];
434        }
435
436        // 7. Post-smooth
437        self.smoother.smooth(
438            &self.a_values,
439            &self.a_row_ptr,
440            &self.a_col_idx,
441            x,
442            b,
443            self.config.post_sweeps,
444        )?;
445
446        Ok(())
447    }
448
449    /// Perform one V-cycle using classical Jacobi (fallback).
450    fn v_cycle_jacobi(&self, x: &mut [f64], b: &[f64]) -> SparseResult<()> {
451        let omega = self.config.omega;
452        let pre = self.config.pre_sweeps;
453        let post = self.config.post_sweeps;
454
455        // Pre-smooth with Jacobi
456        jacobi_smooth(
457            &self.a_values,
458            &self.a_row_ptr,
459            &self.a_col_idx,
460            x,
461            b,
462            &self.diag_inv,
463            omega,
464            pre,
465        );
466
467        // Restrict
468        let r_fine = compute_residual(&self.a_values, &self.a_row_ptr, &self.a_col_idx, x, b);
469        let r_coarse = csr_matvec(&self.r_values, &self.r_row_ptr, &self.r_col_idx, &r_fine);
470
471        // Coarse solve
472        let e_coarse = direct_solve(
473            &self.ac_values,
474            &self.ac_row_ptr,
475            &self.ac_col_idx,
476            &r_coarse,
477            self.n_c,
478        )?;
479
480        // Prolongate
481        let e_fine = csr_matvec(&self.p_values, &self.p_row_ptr, &self.p_col_idx, &e_coarse);
482        for i in 0..self.n {
483            x[i] += e_fine[i];
484        }
485
486        // Post-smooth with Jacobi
487        jacobi_smooth(
488            &self.a_values,
489            &self.a_row_ptr,
490            &self.a_col_idx,
491            x,
492            b,
493            &self.diag_inv,
494            omega,
495            post,
496        );
497
498        Ok(())
499    }
500
501    /// Solve A x = b using iterative V-cycles.
502    ///
503    /// Returns the solution vector. Falls back to Jacobi smoothing if the
504    /// learned smoother causes divergence.
505    pub fn solve(&self, b: &[f64]) -> SparseResult<Vec<f64>> {
506        if b.len() != self.n {
507            return Err(SparseError::DimensionMismatch {
508                expected: self.n,
509                found: b.len(),
510            });
511        }
512
513        let mut x = vec![0.0; self.n];
514        let tol = self.config.convergence_tol;
515        let max_iter = self.config.max_training_steps;
516
517        let r0 = compute_residual(&self.a_values, &self.a_row_ptr, &self.a_col_idx, &x, b);
518        let norm0 = vec_norm(&r0);
519        if norm0 < tol {
520            return Ok(x);
521        }
522
523        let mut use_learned = true;
524        let mut prev_norm = norm0;
525
526        for _iter in 0..max_iter {
527            if use_learned {
528                let x_backup: Vec<f64> = x.clone();
529                let result = self.v_cycle(&mut x, b);
530
531                let r = compute_residual(&self.a_values, &self.a_row_ptr, &self.a_col_idx, &x, b);
532                let norm_r = vec_norm(&r);
533
534                // Check for divergence: if residual increased, fall back to Jacobi
535                if result.is_err() || norm_r > prev_norm * 2.0 {
536                    x.copy_from_slice(&x_backup);
537                    use_learned = false;
538                    self.v_cycle_jacobi(&mut x, b)?;
539                    let r2 =
540                        compute_residual(&self.a_values, &self.a_row_ptr, &self.a_col_idx, &x, b);
541                    prev_norm = vec_norm(&r2);
542                } else {
543                    prev_norm = norm_r;
544                }
545            } else {
546                self.v_cycle_jacobi(&mut x, b)?;
547                let r = compute_residual(&self.a_values, &self.a_row_ptr, &self.a_col_idx, &x, b);
548                prev_norm = vec_norm(&r);
549            }
550
551            if prev_norm < tol * norm0 {
552                break;
553            }
554        }
555
556        Ok(x)
557    }
558
559    /// Compare learned smoother convergence with classical Jacobi.
560    ///
561    /// Runs both for `n_iterations` V-cycles and returns metrics.
562    pub fn compare_with_jacobi(
563        &self,
564        b: &[f64],
565        n_iterations: usize,
566    ) -> SparseResult<SmootherMetrics> {
567        if b.len() != self.n {
568            return Err(SparseError::DimensionMismatch {
569                expected: self.n,
570                found: b.len(),
571            });
572        }
573
574        // --- Run learned smoother ---
575        let mut x_learned = vec![0.0; self.n];
576        let r0 = compute_residual(
577            &self.a_values,
578            &self.a_row_ptr,
579            &self.a_col_idx,
580            &x_learned,
581            b,
582        );
583        let norm0 = vec_norm(&r0);
584
585        let mut learned_history = Vec::with_capacity(n_iterations);
586        for _ in 0..n_iterations {
587            let _ = self.v_cycle(&mut x_learned, b);
588            let r = compute_residual(
589                &self.a_values,
590                &self.a_row_ptr,
591                &self.a_col_idx,
592                &x_learned,
593                b,
594            );
595            learned_history.push(vec_norm(&r));
596        }
597
598        // --- Run Jacobi ---
599        let mut x_jacobi = vec![0.0; self.n];
600        let mut jacobi_final_norm = norm0;
601        for _ in 0..n_iterations {
602            self.v_cycle_jacobi(&mut x_jacobi, b)?;
603            let r = compute_residual(
604                &self.a_values,
605                &self.a_row_ptr,
606                &self.a_col_idx,
607                &x_jacobi,
608                b,
609            );
610            jacobi_final_norm = vec_norm(&r);
611        }
612
613        let learned_final = learned_history.last().copied().unwrap_or(norm0);
614
615        // Convergence factor: geometric mean of reduction per step
616        let convergence_factor = if n_iterations > 0 && norm0 > f64::EPSILON {
617            (learned_final / norm0).powf(1.0 / n_iterations as f64)
618        } else {
619            1.0
620        };
621
622        let residual_reduction = if norm0 > f64::EPSILON {
623            learned_final / norm0
624        } else {
625            0.0
626        };
627
628        let spectral_radius_reduction = if jacobi_final_norm > f64::EPSILON {
629            learned_final / jacobi_final_norm
630        } else {
631            1.0
632        };
633
634        Ok(SmootherMetrics {
635            spectral_radius_reduction,
636            convergence_factor,
637            residual_reduction,
638            training_loss_history: learned_history,
639        })
640    }
641
642    /// Problem dimension.
643    pub fn dim(&self) -> usize {
644        self.n
645    }
646
647    /// Coarse grid dimension.
648    pub fn coarse_dim(&self) -> usize {
649        self.n_c
650    }
651}
652
653// ---------------------------------------------------------------------------
654// Tests
655// ---------------------------------------------------------------------------
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660    use crate::learned_smoother::linear_smoother::LinearSmoother;
661
662    /// Build 1D Poisson matrix of size n: tridiag(-1, 2, -1).
663    fn poisson_1d(n: usize) -> (Vec<f64>, Vec<usize>, Vec<usize>) {
664        let mut values = Vec::new();
665        let mut col_idx = Vec::new();
666        let mut row_ptr = vec![0usize];
667
668        for i in 0..n {
669            if i > 0 {
670                values.push(-1.0);
671                col_idx.push(i - 1);
672            }
673            values.push(2.0);
674            col_idx.push(i);
675            if i + 1 < n {
676                values.push(-1.0);
677                col_idx.push(i + 1);
678            }
679            row_ptr.push(values.len());
680        }
681        (values, row_ptr, col_idx)
682    }
683
684    #[test]
685    fn test_coarsening_dimensions() {
686        let (_, _, _, _, _, _, n_c) = build_coarsening(7);
687        assert_eq!(n_c, 4);
688        let (_, _, _, _, _, _, n_c2) = build_coarsening(8);
689        assert_eq!(n_c2, 4);
690    }
691
692    #[test]
693    fn test_hybrid_solver_solves_poisson() {
694        let n = 7;
695        let (vals, rp, ci) = poisson_1d(n);
696
697        let smoother = LinearSmoother::from_csr(&vals, &rp, &ci, 2.0 / 3.0);
698        let config = LearnedSmootherConfig {
699            max_training_steps: 200,
700            convergence_tol: 1e-8,
701            ..LearnedSmootherConfig::default()
702        };
703
704        let solver = HybridMultigridSolver::new(
705            vals.clone(),
706            rp.clone(),
707            ci.clone(),
708            Box::new(smoother),
709            config,
710        )
711        .expect("solver creation");
712
713        // RHS: b = [1, 0, 0, ..., 0, 1]
714        let mut b = vec![0.0; n];
715        b[0] = 1.0;
716        b[n - 1] = 1.0;
717
718        let x = solver.solve(&b).expect("solve");
719
720        // Verify: ‖b - Ax‖ / ‖b‖ should be small
721        let r = compute_residual(&vals, &rp, &ci, &x, &b);
722        let rel_res = vec_norm(&r) / vec_norm(&b);
723        assert!(
724            rel_res < 1e-4,
725            "Relative residual should be small, got {rel_res}"
726        );
727    }
728
729    #[test]
730    fn test_compare_with_jacobi() {
731        let n = 7;
732        let (vals, rp, ci) = poisson_1d(n);
733
734        let smoother = LinearSmoother::from_csr(&vals, &rp, &ci, 2.0 / 3.0);
735        let config = LearnedSmootherConfig::default();
736
737        let solver = HybridMultigridSolver::new(
738            vals.clone(),
739            rp.clone(),
740            ci.clone(),
741            Box::new(smoother),
742            config,
743        )
744        .expect("solver creation");
745
746        let mut b = vec![1.0; n];
747        b[0] = 2.0;
748
749        let metrics = solver.compare_with_jacobi(&b, 10).expect("compare");
750
751        assert!(
752            metrics.convergence_factor < 1.0,
753            "Convergence factor should be < 1, got {}",
754            metrics.convergence_factor
755        );
756        assert_eq!(metrics.training_loss_history.len(), 10);
757    }
758
759    #[test]
760    fn test_solver_dimension_mismatch() {
761        let n = 5;
762        let (vals, rp, ci) = poisson_1d(n);
763        let smoother = LinearSmoother::from_csr(&vals, &rp, &ci, 2.0 / 3.0);
764        let config = LearnedSmootherConfig::default();
765
766        let solver = HybridMultigridSolver::new(vals, rp, ci, Box::new(smoother), config)
767            .expect("solver creation");
768
769        let b = vec![1.0; 3]; // wrong size
770        assert!(solver.solve(&b).is_err());
771    }
772
773    #[test]
774    fn test_solver_coarse_dim() {
775        let n = 9;
776        let (vals, rp, ci) = poisson_1d(n);
777        let smoother = LinearSmoother::from_csr(&vals, &rp, &ci, 2.0 / 3.0);
778        let config = LearnedSmootherConfig::default();
779
780        let solver = HybridMultigridSolver::new(vals, rp, ci, Box::new(smoother), config)
781            .expect("solver creation");
782
783        assert_eq!(solver.dim(), 9);
784        assert_eq!(solver.coarse_dim(), 5);
785    }
786
787    #[test]
788    fn test_direct_solve_small_system() {
789        // 2x2 system: [2 1; 1 3] x = [5; 7] => x = [8/5, 9/5] = [1.6, 1.8]
790        let vals = vec![2.0, 1.0, 1.0, 3.0];
791        let rp = vec![0, 2, 4];
792        let ci = vec![0, 1, 0, 1];
793        let b = vec![5.0, 7.0];
794
795        let x = direct_solve(&vals, &rp, &ci, &b, 2).expect("direct solve");
796        assert!((x[0] - 1.6).abs() < 1e-10);
797        assert!((x[1] - 1.8).abs() < 1e-10);
798    }
799}