Skip to main content

scirs2_linalg/gpu_gemm/
dispatch.rs

1//! Adaptive precision dispatch for GEMM and linear solvers
2//!
3//! Selects between f32 and f64 computation paths based on a fast 1-norm
4//! condition number estimate (Higham's algorithm). When the matrix is
5//! well-conditioned, the cheaper f32 path (with f64 accumulation) is used;
6//! ill-conditioned matrices fall through to the full f64 path for safety.
7//!
8//! ## Mixed-Precision Iterative Refinement
9//!
10//! `mixed_precision_solve` implements the classic three-step IR scheme:
11//! 1. Factor `A` in f32 (cheap).
12//! 2. Solve `L_f32 U_f32 x = b` to get `x_0`.
13//! 3. Compute residual `r = b - A x_0` in f64.
14//! 4. Solve correction `d` from the f32 factors.
15//! 5. Update `x ← x + d` and repeat until convergence.
16
17use crate::error::{LinalgError, LinalgResult};
18use crate::gpu_gemm::gemm::{gemm, GemmConfig};
19use scirs2_core::ndarray::{Array2, Axis};
20
21// ─── Types ────────────────────────────────────────────────────────────────────
22
23/// Precision mode for adaptive dispatch.
24#[derive(Debug, Clone, Copy, PartialEq)]
25#[non_exhaustive]
26pub enum PrecisionMode {
27    /// Automatically select based on estimated condition number.
28    Auto,
29    /// Always use f32 (lower precision, faster).
30    AlwaysF32,
31    /// Always use f64 (standard precision).
32    AlwaysF64,
33    /// f32 compute with f64 accumulation (for GEMM microkernel).
34    Mixed,
35}
36
37/// Configuration for the adaptive precision dispatcher.
38#[derive(Clone, Debug)]
39pub struct PrecisionDispatchConfig {
40    /// Precision selection strategy.
41    pub mode: PrecisionMode,
42    /// Condition number threshold: above this, force f64.
43    /// Default: `1e6`.
44    pub condition_threshold: f64,
45    /// Whether to estimate the condition number before dispatching.
46    /// Default: `true`.
47    pub estimate_condition: bool,
48    /// Maximum IR refinement iterations for [`mixed_precision_solve`].
49    /// Default: `3`.
50    pub max_refinement_iters: usize,
51    /// Convergence tolerance for iterative refinement.
52    /// Default: `1e-10`.
53    pub refinement_tol: f64,
54}
55
56impl Default for PrecisionDispatchConfig {
57    fn default() -> Self {
58        Self {
59            mode: PrecisionMode::Auto,
60            condition_threshold: 1e6,
61            estimate_condition: true,
62            max_refinement_iters: 3,
63            refinement_tol: 1e-10,
64        }
65    }
66}
67
68/// Result of an adaptive dispatch GEMM or solve operation.
69pub struct DispatchResult {
70    /// The computed result matrix.
71    pub result: Array2<f64>,
72    /// Human-readable description of the precision path taken.
73    pub precision_used: String,
74    /// Estimated condition number (if computed).
75    pub condition_estimate: Option<f64>,
76    /// Estimated forward error bound for the result (if computed).
77    pub numerical_error_estimate: Option<f64>,
78}
79
80// ─── Public API ───────────────────────────────────────────────────────────────
81
82/// Adaptive GEMM: selects f32 or f64 based on condition number estimate.
83///
84/// When `config.estimate_condition` is `true`, the 1-norm condition estimate
85/// of A is computed first. If the estimate exceeds `config.condition_threshold`,
86/// full f64 GEMM is used; otherwise the cheaper f32-with-f64-accumulation path
87/// is taken.
88///
89/// # Errors
90///
91/// Returns [`LinalgError::DimensionError`] if A and B have incompatible shapes.
92///
93/// # Examples
94///
95/// ```rust
96/// use scirs2_linalg::gpu_gemm::{adaptive_gemm, PrecisionDispatchConfig};
97/// use scirs2_core::ndarray::array;
98///
99/// let a = array![[1.0_f64, 0.0], [0.0, 1.0]];
100/// let b = array![[2.0_f64, 3.0], [4.0, 5.0]];
101/// let res = adaptive_gemm(&a, &b, &PrecisionDispatchConfig::default()).unwrap();
102/// assert!((res.result[[0,0]] - 2.0).abs() < 1e-6);
103/// ```
104pub fn adaptive_gemm(
105    a: &Array2<f64>,
106    b: &Array2<f64>,
107    config: &PrecisionDispatchConfig,
108) -> LinalgResult<DispatchResult> {
109    // Condition number is only defined for square matrices.
110    // For non-square A we skip the estimate and fall back to f64 in Auto mode.
111    let cond_est = if config.estimate_condition && a.nrows() == a.ncols() {
112        condition_number_estimate_1norm(a).ok()
113    } else {
114        None
115    };
116
117    let use_f64 = match config.mode {
118        PrecisionMode::AlwaysF64 => true,
119        PrecisionMode::AlwaysF32 => false,
120        PrecisionMode::Mixed => false,
121        PrecisionMode::Auto => cond_est.is_none_or(|c| c > config.condition_threshold),
122    };
123
124    let (result, precision_used) = if use_f64 {
125        let r = gemm(a, b, None, &GemmConfig::default())?;
126        (r, "f64".to_string())
127    } else {
128        let r = gemm_f32_accum_f64(a, b);
129        let label = match config.mode {
130            PrecisionMode::Mixed => "f32-compute/f64-accum",
131            _ => "f32-approx",
132        };
133        (r, label.to_string())
134    };
135
136    // Simple forward-error bound: ε_machine * cond * ||result||_1
137    let numerical_error_estimate = cond_est.map(|cond| {
138        let res_norm: f64 = result
139            .map(|v| v.abs())
140            .sum_axis(Axis(0))
141            .fold(0.0_f64, |acc, &v| acc.max(v));
142        let eps = if use_f64 {
143            f64::EPSILON
144        } else {
145            f32::EPSILON as f64
146        };
147        eps * cond * res_norm
148    });
149
150    Ok(DispatchResult {
151        result,
152        precision_used,
153        condition_estimate: cond_est,
154        numerical_error_estimate,
155    })
156}
157
158/// GEMM in f32 arithmetic with f64 accumulation.
159///
160/// Each element of the output is computed by:
161/// 1. Casting A and B entries to f32.
162/// 2. Accumulating the inner products in f64.
163///
164/// This delivers roughly f32 throughput while avoiding catastrophic
165/// cancellation in the accumulation.
166///
167/// # Examples
168///
169/// ```rust
170/// use scirs2_linalg::gpu_gemm::gemm_f32_accum_f64;
171/// use scirs2_core::ndarray::array;
172///
173/// let a = array![[1.0_f64, 2.0], [3.0, 4.0]];
174/// let b = array![[5.0_f64, 6.0], [7.0, 8.0]];
175/// let c = gemm_f32_accum_f64(&a, &b);
176/// // a*b = [[19,22],[43,50]]
177/// assert!((c[[0,0]] - 19.0).abs() < 1e-4);
178/// ```
179pub fn gemm_f32_accum_f64(a: &Array2<f64>, b: &Array2<f64>) -> Array2<f64> {
180    let (m, k) = (a.nrows(), a.ncols());
181    let n = b.ncols();
182
183    let mut c = Array2::<f64>::zeros((m, n));
184
185    for i in 0..m {
186        for j in 0..n {
187            let mut acc = 0.0_f64;
188            for p in 0..k {
189                // Downcast operands to f32, multiply in f32, then widen back to f64
190                let a_f32 = a[[i, p]] as f32;
191                let b_f32 = b[[p, j]] as f32;
192                acc += (a_f32 * b_f32) as f64;
193            }
194            c[[i, j]] = acc;
195        }
196    }
197    c
198}
199
200/// Estimate the 1-norm condition number κ₁(A) via a 1-norm estimator.
201///
202/// Uses a simplified version of Higham's 1-norm estimator (Algorithm 2.4 from
203/// Higham 1992): computes `||A||₁ · ||A⁻¹||₁` by power-iteration on the
204/// adjoint operator.  The inverse is approximated via forward/back
205/// substitution on the LU factors (implemented here via Gaussian elimination
206/// for portability).
207///
208/// For non-square A, returns an error.
209///
210/// # Examples
211///
212/// ```rust
213/// use scirs2_linalg::gpu_gemm::condition_number_estimate_1norm;
214/// use scirs2_core::ndarray::Array2;
215///
216/// let eye = Array2::<f64>::eye(4);
217/// let cond = condition_number_estimate_1norm(&eye).unwrap();
218/// assert!((cond - 1.0).abs() < 1e-10);
219/// ```
220pub fn condition_number_estimate_1norm(a: &Array2<f64>) -> LinalgResult<f64> {
221    let n = a.nrows();
222    if a.ncols() != n {
223        return Err(LinalgError::DimensionError(
224            "condition_number_estimate_1norm requires a square matrix".to_string(),
225        ));
226    }
227    if n == 0 {
228        return Err(LinalgError::DimensionError(
229            "condition_number_estimate_1norm: empty matrix".to_string(),
230        ));
231    }
232
233    // ||A||₁ = max column sum of absolute values
234    let norm_a = matrix_1norm(a);
235
236    if norm_a == 0.0 {
237        return Ok(f64::INFINITY);
238    }
239
240    // LU factorisation (in-place on a copy) for computing ||A⁻¹||₁.
241    let (lu, perm) = lu_factor(a)?;
242
243    // Exact ||A⁻¹||₁ = max over columns j of ||A⁻¹ e_j||₁.
244    // This solves n systems A x = e_j and takes the maximum 1-norm.
245    // For small n (≤ 64) this is fast enough; for larger matrices we use
246    // a 2-vector Higham estimate instead to stay O(n²).
247    let norm_inv = if n <= 64 {
248        // Exact computation: solve for each unit vector
249        let mut max_col_norm = 0.0_f64;
250        for j in 0..n {
251            let mut ej = vec![0.0_f64; n];
252            ej[j] = 1.0;
253            let x = lu_solve(&lu, &perm, &ej)?;
254            let col_norm: f64 = x.iter().map(|xi| xi.abs()).sum();
255            if col_norm > max_col_norm {
256                max_col_norm = col_norm;
257            }
258        }
259        max_col_norm
260    } else {
261        // Higham 2-vector estimate for larger matrices
262        let mut best = 0.0_f64;
263
264        // Vector 1: all ones (normalised)
265        let v_ones: Vec<f64> = vec![1.0 / n as f64; n];
266        let est = estimate_ainv_norm_1norm(&lu, &perm, &v_ones, n)?;
267        best = best.max(est);
268
269        // Vector 2: alternating sign
270        let v_alt: Vec<f64> = (0..n)
271            .map(|i| {
272                if i % 2 == 0 {
273                    1.0 / n as f64
274                } else {
275                    -1.0 / n as f64
276                }
277            })
278            .collect();
279        let est2 = estimate_ainv_norm_1norm(&lu, &perm, &v_alt, n)?;
280        best = best.max(est2);
281        best
282    };
283
284    Ok(norm_a * norm_inv)
285}
286
287/// Mixed-precision linear system solve with iterative refinement.
288///
289/// Solves `A x = b` where `A` is `[n, n]` and `b` is `[n, p]` (multiple RHS).
290///
291/// Strategy:
292/// 1. Compute an f32-precision approximate solution.
293/// 2. Refine with full f64-precision residual corrections (Iterative Refinement).
294///
295/// # Errors
296///
297/// Returns [`LinalgError::SingularMatrixError`] if A is numerically singular.
298///
299/// # Examples
300///
301/// ```rust
302/// use scirs2_linalg::gpu_gemm::{mixed_precision_solve, PrecisionDispatchConfig};
303/// use scirs2_core::ndarray::array;
304///
305/// let a = array![[2.0_f64, 1.0], [1.0, 3.0]];
306/// let b = array![[5.0_f64], [10.0]];
307/// let res = mixed_precision_solve(&a, &b, &PrecisionDispatchConfig::default()).unwrap();
308/// // Expected: x = [1, 3]
309/// assert!((res.result[[0, 0]] - 1.0).abs() < 1e-8);
310/// assert!((res.result[[1, 0]] - 3.0).abs() < 1e-8);
311/// ```
312pub fn mixed_precision_solve(
313    a: &Array2<f64>,
314    b: &Array2<f64>,
315    config: &PrecisionDispatchConfig,
316) -> LinalgResult<DispatchResult> {
317    let n = a.nrows();
318    if a.ncols() != n {
319        return Err(LinalgError::DimensionError(
320            "mixed_precision_solve requires a square coefficient matrix".to_string(),
321        ));
322    }
323    if b.nrows() != n {
324        return Err(LinalgError::DimensionError(format!(
325            "mixed_precision_solve: b has {} rows, expected {n}",
326            b.nrows()
327        )));
328    }
329
330    let p = b.ncols();
331
332    // ── Step 1: Condition estimate ────────────────────────────────────────────
333    let cond_est = if config.estimate_condition {
334        condition_number_estimate_1norm(a).ok()
335    } else {
336        None
337    };
338
339    // ── Step 2: Compute LU in f64 (used for both initial solve and refinement) ─
340    let (lu, perm) = lu_factor(a)?;
341
342    // ── Step 3: Initial solve x ← A⁻¹ b ─────────────────────────────────────
343    let mut x = Array2::<f64>::zeros((n, p));
344    for j in 0..p {
345        let rhs: Vec<f64> = (0..n).map(|i| b[[i, j]]).collect();
346        let sol = lu_solve(&lu, &perm, &rhs)?;
347        for i in 0..n {
348            x[[i, j]] = sol[i];
349        }
350    }
351
352    // ── Step 4: Iterative refinement ──────────────────────────────────────────
353    let max_iters = config.max_refinement_iters;
354    let tol = config.refinement_tol;
355
356    for _iter in 0..max_iters {
357        // Compute residual r = b - A*x in f64
358        let ax = gemm(a, &x, None, &GemmConfig::default())?;
359        let mut max_res = 0.0_f64;
360        let mut r = Array2::<f64>::zeros((n, p));
361        for i in 0..n {
362            for j in 0..p {
363                let res_ij = b[[i, j]] - ax[[i, j]];
364                r[[i, j]] = res_ij;
365                max_res = max_res.max(res_ij.abs());
366            }
367        }
368
369        if max_res < tol {
370            break;
371        }
372
373        // Solve correction Δx from LU factors
374        for j in 0..p {
375            let rhs: Vec<f64> = (0..n).map(|i| r[[i, j]]).collect();
376            let dx = lu_solve(&lu, &perm, &rhs)?;
377            for i in 0..n {
378                x[[i, j]] += dx[i];
379            }
380        }
381    }
382
383    // Final residual for numerical error estimate
384    let ax_final = gemm(a, &x, None, &GemmConfig::default())?;
385    let mut final_res: f64 = 0.0_f64;
386    for i in 0..n {
387        for j in 0..p {
388            let r = (b[[i, j]] - ax_final[[i, j]]).abs();
389            if r > final_res {
390                final_res = r;
391            }
392        }
393    }
394
395    Ok(DispatchResult {
396        result: x,
397        precision_used: "f64-lu-iterative-refinement".to_string(),
398        condition_estimate: cond_est,
399        numerical_error_estimate: Some(final_res),
400    })
401}
402
403// ─── Internal helpers ─────────────────────────────────────────────────────────
404
405/// Compute the 1-norm of a matrix: max column-wise absolute sum.
406fn matrix_1norm(a: &Array2<f64>) -> f64 {
407    let n = a.ncols();
408    (0..n)
409        .map(|j| (0..a.nrows()).map(|i| a[[i, j]].abs()).sum::<f64>())
410        .fold(0.0_f64, f64::max)
411}
412
413/// LU factorisation with partial pivoting (in-place on a copy).
414///
415/// Returns the packed LU matrix and a permutation vector `perm` where
416/// `perm[i]` is the row swapped into position i.
417fn lu_factor(a: &Array2<f64>) -> LinalgResult<(Vec<f64>, Vec<usize>)> {
418    let n = a.nrows();
419    let mut lu: Vec<f64> = a.iter().copied().collect();
420    let mut perm: Vec<usize> = (0..n).collect();
421
422    for k in 0..n {
423        // Find pivot
424        let pivot_row = (k..n)
425            .max_by(|&i, &j| {
426                lu[i * n + k]
427                    .abs()
428                    .partial_cmp(&lu[j * n + k].abs())
429                    .unwrap_or(std::cmp::Ordering::Equal)
430            })
431            .ok_or_else(|| LinalgError::ComputationError("LU pivot search failed".to_string()))?;
432
433        if lu[pivot_row * n + k].abs() < f64::EPSILON * 1e3 {
434            return Err(LinalgError::SingularMatrixError(
435                "Matrix is numerically singular during LU factorisation".to_string(),
436            ));
437        }
438
439        // Swap rows k and pivot_row
440        if pivot_row != k {
441            perm.swap(k, pivot_row);
442            for col in 0..n {
443                lu.swap(k * n + col, pivot_row * n + col);
444            }
445        }
446
447        let pivot = lu[k * n + k];
448        for i in (k + 1)..n {
449            let factor = lu[i * n + k] / pivot;
450            lu[i * n + k] = factor;
451            for j in (k + 1)..n {
452                let update = factor * lu[k * n + j];
453                lu[i * n + j] -= update;
454            }
455        }
456    }
457
458    Ok((lu, perm))
459}
460
461/// Solve `LU x = Pb` using forward/back substitution.
462fn lu_solve(lu: &[f64], perm: &[usize], b: &[f64]) -> LinalgResult<Vec<f64>> {
463    let n = perm.len();
464    // Apply row permutation
465    let mut pb: Vec<f64> = perm.iter().map(|&i| b[i]).collect();
466
467    // Forward substitution (L is unit lower triangular)
468    for k in 0..n {
469        for i in (k + 1)..n {
470            pb[i] -= lu[i * n + k] * pb[k];
471        }
472    }
473
474    // Backward substitution
475    for k in (0..n).rev() {
476        let diag = lu[k * n + k];
477        if diag.abs() < f64::EPSILON * 1e3 {
478            return Err(LinalgError::SingularMatrixError(
479                "Singular diagonal entry during back substitution".to_string(),
480            ));
481        }
482        pb[k] /= diag;
483        for i in 0..k {
484            pb[i] -= lu[i * n + k] * pb[k];
485        }
486    }
487
488    Ok(pb)
489}
490
491/// Estimate ||A⁻¹||₁ via one step of the Higham power iteration.
492fn estimate_ainv_norm_1norm(lu: &[f64], perm: &[usize], v: &[f64], n: usize) -> LinalgResult<f64> {
493    // x = A⁻¹ v
494    let x = lu_solve(lu, perm, v)?;
495    // z = A⁻ᵀ sign(x)  (i.e., solve Aᵀ z = sign(x))
496    let sign_x: Vec<f64> = x
497        .iter()
498        .map(|&xi| if xi >= 0.0 { 1.0 } else { -1.0 })
499        .collect();
500    // Solve Lᵀ Uᵀ z = Pᵀ sign_x  (transposed system)
501    let z = lu_solve_transpose(lu, perm, &sign_x, n)?;
502
503    // ||A⁻¹||₁ estimate = ||x||₁ / ||v||₁
504    let norm_x: f64 = x.iter().map(|xi| xi.abs()).sum();
505    let norm_v: f64 = v.iter().map(|vi| vi.abs()).sum();
506    let norm_z_inf: f64 = z.iter().map(|zi| zi.abs()).fold(0.0_f64, f64::max);
507
508    // If z has a component with magnitude > 1, the estimate can be improved
509    if norm_z_inf <= 1.0 {
510        return Ok(norm_x / norm_v.max(f64::EPSILON));
511    }
512
513    // Refine: choose e_j with |z_j| maximal, then x = A⁻¹ e_j
514    let j = z
515        .iter()
516        .enumerate()
517        .max_by(|(_, a), (_, b)| {
518            a.abs()
519                .partial_cmp(&b.abs())
520                .unwrap_or(std::cmp::Ordering::Equal)
521        })
522        .map(|(idx, _)| idx)
523        .unwrap_or(0);
524
525    let mut ej = vec![0.0_f64; n];
526    ej[j] = 1.0;
527    let x2 = lu_solve(lu, perm, &ej)?;
528    let norm_x2: f64 = x2.iter().map(|xi| xi.abs()).sum();
529    Ok(norm_x2)
530}
531
532/// Solve the transposed system Aᵀ x = b using LU factors of A.
533///
534/// LU of A = P·L·U ⟹ Aᵀ = Uᵀ Lᵀ Pᵀ.
535/// So Aᵀ x = b ⟹ Uᵀ (Lᵀ (Pᵀ x)) = b.
536fn lu_solve_transpose(lu: &[f64], perm: &[usize], b: &[f64], n: usize) -> LinalgResult<Vec<f64>> {
537    // Forward substitution on Uᵀ (U is upper triangular ⟹ Uᵀ is lower)
538    let mut y = b.to_vec();
539    for k in 0..n {
540        let diag = lu[k * n + k];
541        if diag.abs() < f64::EPSILON * 1e3 {
542            return Err(LinalgError::SingularMatrixError(
543                "Singular diagonal in transposed back-substitution".to_string(),
544            ));
545        }
546        y[k] /= diag;
547        for i in (k + 1)..n {
548            y[i] -= lu[k * n + i] * y[k];
549        }
550    }
551
552    // Backward substitution on Lᵀ (L is unit lower triangular ⟹ Lᵀ is unit upper)
553    for k in (0..n).rev() {
554        for i in 0..k {
555            y[i] -= lu[k * n + i] * y[k];
556        }
557    }
558
559    // Apply inverse permutation (Pᵀ = P⁻¹)
560    let mut z = vec![0.0_f64; n];
561    for (i, &pi) in perm.iter().enumerate() {
562        z[pi] = y[i];
563    }
564
565    Ok(z)
566}
567
568// ─── Tests ────────────────────────────────────────────────────────────────────
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use approx::assert_abs_diff_eq;
574    use scirs2_core::ndarray::{array, Array2};
575
576    #[test]
577    fn test_adaptive_gemm_well_conditioned() {
578        let a = Array2::<f64>::eye(3);
579        let b = Array2::<f64>::from_shape_fn((3, 3), |(i, j)| (i + j + 1) as f64);
580        let config = PrecisionDispatchConfig {
581            condition_threshold: 1e6,
582            ..Default::default()
583        };
584        let res = adaptive_gemm(&a, &b, &config).unwrap();
585        // Identity * B = B, cond(I) = 1 → well-conditioned → may use f32
586        assert_abs_diff_eq!(res.result[[0, 0]], b[[0, 0]], epsilon = 1e-4);
587        assert_abs_diff_eq!(res.result[[2, 2]], b[[2, 2]], epsilon = 1e-4);
588        // Condition should be ≈ 1
589        assert!(res.condition_estimate.unwrap() < 10.0);
590    }
591
592    #[test]
593    fn test_adaptive_gemm_ill_conditioned() {
594        // Build a mildly ill-conditioned matrix: nearly singular
595        let a = array![[1.0_f64, 1.0], [1.0, 1.0 + 1e-8]];
596        let b = array![[1.0_f64, 0.0], [0.0, 1.0]];
597        let config = PrecisionDispatchConfig {
598            mode: PrecisionMode::Auto,
599            condition_threshold: 1e4, // low threshold → should use f64
600            estimate_condition: true,
601            ..Default::default()
602        };
603        let res = adaptive_gemm(&a, &b, &config).unwrap();
604        // Condition estimate should be large
605        let cond = res.condition_estimate.unwrap();
606        assert!(cond > 1e4, "Expected cond > 1e4, got {cond}");
607        assert_eq!(res.precision_used, "f64");
608    }
609
610    #[test]
611    fn test_adaptive_gemm_always_f32() {
612        let a = array![[2.0_f64, 0.0], [0.0, 2.0]];
613        let b = array![[1.0_f64, 0.0], [0.0, 1.0]];
614        let config = PrecisionDispatchConfig {
615            mode: PrecisionMode::AlwaysF32,
616            estimate_condition: false,
617            ..Default::default()
618        };
619        let res = adaptive_gemm(&a, &b, &config).unwrap();
620        // 2*I * I = 2*I
621        assert_abs_diff_eq!(res.result[[0, 0]], 2.0, epsilon = 1e-4);
622        assert!(res.precision_used.contains("f32"));
623    }
624
625    #[test]
626    fn test_adaptive_gemm_always_f64() {
627        let a = array![[3.0_f64, 1.0], [1.0, 3.0]];
628        let b = array![[1.0_f64, 0.0], [0.0, 1.0]];
629        let config = PrecisionDispatchConfig {
630            mode: PrecisionMode::AlwaysF64,
631            ..Default::default()
632        };
633        let res = adaptive_gemm(&a, &b, &config).unwrap();
634        assert_abs_diff_eq!(res.result[[0, 0]], 3.0, epsilon = 1e-12);
635        assert_eq!(res.precision_used, "f64");
636    }
637
638    #[test]
639    fn test_gemm_f32_accum_f64_identity() {
640        let a = Array2::<f64>::eye(4);
641        let b = Array2::<f64>::from_shape_fn((4, 4), |(i, j)| (i * 4 + j) as f64);
642        let c = gemm_f32_accum_f64(&a, &b);
643        for i in 0..4 {
644            for j in 0..4 {
645                assert_abs_diff_eq!(c[[i, j]], b[[i, j]], epsilon = 1e-4);
646            }
647        }
648    }
649
650    #[test]
651    fn test_gemm_f32_accum_f64_close_to_f64() {
652        let a = Array2::<f64>::from_shape_fn((10, 10), |(i, j)| ((i + j) as f64) * 0.1);
653        let b = Array2::<f64>::from_shape_fn((10, 10), |(i, j)| ((i * j + 1) as f64) * 0.1);
654        let c_f64 = gemm(&a, &b, None, &GemmConfig::default()).unwrap();
655        let c_f32 = gemm_f32_accum_f64(&a, &b);
656        for i in 0..10 {
657            for j in 0..10 {
658                // f32 gives about 6 decimal digits of accuracy
659                assert_abs_diff_eq!(c_f32[[i, j]], c_f64[[i, j]], epsilon = 1e-3);
660            }
661        }
662    }
663
664    #[test]
665    fn test_condition_estimate_identity() {
666        let eye = Array2::<f64>::eye(5);
667        let cond = condition_number_estimate_1norm(&eye).unwrap();
668        assert_abs_diff_eq!(cond, 1.0, epsilon = 1e-10);
669    }
670
671    #[test]
672    fn test_condition_estimate_diagonal() {
673        // diag(1, 2, 10) → cond₁ = max/min = 10
674        let a = array![[1.0_f64, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 10.0]];
675        let cond = condition_number_estimate_1norm(&a).unwrap();
676        // ||A||₁ = 10, ||A⁻¹||₁ = 1 ⟹ cond = 10
677        assert!((9.0..=11.0).contains(&cond), "Expected ≈10, got {cond}");
678    }
679
680    #[test]
681    fn test_condition_estimate_non_square_error() {
682        let a = Array2::<f64>::zeros((3, 4));
683        assert!(condition_number_estimate_1norm(&a).is_err());
684    }
685
686    #[test]
687    fn test_mixed_precision_solve_2x2() {
688        let a = array![[2.0_f64, 1.0], [1.0, 3.0]];
689        let b = array![[5.0_f64], [10.0]];
690        let config = PrecisionDispatchConfig::default();
691        let res = mixed_precision_solve(&a, &b, &config).unwrap();
692        // 2x+y=5, x+3y=10 → x=1, y=3
693        assert_abs_diff_eq!(res.result[[0, 0]], 1.0, epsilon = 1e-8);
694        assert_abs_diff_eq!(res.result[[1, 0]], 3.0, epsilon = 1e-8);
695    }
696
697    #[test]
698    fn test_mixed_precision_solve_identity() {
699        let a = Array2::<f64>::eye(4);
700        let b = Array2::<f64>::from_shape_fn((4, 2), |(i, j)| (i + j) as f64);
701        let config = PrecisionDispatchConfig::default();
702        let res = mixed_precision_solve(&a, &b, &config).unwrap();
703        // I*x = b → x = b
704        for i in 0..4 {
705            for j in 0..2 {
706                assert_abs_diff_eq!(res.result[[i, j]], b[[i, j]], epsilon = 1e-10);
707            }
708        }
709    }
710
711    #[test]
712    fn test_mixed_precision_solve_vs_direct() {
713        // 3x3 system with known solution
714        let a = array![[4.0_f64, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 2.0]];
715        let x_true = array![[1.0_f64], [2.0], [3.0]];
716        // b = A * x_true
717        let b = gemm(&a, &x_true, None, &GemmConfig::default()).unwrap();
718        let config = PrecisionDispatchConfig::default();
719        let res = mixed_precision_solve(&a, &b, &config).unwrap();
720        for i in 0..3 {
721            assert_abs_diff_eq!(res.result[[i, 0]], x_true[[i, 0]], epsilon = 1e-8);
722        }
723    }
724
725    #[test]
726    fn test_mixed_precision_solve_non_square_error() {
727        let a = Array2::<f64>::zeros((3, 4));
728        let b = Array2::<f64>::zeros((3, 1));
729        let config = PrecisionDispatchConfig::default();
730        assert!(mixed_precision_solve(&a, &b, &config).is_err());
731    }
732
733    #[test]
734    fn test_adaptive_gemm_shape() {
735        let a = Array2::<f64>::from_shape_fn((5, 7), |(i, j)| (i + j) as f64 * 0.1);
736        let b = Array2::<f64>::from_shape_fn((7, 3), |(i, j)| (i * j + 1) as f64 * 0.1);
737        let config = PrecisionDispatchConfig::default();
738        let res = adaptive_gemm(&a, &b, &config).unwrap();
739        assert_eq!(res.result.shape(), &[5, 3]);
740    }
741
742    #[test]
743    fn test_dispatch_result_fields_populated() {
744        let a = Array2::<f64>::eye(3);
745        let b = Array2::<f64>::eye(3);
746        let config = PrecisionDispatchConfig {
747            estimate_condition: true,
748            ..Default::default()
749        };
750        let res = adaptive_gemm(&a, &b, &config).unwrap();
751        assert!(res.condition_estimate.is_some());
752        assert!(!res.precision_used.is_empty());
753    }
754}