Skip to main content

complex_matrix_market_demo/
complex_matrix_market_demo.rs

1//! Complex Matrix Market benchmark/demo for GMRES/FGMRES on generated and file-backed
2//! complex systems. Run it with MPI enabled when comparing replicated correctness
3//! checks against distributed scalability behavior.
4//!
5//! Correctness sweep (replicated operator/preconditioner checks, long iteration
6//! budget, and stricter modified Gram-Schmidt reorthogonalization):
7//! cargo mpirun -n 4 --example complex_matrix_market_demo --features=complex,mpi,mpi_examples -- --mode correctness --dist-policy off --maxits 2000 --restarts 150,250,324 --pcs replicated-full-ilu0,none,jacobi,block-jacobi-ilu0 --fgmres-orthog modified --fgmres-reorth always
8//!
9//! Scalability sweep (automatic distributed policy with lightweight local-block
10//! preconditioners):
11//! cargo mpirun -n 4 --example complex_matrix_market_demo --features=complex,mpi,mpi_examples -- --mode scalability --dist-policy auto --maxits 1000 --restarts 50,100 --pcs none,jacobi,block-jacobi-ilu0
12//!
13//! Optional MPI correctness sweep with overlap ILU rows. `--include-overlap-pcs`
14//! keeps the zero-overlap block-Jacobi ILU baseline, then adds
15//! `asm-ilu0-overlap1`, `ras-ilu0-overlap1`, and `ras-iluk1-overlap1`;
16//! correctness rows include `GlobalOK`/`XOK` verdicts before users compare speed.
17//! cargo mpirun -n 4 --example complex_matrix_market_demo --features=complex,mpi,mpi_examples -- --mode correctness --dist-policy auto --include-overlap-pcs --warmup-runs 0 --measured-runs 1
18
19#![cfg_attr(not(feature = "complex"), allow(dead_code))]
20
21#[cfg(not(feature = "complex"))]
22fn main() {
23    eprintln!(
24        "This example requires the `complex` feature. \\nre-run with `cargo run --features complex --example complex_matrix_market_demo`."
25    );
26}
27
28#[cfg(feature = "complex")]
29use kryst::error::KError;
30
31#[cfg(feature = "complex")]
32mod complex_demo {
33    use std::env;
34    use std::fs::File;
35    use std::io::Write;
36    use std::path::{Path, PathBuf};
37    use std::sync::Arc;
38    use std::time::Instant;
39
40    use super::KError;
41    use kryst::algebra::bridge::BridgeScratch;
42    use kryst::algebra::prelude::*;
43    use kryst::context::ksp_context::{ReorthPolicy, Workspace};
44    use kryst::matrix::DistCsrOp;
45    use kryst::matrix::dist_csr::DistributedPlanDiagnostics;
46    use kryst::matrix::sparse::CsrMatrix as SparseCsrMatrix;
47    use kryst::ops::klinop::KLinOp;
48    use kryst::ops::kpc::KPreconditioner;
49    use kryst::parallel::{Comm, UniverseComm};
50    use kryst::preconditioner::PcSide;
51    use kryst::preconditioner::Preconditioner;
52    use kryst::preconditioner::ilu_csr::{
53        IluCsr, IluCsrConfig, IluKind, ReorderingKind, ReorderingOptions,
54    };
55    use kryst::preconditioner::jacobi::Jacobi;
56    use kryst::preconditioner::overlap_ilu::{OverlapIluPc, OverlapRestriction};
57    use kryst::solver::fgmres::{
58        FgmresSolver, FgmresStagnationPolicy, FgmresVariant, OrthogMethod, ResidualCheckPolicy,
59    };
60    use kryst::solver::gmres::{GmresOrthog, GmresSolver, GmresVariant as PlainGmresVariant};
61    use kryst::solver::{LinearSolver, MonitorAction, MonitorCallback};
62    use kryst::utils::convergence::ConvergedReason;
63    use kryst::utils::matrix_market::read_matrix_market;
64
65    #[cfg(feature = "mpi")]
66    use kryst::parallel::MpiComm;
67    #[cfg(not(feature = "mpi"))]
68    use kryst::parallel::NoComm;
69
70    pub fn run() -> Result<(), KError> {
71        #[cfg(feature = "logging")]
72        let _ = env_logger::try_init();
73
74        #[cfg(feature = "mpi")]
75        let comm = UniverseComm::Mpi(Arc::new(MpiComm::new()));
76        #[cfg(not(feature = "mpi"))]
77        let comm = UniverseComm::NoComm(NoComm);
78
79        let rank = comm.rank();
80        let size = comm.size();
81        let config = BenchmarkConfig::from_env_args()?;
82        #[cfg(feature = "mpi")]
83        let is_parallel = matches!(comm, UniverseComm::Mpi(_)) && size > 1;
84        #[cfg(not(feature = "mpi"))]
85        let is_parallel = false;
86
87        if rank == 0 {
88            println!("Complex Matrix Market demo (GMRES/FGMRES)");
89            println!("Run mode: {}", config.run_mode.label());
90            println!(
91                "Parallel backend: {}",
92                if is_parallel {
93                    format!("MPI ({} ranks)", size)
94                } else {
95                    "serial".into()
96                }
97            );
98            println!(
99                "Benchmark runs: warmup={}, measured={}",
100                config.warmup_runs, config.measured_runs
101            );
102            println!(
103                "Mode profile: {}",
104                match config.run_mode {
105                    RunMode::Correctness => {
106                        "small matrices, explicit true residuals, optional replicated operator checks"
107                    }
108                    RunMode::Scalability => {
109                        "distributed operator + local-block PC only, global norms only, lightweight reporting"
110                    }
111                    RunMode::Robustness => {
112                        "robustness stress: fallback-enabled stagnation handling and restart-heavy behavior"
113                    }
114                }
115            );
116            println!(
117                "Stagnation fallback: {} (min_inner_before_fallback={})",
118                if config.run_mode == RunMode::Correctness || !config.allow_stagnation_fallback {
119                    "disabled"
120                } else {
121                    "enabled"
122                },
123                config.min_inner_before_fallback
124            );
125            println!("KSP default: {}", config.ksp.label());
126            println!("GMRES/FGMRES haptol: {:.3e}", config.fgmres_haptol);
127            println!(
128                "Complex ILU reordering: kind={:?}, symmetric={}",
129                config.ilu_reordering.kind, config.ilu_reordering.symmetric
130            );
131            if config.run_mode == RunMode::Correctness {
132                println!(
133                    "Replicated check marker: {}",
134                    if config.mark_replicated_check {
135                        "enabled (metadata-only)"
136                    } else {
137                        "disabled"
138                    }
139                );
140                println!(
141                    "Overlap PC preset: {}",
142                    if should_include_overlap_pcs(&config, size) {
143                        "enabled (MPI correctness only; GlobalOK/XOK verdict columns shown)"
144                    } else if config.include_overlap_pcs {
145                        "requested but inactive (requires MPI correctness with >1 rank)"
146                    } else {
147                        "disabled"
148                    }
149                );
150            }
151            if config.residual_history {
152                println!(
153                    "Residual history: enabled{}",
154                    config
155                        .residual_history_file
156                        .as_ref()
157                        .map(|p| format!(", output={}", p.display()))
158                        .unwrap_or_default()
159                );
160            }
161            println!("===============================================================");
162            println!();
163        }
164
165        let base = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/mtx");
166        let all_cases = [
167            MatrixCase {
168                file_name: "qc324.mtx",
169                description: "qc324 (complex, general)",
170                classification: "reference complex general case",
171            },
172            MatrixCase {
173                file_name: "dwg961a.mtx",
174                description: "dwg961a (complex, general)",
175                classification: "stress/singular-or-bad-diagonal case",
176            },
177        ];
178        let cases: Vec<MatrixCase> = match config.run_mode {
179            RunMode::Correctness => all_cases.to_vec(),
180            RunMode::Scalability => all_cases.to_vec(),
181            RunMode::Robustness => all_cases.to_vec(),
182        };
183
184        if matches!(config.run_mode, RunMode::Correctness | RunMode::Scalability) {
185            let (nx, ny) = config.poisson_grid;
186            let generated_cases = [
187                (
188                    config.poisson_shift_real,
189                    config.poisson_shift_imag,
190                    "easy",
191                    "generated complex shifted Poisson case",
192                ),
193                (
194                    0.01,
195                    0.25,
196                    "hard",
197                    "generated complex shifted Poisson hard-shift case",
198                ),
199            ];
200            for (alpha, beta, difficulty, classification) in generated_cases {
201                match build_shifted_poisson_case(
202                    &comm,
203                    nx,
204                    ny,
205                    alpha,
206                    beta,
207                    config.poisson_convection_x,
208                    config.poisson_convection_y,
209                ) {
210                    Ok(problem) => execute_problem(
211                        &format!(
212                            "generated shifted Poisson {difficulty} (-Δ + ({alpha:.3} + {beta:.3}i)I, convection=({:.3},{:.3}))",
213                            config.poisson_convection_x, config.poisson_convection_y
214                        ),
215                        classification,
216                        problem,
217                        &config,
218                        rank,
219                    ),
220                    Err(err) if rank == 0 => {
221                        println!(
222                            "❌ Failed to build generated shifted Poisson {difficulty} case: {err}\n"
223                        );
224                    }
225                    Err(_) => {}
226                }
227            }
228        }
229
230        for case in cases {
231            let mat_name = case.file_name;
232            let descr = case.description;
233            let mat_path = base.join(mat_name);
234            let available = mat_path.exists();
235            if !available {
236                if rank == 0 {
237                    println!("⚠ Missing file {mat_name} for {descr}, skipping.\n");
238                }
239                continue;
240            }
241
242            let problem = match load_problem_complex(
243                &mat_path,
244                &comm,
245                config.run_mode,
246                config.mark_replicated_check,
247            ) {
248                Ok(p) => p,
249                Err(err) => {
250                    if rank == 0 {
251                        println!("❌ Failed to load {descr}: {err}\n");
252                    }
253                    continue;
254                }
255            };
256
257            execute_problem(descr, case.classification, problem, &config, rank);
258        }
259
260        if rank == 0 {
261            println!("Example complete.");
262            println!(
263                "Final backend summary: {}.",
264                if is_parallel {
265                    "MPI run (see per-case backend labels for replicated/distributed mode)"
266                } else {
267                    "serial CSR"
268                }
269            );
270        }
271
272        Ok(())
273    }
274
275    fn execute_problem(
276        descr: &str,
277        classification: &str,
278        problem: Problem,
279        config: &BenchmarkConfig,
280        rank: usize,
281    ) {
282        let rhs_norm2_local: f64 = problem.rhs.iter().map(|v| v.abs2()).sum();
283        let rhs_norm = problem.comm.all_reduce_f64(rhs_norm2_local).sqrt();
284        let runs = RunSpec::build_default_matrix(config, &problem);
285        if rank == 0 {
286            println!(
287                "=== [{} mode] {descr} — {} ===",
288                config.run_mode.label(),
289                problem.backend_descr
290            );
291            println!("Classification: {classification}");
292            println!(
293                "Run backend: {} ({})",
294                problem.backend.run_label(),
295                problem.backend.details()
296            );
297            println!(
298                "Benchmark/export label: {}",
299                problem.backend.benchmark_export_label()
300            );
301            println!("Global DOFs: {}", problem.global_n);
302            println!("Local DOFs (rank {rank}): {}", problem.local_n);
303            println!(
304                "Local row range (rank {rank}): [{}..{})",
305                problem.global_row_start,
306                problem.global_row_start + problem.local_n
307            );
308            if problem.comm.size() > 1 && problem.local_n == problem.global_n {
309                println!("Note: replicated execution: MPI ranks are not sharing SpMV rows.");
310            }
311            if config.run_mode == RunMode::Correctness {
312                println!(
313                    "MPI scalability note: use distributed mode for scaling claims; correctness mode emphasizes solver validation."
314                );
315            }
316            println!("‖b_unscaled‖₂ = {:.3e}", rhs_norm);
317            println!(
318                "{}",
319                format_dist_policy_report(config, runs.first(), &problem)
320            );
321            println!("Residual semantics: rec/reported = solver recurrence/monitor residual.");
322            if config.run_mode == RunMode::Correctness {
323                println!(
324                    "Verdict threshold: rel residual and generated x_err checks pass when <= {:.3e} ({}× rtol).",
325                    correctness_verdict_tolerance(config),
326                    CORRECTNESS_VERDICT_RTOL_MULTIPLIER
327                );
328                println!(
329                    "                    True(dist) = ||b_unscaled - problem.op x_unscaled||₂ on owned rows, then reduced across ranks."
330                );
331                println!(
332                    "                    True(global) = ||b_global - problem.global_csr x_global||₂ after gathering owned vectors in rank order."
333                );
334                println!(
335                    "                    *(rel) columns divide by the corresponding unscaled RHS norm (stable under --row-scale)."
336                );
337            }
338            println!(
339                "FGMRES side policy: requested left/symmetric are normalized to effective right preconditioning."
340            );
341            if problem.rhs_source == RhsSource::GeneratedAOnes {
342                println!(
343                    "Generated A*ones reference: {}",
344                    if problem.solution_reference.valid_for_x_error() {
345                        "valid for x_err(rel) reporting"
346                    } else {
347                        "x_err(rel) suppressed; matrix has zero/missing rows or singularity indicators, so the solution may be nonunique"
348                    }
349                );
350                if !problem.solution_reference.valid_for_x_error() {
351                    println!("    → {}", problem.solution_reference.suppression_reason());
352                }
353            }
354            if config.run_mode == RunMode::Correctness && config.mark_replicated_check {
355                println!(
356                    "Replicated check marker: ENABLED (metadata-only marker for cross-run comparison)."
357                );
358            }
359            let include_dof_col = matches!(
360                problem.backend,
361                CsrBackend::Serial | CsrBackend::Distributed
362            );
363            if config.run_mode == RunMode::Scalability {
364                println!(
365                    "{:<7} {:<8} {:<6} {:<42} {:<36} {:<34} {:>9} {:>9} {:>7} {:>5} {:>5} {:>5} {:>17} {:>14} {:>12}",
366                    "Op",
367                    "Exec",
368                    "PCdom",
369                    "PC apply",
370                    "Method",
371                    "Effective policy",
372                    "Med(s)",
373                    "Min(s)",
374                    "Iters",
375                    "Rst",
376                    "Inn",
377                    "Pfb",
378                    "Reds(obs/wait/model)",
379                    "Rec/Reported",
380                    "DOF/s"
381                );
382                println!("{}", "-".repeat(240));
383            } else if include_dof_col {
384                println!(
385                    "{:<7} {:<8} {:<6} {:<42} {:<36} {:<34} {:<34} {:>9} {:>9} {:>9} {:>7} {:>5} {:>5} {:>5} {:>14} {:>14} {:>14} {:>14} {:>14} {:>14} {:>14} {:>8} {:>8} {:>8} {:>26} {:>12}",
386                    "Op",
387                    "Exec",
388                    "PCdom",
389                    "PC apply",
390                    "Method",
391                    "Requested policy",
392                    "Effective policy",
393                    "Setup(s)",
394                    "Med(s)",
395                    "Min(s)",
396                    "Iters",
397                    "Rst",
398                    "Inn",
399                    "Pfb",
400                    "Rec/Reported",
401                    "True(dist)",
402                    "TrueDist(rel)",
403                    "x_err(dist)",
404                    "True(global)",
405                    "TrueGlob(rel)",
406                    "x_err(global)",
407                    "DistOK",
408                    "GlobalOK",
409                    "XOK",
410                    "Reason",
411                    "DOF/s"
412                );
413                println!("{}", "-".repeat(408));
414            } else {
415                println!(
416                    "{:<7} {:<8} {:<6} {:<42} {:<36} {:<34} {:<34} {:>9} {:>9} {:>9} {:>7} {:>5} {:>5} {:>5} {:>14} {:>14} {:>14} {:>14} {:>14} {:>14} {:>14} {:>8} {:>8} {:>8} {:>26}",
417                    "Op",
418                    "Exec",
419                    "PCdom",
420                    "PC apply",
421                    "Method",
422                    "Requested policy",
423                    "Effective policy",
424                    "Setup(s)",
425                    "Med(s)",
426                    "Min(s)",
427                    "Iters",
428                    "Rst",
429                    "Inn",
430                    "Pfb",
431                    "Rec/Reported",
432                    "True(dist)",
433                    "TrueDist(rel)",
434                    "x_err(dist)",
435                    "True(global)",
436                    "TrueGlob(rel)",
437                    "x_err(global)",
438                    "DistOK",
439                    "GlobalOK",
440                    "XOK",
441                    "Reason"
442                );
443                println!("{}", "-".repeat(392));
444            }
445            println!(
446                "Legend: Op=operator storage (csr-cx=complex CSR), Exec=execution backend (ser=serial, mpi-row=MPI row partition, mpi-repl=MPI replicated operator), PCdom=PC domain (full=global/full serial matrix ILU, own0=MPI owned block, overlap=0 local ILU/ASM, n/a=no ILU domain), PC apply=how the preconditioner is applied."
447            );
448        }
449
450        for spec in runs {
451            match run_once(&problem, &spec, config) {
452                Ok(row) => {
453                    if rank == 0 {
454                        println!("{}", render_result_row(&row, config.run_mode, &problem));
455                    }
456                }
457                Err(err) => {
458                    if rank == 0 {
459                        println!(
460                            "{}",
461                            render_failure_result_row(&spec, config.run_mode, &problem)
462                        );
463                        println!("    → {err}");
464                    }
465                }
466            }
467            problem.comm.barrier();
468        }
469
470        if rank == 0 {
471            println!("{}", "=".repeat(96));
472            println!();
473        }
474        problem.comm.barrier();
475    }
476
477    #[derive(Clone, Copy, Debug)]
478    struct MatrixCase {
479        file_name: &'static str,
480        description: &'static str,
481        classification: &'static str,
482    }
483
484    struct Problem {
485        op: Arc<dyn KLinOp<Scalar = S>>,
486        dist_plan_diagnostics: DistributedPlanDiagnostics,
487        rhs: Vec<S>,
488        rhs_source: RhsSource,
489        solution_reference: SolutionReferenceDiagnostics,
490        csr_for_pc: Arc<SparseCsrMatrix<S>>,
491        global_csr: Arc<SparseCsrMatrix<S>>,
492        local_rows_nnz: usize,
493        zero_global_rows_local: usize,
494        local_n: usize,
495        global_n: usize,
496        global_row_start: usize,
497        comm: UniverseComm,
498        backend: CsrBackend,
499        backend_descr: String,
500        generated_case: bool,
501    }
502
503    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
504    enum RhsSource {
505        LoadedFromFile,
506        GeneratedAOnes,
507    }
508
509    #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
510    struct SolutionReferenceDiagnostics {
511        global_rows: usize,
512        global_cols: usize,
513        zero_row_count: usize,
514        missing_diagonal_count: usize,
515        tiny_diagonal_count: usize,
516    }
517
518    impl SolutionReferenceDiagnostics {
519        fn valid_for_x_error(self) -> bool {
520            self.global_rows == self.global_cols
521                && self.zero_row_count == 0
522                && self.missing_diagonal_count == 0
523                && self.tiny_diagonal_count == 0
524        }
525
526        fn suppression_reason(self) -> String {
527            let mut reasons = Vec::new();
528            if self.global_rows != self.global_cols {
529                reasons.push(format!(
530                    "matrix is rectangular ({}x{})",
531                    self.global_rows, self.global_cols
532                ));
533            }
534            if self.zero_row_count > 0 {
535                reasons.push(format!("{} zero/missing rows", self.zero_row_count));
536            }
537            if self.missing_diagonal_count > 0 {
538                reasons.push(format!(
539                    "{} structurally missing diagonals",
540                    self.missing_diagonal_count
541                ));
542            }
543            if self.tiny_diagonal_count > 0 {
544                reasons.push(format!("{} tiny diagonals", self.tiny_diagonal_count));
545            }
546            if reasons.is_empty() {
547                "generated A*ones reference is structurally valid".to_string()
548            } else {
549                format!(
550                    "N/A (generated A*ones but matrix has {}; solution may be nonunique)",
551                    reasons.join(", ")
552                )
553            }
554        }
555    }
556
557    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
558    enum CsrBackend {
559        Serial,
560        Replicated,
561        Distributed,
562    }
563
564    impl CsrBackend {
565        fn run_label(self) -> &'static str {
566            match self {
567                Self::Serial => "serial CSR",
568                Self::Replicated => "replicated CSR",
569                Self::Distributed => "distributed CSR",
570            }
571        }
572
573        fn details(self) -> &'static str {
574            match self {
575                Self::Serial => "single rank operator",
576                Self::Replicated => "all ranks hold full matrix, identical solve",
577                Self::Distributed => "row-partitioned operator",
578            }
579        }
580
581        fn benchmark_export_label(self) -> &'static str {
582            match self {
583                Self::Serial => "serial",
584                Self::Replicated => "replicated",
585                Self::Distributed => "distributed",
586            }
587        }
588    }
589
590    const CORRECTNESS_VERDICT_RTOL_MULTIPLIER: f64 = 10.0;
591
592    fn correctness_verdict_tolerance(bench_cfg: &BenchmarkConfig) -> f64 {
593        bench_cfg.rtol * CORRECTNESS_VERDICT_RTOL_MULTIPLIER
594    }
595
596    struct ResultRow {
597        operator_storage: &'static str,
598        execution_backend: &'static str,
599        pc_domain: &'static str,
600        pc_apply: &'static str,
601        method: String,
602        requested_policy: String,
603        effective_policy: String,
604        setup_secs: f64,
605        median_solve_secs: f64,
606        min_solve_secs: f64,
607        iterations: usize,
608        reductions: usize,
609        overlapped_reduction_waits: usize,
610        model_predicted_reductions: Option<usize>,
611        restart_count: Option<usize>,
612        inner_iterations_last_cycle: Option<usize>,
613        pipeline_fallbacks: Option<usize>,
614        reported_residual: R,
615        explicit_true_residual: Option<R>,
616        explicit_true_residual_rel: Option<R>,
617        x_error_rel: Option<R>,
618        global_true_residual: Option<R>,
619        global_true_residual_rel: Option<R>,
620        global_x_error_rel: Option<R>,
621        dist_ok: Option<bool>,
622        global_ok: Option<bool>,
623        x_ok: Option<bool>,
624        reason: ConvergedReason,
625        dof_per_sec: Option<f64>,
626    }
627
628    struct RunSpec {
629        restart: usize,
630        ksp: KspKind,
631        variant: FgmresVariant,
632        residual_check_policy: ResidualCheckPolicy,
633        orthog: OrthogMethod,
634        reorth: ReorthPolicy,
635        pc_side: PcSide,
636        pc: PcKind,
637    }
638
639    #[derive(Clone, Debug)]
640    struct CsrForPcDiagnostics {
641        nnz_local_block: usize,
642        nnz_local_rows: usize,
643        nnz_ratio: f64,
644        zero_local_rows: usize,
645        zero_global_rows: usize,
646        structural_diag_missing: usize,
647        diag_min_abs: f64,
648        diag_max_abs: f64,
649        diag_tiny_or_missing_count: usize,
650    }
651
652    #[derive(Clone, Debug)]
653    struct RankSpread {
654        global_sum: f64,
655        rank_min: f64,
656        rank_max: f64,
657        rank0_local: f64,
658    }
659
660    #[derive(Clone, Debug)]
661    struct CsrForPcDiagnosticsGlobal {
662        nnz_local_block: RankSpread,
663        nnz_local_rows: RankSpread,
664        nnz_ratio: RankSpread,
665        zero_local_rows_rank_count: f64,
666        zero_global_rows: RankSpread,
667        structural_diag_missing: RankSpread,
668        diag_min_abs: RankSpread,
669        diag_max_abs: RankSpread,
670        diag_tiny_or_missing_count: RankSpread,
671        pivot_perturbation_count: Option<RankSpread>,
672    }
673
674    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
675    enum PcKind {
676        None,
677        JacobiWeak,
678        Ilu0Local,
679        IlutLocal,
680        MpiBlockJacobiIlu0Local,
681        AsmIlu0Overlap { overlap: usize },
682        RasIlu0Overlap { overlap: usize },
683        RasIlukOverlap { k: usize, overlap: usize },
684        LocalIluk { k: usize },
685        ReplicatedFullIlu0,
686        ReplicatedFullIluk { k: usize },
687    }
688
689    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
690    enum PcDispatchBranch {
691        None,
692        JacobiWeak,
693        Ilu0Local,
694        ReplicatedFullIlu,
695        IlutLocal,
696        OverlapIlu,
697    }
698
699    #[derive(Clone, Debug)]
700    struct BenchmarkConfig {
701        run_mode: RunMode,
702        warmup_runs: usize,
703        measured_runs: usize,
704        rtol: f64,
705        atol: f64,
706        maxits: usize,
707        restarts: Vec<usize>,
708        ksp: KspKind,
709        pcs: Vec<PcKind>,
710        include_restart_200: bool,
711        include_ilut_real_projection_fallback: bool,
712        include_overlap_pcs: bool,
713        variants: Vec<FgmresVariant>,
714        orthogs: Vec<OrthogMethod>,
715        reorths: Vec<ReorthPolicy>,
716        dist_policy: DistPolicyMode,
717        allow_stagnation_fallback: bool,
718        min_inner_before_fallback: usize,
719        mark_replicated_check: bool,
720        residual_history: bool,
721        residual_history_file: Option<PathBuf>,
722        residual_history_force: bool,
723        fgmres_haptol: f64,
724        row_scale: bool,
725        row_scale_tiny: f64,
726        ilu_reordering: ReorderingOptions,
727        poisson_grid: (usize, usize),
728        poisson_shift_real: f64,
729        poisson_shift_imag: f64,
730        poisson_convection_x: f64,
731        poisson_convection_y: f64,
732    }
733
734    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
735    enum RunMode {
736        Correctness,
737        Scalability,
738        Robustness,
739    }
740
741    impl RunMode {
742        fn default_dist_policy(self) -> DistPolicyMode {
743            match self {
744                Self::Correctness => DistPolicyMode::Off,
745                Self::Scalability => DistPolicyMode::Auto,
746                // Robustness keeps the requested restart/variant unless explicitly opted in.
747                Self::Robustness => DistPolicyMode::Off,
748            }
749        }
750
751        fn label(self) -> &'static str {
752            match self {
753                Self::Correctness => "correctness",
754                Self::Scalability => "scalability",
755                Self::Robustness => "robustness",
756            }
757        }
758
759        fn parse(token: &str) -> Result<Self, KError> {
760            match token.trim().to_ascii_lowercase().as_str() {
761                "correctness" => Ok(Self::Correctness),
762                "scalability" => Ok(Self::Scalability),
763                "robustness" => Ok(Self::Robustness),
764                other => Err(KError::InvalidInput(format!(
765                    "invalid run mode '{other}', expected correctness|scalability|robustness"
766                ))),
767            }
768        }
769    }
770
771    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
772    enum DistPolicyMode {
773        Off,
774        Auto,
775    }
776
777    impl DistPolicyMode {
778        fn label(self) -> &'static str {
779            match self {
780                Self::Off => "off",
781                Self::Auto => "auto",
782            }
783        }
784
785        fn parse(token: &str) -> Result<Self, KError> {
786            match token.trim().to_ascii_lowercase().as_str() {
787                "off" => Ok(Self::Off),
788                "auto" => Ok(Self::Auto),
789                other => Err(KError::InvalidInput(format!(
790                    "invalid DistCSR policy '{other}', expected off|auto"
791                ))),
792            }
793        }
794    }
795
796    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
797    enum KspKind {
798        Gmres,
799        Fgmres,
800    }
801
802    impl KspKind {
803        fn label(self) -> &'static str {
804            match self {
805                Self::Gmres => "GMRES",
806                Self::Fgmres => "FGMRES",
807            }
808        }
809
810        fn parse(token: &str) -> Result<Self, KError> {
811            match token.trim().to_ascii_lowercase().as_str() {
812                "gmres" => Ok(Self::Gmres),
813                "fgmres" => Ok(Self::Fgmres),
814                other => Err(KError::InvalidInput(format!(
815                    "invalid KSP '{other}', expected gmres|fgmres"
816                ))),
817            }
818        }
819
820        fn for_pc(self, pc: PcKind) -> Self {
821            if pc.requires_flexible_right_preconditioning() {
822                Self::Fgmres
823            } else {
824                self
825            }
826        }
827    }
828
829    impl Default for BenchmarkConfig {
830        fn default() -> Self {
831            Self {
832                run_mode: RunMode::Correctness,
833                warmup_runs: 1,
834                measured_runs: 5,
835                rtol: 1e-8,
836                atol: 1e-12,
837                maxits: 500,
838                restarts: vec![50, 100, 150],
839                ksp: KspKind::Gmres,
840                pcs: Vec::new(),
841                include_restart_200: false,
842                include_ilut_real_projection_fallback: false,
843                include_overlap_pcs: false,
844                variants: vec![FgmresVariant::Classical],
845                orthogs: vec![OrthogMethod::ClassicalGS],
846                reorths: vec![ReorthPolicy::IfNeeded],
847                dist_policy: RunMode::Correctness.default_dist_policy(),
848                allow_stagnation_fallback: false,
849                min_inner_before_fallback: 8,
850                mark_replicated_check: false,
851                residual_history: false,
852                residual_history_file: None,
853                residual_history_force: false,
854                fgmres_haptol: 1e-30,
855                row_scale: false,
856                row_scale_tiny: 1e-15,
857                ilu_reordering: ReorderingOptions::default(),
858                poisson_grid: (16, 16),
859                poisson_shift_real: 1.0,
860                poisson_shift_imag: 0.25,
861                poisson_convection_x: 0.0,
862                poisson_convection_y: 0.0,
863            }
864        }
865    }
866
867    impl BenchmarkConfig {
868        fn from_env_args() -> Result<Self, KError> {
869            Self::from_args(env::args().skip(1))
870        }
871
872        fn from_args<I>(args: I) -> Result<Self, KError>
873        where
874            I: IntoIterator<Item = String>,
875        {
876            let mut cfg = Self::default();
877            let mut dist_policy_explicit = false;
878            let mut args = args.into_iter().peekable();
879            while let Some(arg) = args.next() {
880                match arg.as_str() {
881                    "--warmup-runs" => {
882                        let Some(v) = args.next() else {
883                            return Err(KError::InvalidInput(
884                                "missing value for --warmup-runs".into(),
885                            ));
886                        };
887                        cfg.warmup_runs = parse_positive_usize("--warmup-runs", &v)?;
888                    }
889                    "--measured-runs" => {
890                        let Some(v) = args.next() else {
891                            return Err(KError::InvalidInput(
892                                "missing value for --measured-runs".into(),
893                            ));
894                        };
895                        cfg.measured_runs = parse_positive_usize("--measured-runs", &v)?;
896                    }
897                    "--help" | "-h" => {
898                        if cfg!(feature = "mpi") {
899                            println!(
900                                "Usage: cargo mpirun -n <ranks> --example complex_matrix_market_demo --features complex,mpi,mpi_examples -- [--mode correctness|scalability|robustness] [--ksp gmres|fgmres] [--dist-policy off|auto] [--mark-replicated-check] [--warmup-runs N] [--measured-runs N] [--rtol F] [--atol F] [--maxits N] [--restarts csv] [--pcs csv] [--include-restart-200] [--include-ilut-real-projection-fallback] [--include-overlap-pcs] [--allow-stagnation-fallback] [--min-inner-before-fallback N] [--fgmres-variant csv] [--fgmres-orthog csv] [--fgmres-reorth csv] [--fgmres-haptol F] [--residual-history] [--residual-history-file <path>] [--residual-history-force] [--row-scale [tiny]] [--ilu-reordering none|rcm|amd[:nonsym]] [--poisson-grid NXxNY] [--poisson-shift-real F] [--poisson-shift-imag F] [--poisson-convection CX,CY]
901Defaults: --ksp gmres for fixed linear PCs; flexible/nonlinear PC modes select FGMRES. --dist-policy is off for correctness and robustness, auto for scalability.
902--include-overlap-pcs is active only for MPI correctness sweeps; it keeps block-jacobi-ilu0-overlap0 and appends asm-ilu0-overlap1, ras-ilu0-overlap1, ras-iluk1-overlap1 so GlobalOK/XOK verdicts are visible before performance comparisons."
903                            );
904                        } else {
905                            println!(
906                                "Usage: cargo run --example complex_matrix_market_demo --features complex -- [--mode correctness|scalability|robustness] [--ksp gmres|fgmres] [--dist-policy off|auto] [--mark-replicated-check] [--warmup-runs N] [--measured-runs N] [--rtol F] [--atol F] [--maxits N] [--restarts csv] [--pcs csv] [--include-restart-200] [--include-ilut-real-projection-fallback] [--include-overlap-pcs] [--allow-stagnation-fallback] [--min-inner-before-fallback N] [--fgmres-variant csv] [--fgmres-orthog csv] [--fgmres-reorth csv] [--fgmres-haptol F] [--residual-history] [--residual-history-file <path>] [--residual-history-force] [--row-scale [tiny]] [--ilu-reordering none|rcm|amd[:nonsym]] [--poisson-grid NXxNY] [--poisson-shift-real F] [--poisson-shift-imag F] [--poisson-convection CX,CY]
907Defaults: --ksp gmres for fixed linear PCs; flexible/nonlinear PC modes select FGMRES. --dist-policy is off for correctness and robustness, auto for scalability.
908--include-overlap-pcs is active only for MPI correctness sweeps; it keeps block-jacobi-ilu0-overlap0 and appends asm-ilu0-overlap1, ras-ilu0-overlap1, ras-iluk1-overlap1 so GlobalOK/XOK verdicts are visible before performance comparisons."
909                            );
910                        }
911                        std::process::exit(0);
912                    }
913                    "--mode" => {
914                        let Some(v) = args.next() else {
915                            return Err(KError::InvalidInput("missing value for --mode".into()));
916                        };
917                        cfg.run_mode = RunMode::parse(&v)?;
918                    }
919                    "--ksp" => {
920                        let Some(v) = args.next() else {
921                            return Err(KError::InvalidInput("missing value for --ksp".into()));
922                        };
923                        cfg.ksp = KspKind::parse(&v)?;
924                    }
925                    "--dist-policy" => {
926                        let Some(v) = args.next() else {
927                            return Err(KError::InvalidInput(
928                                "missing value for --dist-policy".into(),
929                            ));
930                        };
931                        cfg.dist_policy = DistPolicyMode::parse(&v)?;
932                        dist_policy_explicit = true;
933                    }
934                    "--poisson-grid" => {
935                        let Some(v) = args.next() else {
936                            return Err(KError::InvalidInput(
937                                "missing value for --poisson-grid".into(),
938                            ));
939                        };
940                        cfg.poisson_grid = parse_grid_dims("--poisson-grid", &v)?;
941                    }
942                    "--poisson-shift-real" => {
943                        let Some(v) = args.next() else {
944                            return Err(KError::InvalidInput(
945                                "missing value for --poisson-shift-real".into(),
946                            ));
947                        };
948                        cfg.poisson_shift_real = parse_finite_f64("--poisson-shift-real", &v)?;
949                    }
950                    "--poisson-shift-imag" => {
951                        let Some(v) = args.next() else {
952                            return Err(KError::InvalidInput(
953                                "missing value for --poisson-shift-imag".into(),
954                            ));
955                        };
956                        cfg.poisson_shift_imag = parse_finite_f64("--poisson-shift-imag", &v)?;
957                    }
958                    "--poisson-convection" => {
959                        let Some(v) = args.next() else {
960                            return Err(KError::InvalidInput(
961                                "missing value for --poisson-convection".into(),
962                            ));
963                        };
964                        (cfg.poisson_convection_x, cfg.poisson_convection_y) =
965                            parse_f64_pair("--poisson-convection", &v)?;
966                    }
967                    "--mark-replicated-check" => {
968                        cfg.mark_replicated_check = true;
969                    }
970                    "--correctness-replicated-check" => {
971                        return Err(KError::InvalidInput(
972                            "--correctness-replicated-check is metadata-only and has been renamed to --mark-replicated-check".into(),
973                        ));
974                    }
975                    "--restarts" => {
976                        let Some(v) = args.next() else {
977                            return Err(KError::InvalidInput(
978                                "missing value for --restarts".into(),
979                            ));
980                        };
981                        cfg.restarts = parse_usize_csv("--restarts", &v)?;
982                    }
983                    "--rtol" => {
984                        let Some(v) = args.next() else {
985                            return Err(KError::InvalidInput("missing value for --rtol".into()));
986                        };
987                        cfg.rtol = parse_positive_f64("--rtol", &v)?;
988                    }
989                    "--atol" => {
990                        let Some(v) = args.next() else {
991                            return Err(KError::InvalidInput("missing value for --atol".into()));
992                        };
993                        cfg.atol = parse_positive_f64("--atol", &v)?;
994                    }
995                    "--maxits" => {
996                        let Some(v) = args.next() else {
997                            return Err(KError::InvalidInput("missing value for --maxits".into()));
998                        };
999                        cfg.maxits = parse_positive_usize("--maxits", &v)?;
1000                    }
1001                    "--pcs" => {
1002                        let Some(v) = args.next() else {
1003                            return Err(KError::InvalidInput("missing value for --pcs".into()));
1004                        };
1005                        cfg.pcs = parse_pc_csv("--pcs", &v)?;
1006                    }
1007                    "--include-restart-200" => {
1008                        cfg.include_restart_200 = true;
1009                    }
1010                    "--include-ilut-real-projection-fallback" => {
1011                        cfg.include_ilut_real_projection_fallback = true;
1012                    }
1013                    "--include-overlap-pcs" => {
1014                        cfg.include_overlap_pcs = true;
1015                    }
1016                    "--ilu-reordering" | "--pc-ilu-reordering-type" => {
1017                        let Some(v) = args.next() else {
1018                            return Err(KError::InvalidInput(
1019                                "missing value for --ilu-reordering".into(),
1020                            ));
1021                        };
1022                        cfg.ilu_reordering = parse_ilu_reordering(&v)?;
1023                    }
1024                    "--allow-stagnation-fallback" => {
1025                        cfg.allow_stagnation_fallback = true;
1026                    }
1027                    "--min-inner-before-fallback" => {
1028                        let Some(v) = args.next() else {
1029                            return Err(KError::InvalidInput(
1030                                "missing value for --min-inner-before-fallback".into(),
1031                            ));
1032                        };
1033                        cfg.min_inner_before_fallback =
1034                            parse_positive_usize("--min-inner-before-fallback", &v)?;
1035                    }
1036                    "--fgmres-variant" => {
1037                        let Some(v) = args.next() else {
1038                            return Err(KError::InvalidInput(
1039                                "missing value for --fgmres-variant".into(),
1040                            ));
1041                        };
1042                        cfg.variants = parse_variant_csv("--fgmres-variant", &v)?;
1043                    }
1044                    "--fgmres-orthog" => {
1045                        let Some(v) = args.next() else {
1046                            return Err(KError::InvalidInput(
1047                                "missing value for --fgmres-orthog".into(),
1048                            ));
1049                        };
1050                        cfg.orthogs = parse_orthog_csv("--fgmres-orthog", &v)?;
1051                    }
1052                    "--residual-history" => {
1053                        cfg.residual_history = true;
1054                    }
1055                    "--residual-history-file" => {
1056                        let Some(v) = args.next() else {
1057                            return Err(KError::InvalidInput(
1058                                "missing value for --residual-history-file".into(),
1059                            ));
1060                        };
1061                        cfg.residual_history_file = Some(PathBuf::from(v));
1062                    }
1063                    "--residual-history-force" => {
1064                        cfg.residual_history_force = true;
1065                    }
1066                    "--row-scale" => {
1067                        cfg.row_scale = true;
1068                        if let Some(next) = args.peek() {
1069                            if !next.starts_with("--") {
1070                                let tiny = args.next().ok_or_else(|| {
1071                                    KError::InvalidInput(
1072                                        "failed to read --row-scale optional tiny".into(),
1073                                    )
1074                                })?;
1075                                cfg.row_scale_tiny = parse_positive_f64("--row-scale", &tiny)?;
1076                            }
1077                        }
1078                    }
1079                    "--fgmres-reorth" => {
1080                        let Some(v) = args.next() else {
1081                            return Err(KError::InvalidInput(
1082                                "missing value for --fgmres-reorth".into(),
1083                            ));
1084                        };
1085                        cfg.reorths = parse_reorth_csv("--fgmres-reorth", &v)?;
1086                    }
1087                    "--fgmres-haptol" => {
1088                        let Some(v) = args.next() else {
1089                            return Err(KError::InvalidInput(
1090                                "missing value for --fgmres-haptol".into(),
1091                            ));
1092                        };
1093                        cfg.fgmres_haptol = parse_positive_finite_f64("--fgmres-haptol", &v)?;
1094                    }
1095                    _ => {
1096                        return Err(KError::InvalidInput(format!("unknown argument: {arg}")));
1097                    }
1098                }
1099            }
1100            if cfg.measured_runs == 0 {
1101                return Err(KError::InvalidInput(
1102                    "--measured-runs must be at least 1".into(),
1103                ));
1104            }
1105            if !dist_policy_explicit {
1106                cfg.dist_policy = cfg.run_mode.default_dist_policy();
1107            }
1108            if cfg.include_restart_200 && !cfg.restarts.contains(&200) {
1109                cfg.restarts.push(200);
1110            }
1111            if cfg.run_mode == RunMode::Scalability {
1112                cfg.mark_replicated_check = false;
1113            }
1114            if cfg.pcs.iter().any(|pc| pc.is_ilut()) {
1115                eprintln!(
1116                    "⚠ --pcs includes ILUT for a complex run: current ILUT path is a degraded real projection and is not trusted for complex robustness benchmarking."
1117                );
1118            }
1119            if cfg.include_ilut_real_projection_fallback && !cfg.pcs.iter().any(|pc| pc.is_ilut()) {
1120                eprintln!(
1121                    "⚠ Including {} in the default complex benchmark set: current ILUT path is a degraded real projection and is not trusted for complex robustness benchmarking.",
1122                    ILUT_REAL_PROJECTION_FALLBACK_LABEL
1123                );
1124            }
1125            if cfg.residual_history
1126                && cfg.run_mode != RunMode::Correctness
1127                && !cfg.residual_history_force
1128            {
1129                cfg.residual_history = false;
1130            }
1131            Ok(cfg)
1132        }
1133    }
1134
1135    fn parse_grid_dims(flag: &str, value: &str) -> Result<(usize, usize), KError> {
1136        let Some((nx, ny)) = value.split_once(['x', 'X']) else {
1137            return Err(KError::InvalidInput(format!(
1138                "invalid value '{value}' for {flag}, expected NXxNY"
1139            )));
1140        };
1141        let nx = parse_positive_usize(flag, nx)?;
1142        let ny = parse_positive_usize(flag, ny)?;
1143        if nx == 0 || ny == 0 {
1144            return Err(KError::InvalidInput(format!(
1145                "invalid value '{value}' for {flag}, dimensions must be positive"
1146            )));
1147        }
1148        Ok((nx, ny))
1149    }
1150
1151    fn parse_positive_usize(flag: &str, value: &str) -> Result<usize, KError> {
1152        value.parse::<usize>().map_err(|_| {
1153            KError::InvalidInput(format!(
1154                "invalid value '{value}' for {flag}, expected non-negative integer"
1155            ))
1156        })
1157    }
1158
1159    fn parse_positive_f64(flag: &str, value: &str) -> Result<f64, KError> {
1160        let val = value.parse::<f64>().map_err(|_| {
1161            KError::InvalidInput(format!(
1162                "invalid value '{value}' for {flag}, expected non-negative float"
1163            ))
1164        })?;
1165        if val < 0.0 {
1166            return Err(KError::InvalidInput(format!(
1167                "invalid value '{value}' for {flag}, expected non-negative float"
1168            )));
1169        }
1170        Ok(val)
1171    }
1172
1173    fn parse_finite_f64(flag: &str, value: &str) -> Result<f64, KError> {
1174        let val = value.parse::<f64>().map_err(|_| {
1175            KError::InvalidInput(format!(
1176                "invalid value '{value}' for {flag}, expected finite float"
1177            ))
1178        })?;
1179        if !val.is_finite() {
1180            return Err(KError::InvalidInput(format!(
1181                "invalid value '{value}' for {flag}, expected finite float"
1182            )));
1183        }
1184        Ok(val)
1185    }
1186
1187    fn parse_f64_pair(flag: &str, value: &str) -> Result<(f64, f64), KError> {
1188        let Some((x, y)) = value.split_once(',') else {
1189            return Err(KError::InvalidInput(format!(
1190                "invalid value '{value}' for {flag}, expected X,Y"
1191            )));
1192        };
1193        let x = parse_finite_f64(flag, x.trim())?;
1194        let y = parse_finite_f64(flag, y.trim())?;
1195        Ok((x, y))
1196    }
1197
1198    fn parse_positive_finite_f64(flag: &str, value: &str) -> Result<f64, KError> {
1199        let val = value.parse::<f64>().map_err(|_| {
1200            KError::InvalidInput(format!(
1201                "invalid value '{value}' for {flag}, expected positive finite float"
1202            ))
1203        })?;
1204        if !val.is_finite() || val <= 0.0 {
1205            return Err(KError::InvalidInput(format!(
1206                "invalid value '{value}' for {flag}, expected positive finite float"
1207            )));
1208        }
1209        Ok(val)
1210    }
1211
1212    fn parse_usize_csv(flag: &str, value: &str) -> Result<Vec<usize>, KError> {
1213        let vals = value
1214            .split(',')
1215            .map(str::trim)
1216            .filter(|s| !s.is_empty())
1217            .map(|v| parse_positive_usize(flag, v))
1218            .collect::<Result<Vec<_>, _>>()?;
1219        if vals.is_empty() {
1220            return Err(KError::InvalidInput(format!(
1221                "{flag} expects at least one integer value"
1222            )));
1223        }
1224        Ok(vals)
1225    }
1226
1227    fn parse_variant(token: &str) -> Result<FgmresVariant, KError> {
1228        match token.trim().to_ascii_lowercase().as_str() {
1229            "classical" => Ok(FgmresVariant::Classical),
1230            "pipelined" => Ok(FgmresVariant::Pipelined),
1231            other => Err(KError::InvalidInput(format!(
1232                "invalid fgmres variant '{other}', expected classical|pipelined"
1233            ))),
1234        }
1235    }
1236
1237    fn parse_orthog(token: &str) -> Result<OrthogMethod, KError> {
1238        match token.trim().to_ascii_lowercase().as_str() {
1239            "classical" | "cgs" | "cgs_refined" | "cgs-refined" | "refined" => {
1240                Ok(OrthogMethod::ClassicalGS)
1241            }
1242            "mgs" | "modified" => Ok(OrthogMethod::ModifiedGS),
1243            other => Err(KError::InvalidInput(format!(
1244                "invalid orthog '{other}', expected classical|cgs|cgs_refined|mgs|modified"
1245            ))),
1246        }
1247    }
1248
1249    fn parse_reorth(token: &str) -> Result<ReorthPolicy, KError> {
1250        match token.trim().to_ascii_lowercase().as_str() {
1251            "never" => Ok(ReorthPolicy::Never),
1252            "ifneeded" | "if-needed" => Ok(ReorthPolicy::IfNeeded),
1253            "always" => Ok(ReorthPolicy::Always),
1254            other => Err(KError::InvalidInput(format!(
1255                "invalid reorth '{other}', expected never|ifneeded|always"
1256            ))),
1257        }
1258    }
1259
1260    fn parse_variant_csv(flag: &str, value: &str) -> Result<Vec<FgmresVariant>, KError> {
1261        parse_csv(flag, value, parse_variant)
1262    }
1263
1264    fn parse_orthog_csv(flag: &str, value: &str) -> Result<Vec<OrthogMethod>, KError> {
1265        parse_csv(flag, value, parse_orthog)
1266    }
1267
1268    fn parse_reorth_csv(flag: &str, value: &str) -> Result<Vec<ReorthPolicy>, KError> {
1269        parse_csv(flag, value, parse_reorth)
1270    }
1271
1272    fn parse_ilu_reordering(value: &str) -> Result<ReorderingOptions, KError> {
1273        let normalized = value.trim().to_ascii_lowercase();
1274        let (base, nonsym) = if let Some(base) = normalized.strip_suffix(":nonsym") {
1275            (base, true)
1276        } else if let Some(base) = normalized.strip_suffix("_nonsym") {
1277            (base, true)
1278        } else {
1279            (normalized.as_str(), false)
1280        };
1281        let kind = match base {
1282            "none" | "natural" => ReorderingKind::None,
1283            "rcm" => ReorderingKind::Rcm,
1284            "amd" => ReorderingKind::Amd,
1285            other => {
1286                return Err(KError::InvalidInput(format!(
1287                    "invalid ILU reordering '{other}', expected none|rcm|amd with optional :nonsym suffix"
1288                )));
1289            }
1290        };
1291        Ok(ReorderingOptions {
1292            kind,
1293            symmetric: !nonsym,
1294            deterministic: true,
1295        })
1296    }
1297
1298    fn parse_pc(token: &str) -> Result<PcKind, KError> {
1299        let token_norm = token.trim().to_ascii_lowercase();
1300        if let Some(k_str) = token_norm.strip_prefix("local-iluk:") {
1301            return Ok(PcKind::LocalIluk {
1302                k: parse_positive_usize("local-iluk:k", k_str)?,
1303            });
1304        }
1305        if let Some(k_str) = token_norm.strip_prefix("block-jacobi-iluk:") {
1306            return Ok(PcKind::LocalIluk {
1307                k: parse_positive_usize("block-jacobi-iluk:k", k_str)?,
1308            });
1309        }
1310        if let Some(k_str) = token_norm.strip_prefix("block-jacobi-iluk") {
1311            let k_str = k_str.strip_suffix("-overlap0").unwrap_or(k_str);
1312            return Ok(PcKind::LocalIluk {
1313                k: parse_positive_usize("block-jacobi-iluk:k", k_str)?,
1314            });
1315        }
1316        if let Some(k_str) = token_norm.strip_prefix("replicated-iluk:") {
1317            return Ok(PcKind::ReplicatedFullIluk {
1318                k: parse_positive_usize("replicated-iluk:k", k_str)?,
1319            });
1320        }
1321        match token_norm.as_str() {
1322            "asm-ilu0-overlap1" => Ok(PcKind::AsmIlu0Overlap { overlap: 1 }),
1323            "asm-ilu0-overlap2" => Ok(PcKind::AsmIlu0Overlap { overlap: 2 }),
1324            "ras-ilu0-overlap1" => Ok(PcKind::RasIlu0Overlap { overlap: 1 }),
1325            "ras-iluk1-overlap1" => Ok(PcKind::RasIlukOverlap { k: 1, overlap: 1 }),
1326            "none" | "off" => Ok(PcKind::None),
1327            "jacobi" | "jacobi-weak" | "weak-jacobi" => Ok(PcKind::JacobiWeak),
1328            "ilu0" | "ilu0-local" | "local-ilu0" => Ok(PcKind::Ilu0Local),
1329            "ilut" | "ilut-local" | "local-ilut" => Ok(PcKind::IlutLocal),
1330            "replicated-ilu0" | "replicated-full-ilu0" => Ok(PcKind::ReplicatedFullIlu0),
1331            "mpi-block-jacobi-ilu0"
1332            | "block-jacobi-ilu0"
1333            | "block-jacobi-ilu0-overlap0"
1334            | "mpi-block-ilu0" => Ok(PcKind::MpiBlockJacobiIlu0Local),
1335            other => Err(KError::InvalidInput(format!(
1336                "invalid pc '{other}', expected none|jacobi|local-ilu0|local-ilut|local-iluk:<k>|block-jacobi-ilu0-overlap0|block-jacobi-iluk1|block-jacobi-iluk1-overlap0|block-jacobi-iluk:<k>|block-jacobi-iluk<k>-overlap0|replicated-ilu0|replicated-full-ilu0|replicated-iluk:<k>|mpi-block-jacobi-ilu0|asm-ilu0-overlap1|asm-ilu0-overlap2|ras-ilu0-overlap1|ras-iluk1-overlap1"
1337            ))),
1338        }
1339    }
1340
1341    fn parse_pc_csv(flag: &str, value: &str) -> Result<Vec<PcKind>, KError> {
1342        parse_csv(flag, value, parse_pc)
1343    }
1344
1345    const MPI_CORRECTNESS_OVERLAP_PCS: [PcKind; 3] = [
1346        PcKind::AsmIlu0Overlap { overlap: 1 },
1347        PcKind::RasIlu0Overlap { overlap: 1 },
1348        PcKind::RasIlukOverlap { k: 1, overlap: 1 },
1349    ];
1350
1351    fn should_include_overlap_pcs(cfg: &BenchmarkConfig, comm_size: usize) -> bool {
1352        cfg.include_overlap_pcs && cfg.run_mode == RunMode::Correctness && comm_size > 1
1353    }
1354
1355    fn push_pc_unique(pcs: &mut Vec<PcKind>, pc: PcKind) {
1356        if !pcs.contains(&pc) {
1357            pcs.push(pc);
1358        }
1359    }
1360
1361    fn append_mpi_correctness_overlap_pcs(pcs: &mut Vec<PcKind>) {
1362        push_pc_unique(pcs, PcKind::MpiBlockJacobiIlu0Local);
1363        for pc in MPI_CORRECTNESS_OVERLAP_PCS {
1364            push_pc_unique(pcs, pc);
1365        }
1366    }
1367
1368    fn parse_csv<T, F>(flag: &str, value: &str, mut parser: F) -> Result<Vec<T>, KError>
1369    where
1370        F: FnMut(&str) -> Result<T, KError>,
1371    {
1372        let vals = value
1373            .split(',')
1374            .map(str::trim)
1375            .filter(|s| !s.is_empty())
1376            .map(&mut parser)
1377            .collect::<Result<Vec<_>, _>>()?;
1378        if vals.is_empty() {
1379            return Err(KError::InvalidInput(format!(
1380                "{flag} expects at least one value"
1381            )));
1382        }
1383        Ok(vals)
1384    }
1385
1386    impl RunSpec {
1387        fn build_default_matrix(cfg: &BenchmarkConfig, problem: &Problem) -> Vec<Self> {
1388            let mut pcs = if cfg.pcs.is_empty() && problem.generated_case {
1389                vec![
1390                    PcKind::None,
1391                    PcKind::JacobiWeak,
1392                    if problem.comm.size() > 1 {
1393                        PcKind::MpiBlockJacobiIlu0Local
1394                    } else {
1395                        PcKind::Ilu0Local
1396                    },
1397                    PcKind::LocalIluk { k: 1 },
1398                ]
1399            } else if cfg.pcs.is_empty() {
1400                match cfg.run_mode {
1401                    RunMode::Correctness => {
1402                        if problem.comm.size() > 1 {
1403                            vec![
1404                                PcKind::ReplicatedFullIlu0,
1405                                PcKind::None,
1406                                PcKind::JacobiWeak,
1407                                PcKind::MpiBlockJacobiIlu0Local,
1408                            ]
1409                        } else {
1410                            vec![PcKind::Ilu0Local, PcKind::JacobiWeak, PcKind::None]
1411                        }
1412                    }
1413                    RunMode::Scalability => {
1414                        if problem.comm.size() > 1 {
1415                            vec![PcKind::MpiBlockJacobiIlu0Local]
1416                        } else {
1417                            vec![PcKind::Ilu0Local]
1418                        }
1419                    }
1420                    RunMode::Robustness => {
1421                        if problem.comm.size() > 1 {
1422                            vec![PcKind::MpiBlockJacobiIlu0Local, PcKind::JacobiWeak]
1423                        } else {
1424                            vec![PcKind::Ilu0Local, PcKind::JacobiWeak]
1425                        }
1426                    }
1427                }
1428            } else {
1429                cfg.pcs.clone()
1430            };
1431            if cfg.pcs.is_empty() && cfg.include_ilut_real_projection_fallback {
1432                pcs.push(PcKind::IlutLocal);
1433            }
1434            if should_include_overlap_pcs(cfg, problem.comm.size()) {
1435                append_mpi_correctness_overlap_pcs(&mut pcs);
1436            }
1437            let mut runs = Vec::new();
1438            for &restart in &cfg.restarts {
1439                for &variant in &cfg.variants {
1440                    for &orthog in &cfg.orthogs {
1441                        for &reorth in &cfg.reorths {
1442                            for &pc in &pcs {
1443                                if pc == PcKind::MpiBlockJacobiIlu0Local && problem.comm.size() <= 1
1444                                {
1445                                    continue;
1446                                }
1447                                runs.push(Self {
1448                                    restart,
1449                                    ksp: cfg.ksp.for_pc(pc),
1450                                    variant,
1451                                    residual_check_policy: match cfg.run_mode {
1452                                        RunMode::Correctness => ResidualCheckPolicy::OnConvergence,
1453                                        RunMode::Scalability => ResidualCheckPolicy::RestartOnly,
1454                                        RunMode::Robustness => ResidualCheckPolicy::RestartOnly,
1455                                    },
1456                                    orthog,
1457                                    reorth,
1458                                    pc_side: PcSide::Right,
1459                                    pc,
1460                                });
1461                            }
1462                        }
1463                    }
1464                }
1465            }
1466            runs
1467        }
1468
1469        fn method_label(&self) -> String {
1470            let effective_side = normalized_fgmres_side(self.pc_side);
1471            let side_desc = if effective_side == self.pc_side {
1472                format!("{}", pc_side_label(self.pc_side))
1473            } else {
1474                format!(
1475                    "{}→{} (normalized)",
1476                    pc_side_label(self.pc_side),
1477                    pc_side_label(effective_side)
1478                )
1479            };
1480            format!(
1481                "{}+{} [m={}, v={}, reschk={}, orth={}, reorth={}, pc=requested {}, effective {}]",
1482                self.ksp.label(),
1483                self.pc.label(),
1484                self.restart,
1485                variant_label(self.variant),
1486                residual_check_policy_label(self.residual_check_policy),
1487                orthog_label(self.orthog),
1488                reorth_label(self.reorth),
1489                pc_side_label(self.pc_side),
1490                side_desc,
1491            )
1492        }
1493
1494        fn requested_policy_label(&self) -> String {
1495            format!(
1496                "ksp={}, variant={}, restart={}, residual-check={}",
1497                self.ksp.label(),
1498                variant_label(self.variant),
1499                self.restart,
1500                residual_check_policy_label(self.residual_check_policy)
1501            )
1502        }
1503    }
1504
1505    fn operator_storage_label(_problem: &Problem) -> &'static str {
1506        "csr-cx"
1507    }
1508
1509    fn execution_backend_label(problem: &Problem) -> &'static str {
1510        match problem.backend {
1511            CsrBackend::Serial => "ser",
1512            CsrBackend::Replicated => "mpi-repl",
1513            CsrBackend::Distributed => "mpi-row",
1514        }
1515    }
1516
1517    fn pc_domain_label(pc: PcKind, problem: &Problem) -> &'static str {
1518        pc_domain_label_for_size(pc, problem.comm.size())
1519    }
1520
1521    fn pc_domain_label_for_size(pc: PcKind, comm_size: usize) -> &'static str {
1522        match pc {
1523            PcKind::ReplicatedFullIlu0 | PcKind::ReplicatedFullIluk { .. } => "full",
1524            PcKind::Ilu0Local | PcKind::IlutLocal | PcKind::LocalIluk { .. } => {
1525                if comm_size <= 1 {
1526                    "full"
1527                } else {
1528                    "own0"
1529                }
1530            }
1531            PcKind::MpiBlockJacobiIlu0Local => "own0",
1532            PcKind::AsmIlu0Overlap { overlap }
1533            | PcKind::RasIlu0Overlap { overlap }
1534            | PcKind::RasIlukOverlap { overlap, .. } => match overlap {
1535                1 => "own+ghost1",
1536                2 => "own+ghost2",
1537                _ => "own+ghostN",
1538            },
1539            PcKind::None | PcKind::JacobiWeak => "n/a",
1540        }
1541    }
1542
1543    fn pc_apply_label(pc: PcKind, problem: &Problem) -> &'static str {
1544        pc_apply_label_for_size(pc, problem.comm.size())
1545    }
1546
1547    fn pc_apply_label_for_size(pc: PcKind, comm_size: usize) -> &'static str {
1548        match pc {
1549            PcKind::ReplicatedFullIlu0 | PcKind::ReplicatedFullIluk { .. } => {
1550                if comm_size > 1 {
1551                    "replicated/full/allgather, not scalable"
1552                } else {
1553                    "replicated/full"
1554                }
1555            }
1556            PcKind::Ilu0Local
1557            | PcKind::IlutLocal
1558            | PcKind::MpiBlockJacobiIlu0Local
1559            | PcKind::LocalIluk { .. } => "owned-block/overlap0",
1560            PcKind::AsmIlu0Overlap { .. } => "asm/overlap-gather/owned-output",
1561            PcKind::RasIlu0Overlap { .. } | PcKind::RasIlukOverlap { .. } => {
1562                "ras/overlap-gather/restricted-owned"
1563            }
1564            PcKind::JacobiWeak => "jacobi/local",
1565            PcKind::None => "none",
1566        }
1567    }
1568
1569    const ILUT_REAL_PROJECTION_FALLBACK_LABEL: &str = "ILUT(real-projection fallback)";
1570
1571    impl PcKind {
1572        fn label(&self) -> String {
1573            match self {
1574                Self::None => "none (unpreconditioned reference)".to_string(),
1575                Self::JacobiWeak => "jacobi (weak baseline)".to_string(),
1576                Self::Ilu0Local => "block-jacobi-ilu0-overlap0".to_string(),
1577                Self::IlutLocal => {
1578                    "local ILUT(real-projection fallback) [degraded/provisional complex path: real-projection fallback; not trusted for complex robustness benchmarking]".to_string()
1579                }
1580                Self::MpiBlockJacobiIlu0Local => {
1581                    "block-jacobi-ilu0-overlap0 [mpi spelling alias]".to_string()
1582                }
1583                Self::AsmIlu0Overlap { overlap } => format!("asm-ilu0-overlap{overlap}"),
1584                Self::RasIlu0Overlap { overlap } => format!("ras-ilu0-overlap{overlap}"),
1585                Self::RasIlukOverlap { k, overlap } => format!("ras-iluk{k}-overlap{overlap}"),
1586                Self::LocalIluk { k } => format!("block-jacobi-iluk{k}-overlap0"),
1587                Self::ReplicatedFullIlu0 => {
1588                    "replicated full ILU(0) [correctness only, not scalable]".to_string()
1589                }
1590                Self::ReplicatedFullIluk { .. } => {
1591                    "replicated full ILU(k) [correctness only, not scalable]".to_string()
1592                }
1593            }
1594        }
1595
1596        fn dispatch_branch(self) -> PcDispatchBranch {
1597            match self {
1598                Self::None => PcDispatchBranch::None,
1599                Self::JacobiWeak => PcDispatchBranch::JacobiWeak,
1600                Self::Ilu0Local | Self::MpiBlockJacobiIlu0Local | Self::LocalIluk { .. } => {
1601                    PcDispatchBranch::Ilu0Local
1602                }
1603                Self::ReplicatedFullIlu0 | Self::ReplicatedFullIluk { .. } => {
1604                    PcDispatchBranch::ReplicatedFullIlu
1605                }
1606                Self::AsmIlu0Overlap { .. }
1607                | Self::RasIlu0Overlap { .. }
1608                | Self::RasIlukOverlap { .. } => PcDispatchBranch::OverlapIlu,
1609                Self::IlutLocal => PcDispatchBranch::IlutLocal,
1610            }
1611        }
1612
1613        fn explicit_alias_of(self) -> Option<Self> {
1614            match self {
1615                Self::MpiBlockJacobiIlu0Local => Some(Self::Ilu0Local),
1616                Self::None
1617                | Self::JacobiWeak
1618                | Self::Ilu0Local
1619                | Self::IlutLocal
1620                | Self::LocalIluk { .. }
1621                | Self::AsmIlu0Overlap { .. }
1622                | Self::RasIlu0Overlap { .. }
1623                | Self::RasIlukOverlap { .. }
1624                | Self::ReplicatedFullIlu0
1625                | Self::ReplicatedFullIluk { .. } => None,
1626            }
1627        }
1628
1629        fn semantic_experiment_key(self, mpi_mode: bool) -> String {
1630            match self {
1631                Self::MpiBlockJacobiIlu0Local | Self::Ilu0Local if mpi_mode => {
1632                    "block-jacobi-ilu0-overlap0".to_string()
1633                }
1634                Self::MpiBlockJacobiIlu0Local => "mpi-block-jacobi-ilu0-local".to_string(),
1635                Self::Ilu0Local => "local-ilu0".to_string(),
1636                Self::None => "none".to_string(),
1637                Self::JacobiWeak => "jacobi-weak".to_string(),
1638                Self::IlutLocal => "local-ilut-real-projection-fallback".to_string(),
1639                Self::LocalIluk { k } => format!("local-iluk:{k}"),
1640                Self::AsmIlu0Overlap { overlap } => format!("asm-ilu0-overlap{overlap}"),
1641                Self::RasIlu0Overlap { overlap } => format!("ras-ilu0-overlap{overlap}"),
1642                Self::RasIlukOverlap { k, overlap } => format!("ras-iluk{k}-overlap{overlap}"),
1643                Self::ReplicatedFullIlu0 => "replicated-ilu0".to_string(),
1644                Self::ReplicatedFullIluk { k } => format!("replicated-iluk:{k}"),
1645            }
1646        }
1647        fn is_ilut(self) -> bool {
1648            matches!(self, Self::IlutLocal)
1649        }
1650
1651        fn requires_flexible_right_preconditioning(self) -> bool {
1652            // Current demo PCs are fixed linear operators. Future variable/nonlinear
1653            // modes should return true here so run construction preserves FGMRES.
1654            false
1655        }
1656    }
1657
1658    fn variant_label(variant: FgmresVariant) -> &'static str {
1659        match variant {
1660            FgmresVariant::Classical => "classical",
1661            FgmresVariant::Pipelined => "pipelined",
1662        }
1663    }
1664
1665    fn plain_gmres_variant_label(variant: PlainGmresVariant) -> &'static str {
1666        match variant {
1667            PlainGmresVariant::Classical => "classical",
1668            PlainGmresVariant::Pipelined => "pipelined",
1669            PlainGmresVariant::SStep { .. } => "s-step",
1670        }
1671    }
1672
1673    fn dist_policy_reason(diag: &DistributedPlanDiagnostics, comm_size: usize) -> &'static str {
1674        let halo_volume = (diag.halo_recv_volume + diag.halo_send_volume) as f64;
1675        let overlap_enabled =
1676            diag.overlap_mode == kryst::matrix::dist_csr::HaloOverlapMode::Interior;
1677        let communication_heavy = comm_size > 1
1678            && (overlap_enabled
1679                || diag.expected_communication_fraction >= 0.55
1680                || halo_volume >= 4096.0);
1681
1682        if communication_heavy {
1683            "high halo/communication pressure detected"
1684        } else {
1685            "low halo pressure and compute-dominant local work"
1686        }
1687    }
1688
1689    fn format_dist_policy_report(
1690        cfg: &BenchmarkConfig,
1691        sample_spec: Option<&RunSpec>,
1692        problem: &Problem,
1693    ) -> String {
1694        match cfg.dist_policy {
1695            DistPolicyMode::Off => format!("DistCSR policy: {}", cfg.dist_policy.label()),
1696            DistPolicyMode::Auto => match sample_spec.map(|spec| spec.ksp).unwrap_or(cfg.ksp) {
1697                KspKind::Gmres => {
1698                    let solver = sample_spec
1699                        .map(|spec| configured_gmres_solver(spec, cfg))
1700                        .unwrap_or_else(|| GmresSolver::new(1, cfg.rtol, cfg.maxits));
1701                    format!(
1702                        "DistCSR policy: auto, KSP=GMRES, selected restart={}, variant={}, reason={}",
1703                        solver.restart,
1704                        plain_gmres_variant_label(solver.variant),
1705                        dist_policy_reason(&problem.dist_plan_diagnostics, problem.comm.size())
1706                    )
1707                }
1708                KspKind::Fgmres => {
1709                    let mut solver = sample_spec
1710                        .map(|spec| configured_fgmres_solver(spec, cfg))
1711                        .unwrap_or_else(|| FgmresSolver::new(cfg.rtol, cfg.maxits, 1));
1712                    apply_fgmres_dist_plan_policy(&mut solver, problem);
1713                    format!(
1714                        "DistCSR policy: auto, KSP=FGMRES, selected restart={}, variant={}, reason={}",
1715                        solver.restart,
1716                        variant_label(solver.variant),
1717                        dist_policy_reason(&problem.dist_plan_diagnostics, problem.comm.size())
1718                    )
1719                }
1720            },
1721        }
1722    }
1723
1724    fn orthog_label(orthog: OrthogMethod) -> &'static str {
1725        match orthog {
1726            OrthogMethod::ClassicalGS => "classical-gs",
1727            OrthogMethod::ModifiedGS => "modified-gs",
1728        }
1729    }
1730
1731    fn reorth_label(reorth: ReorthPolicy) -> &'static str {
1732        match reorth {
1733            ReorthPolicy::Never => "never",
1734            ReorthPolicy::IfNeeded => "if-needed",
1735            ReorthPolicy::Always => "always",
1736        }
1737    }
1738
1739    fn residual_check_policy_label(policy: ResidualCheckPolicy) -> &'static str {
1740        match policy {
1741            ResidualCheckPolicy::RestartOnly => "restart-only",
1742            ResidualCheckPolicy::OnConvergence => "on-convergence",
1743            ResidualCheckPolicy::EveryIteration => "every-iteration",
1744            ResidualCheckPolicy::Debug => "debug",
1745        }
1746    }
1747
1748    fn pc_side_label(pc_side: PcSide) -> &'static str {
1749        match pc_side {
1750            PcSide::Right => "right",
1751            PcSide::Left => "left",
1752            PcSide::Symmetric => "symmetric",
1753        }
1754    }
1755
1756    fn normalized_fgmres_side(requested_side: PcSide) -> PcSide {
1757        match requested_side {
1758            PcSide::Right => PcSide::Right,
1759            PcSide::Left | PcSide::Symmetric => PcSide::Right,
1760        }
1761    }
1762
1763    #[derive(Clone, Debug)]
1764    struct ResidualHistoryEntry {
1765        iter: usize,
1766        recurrence_residual: R,
1767        true_residual: Option<R>,
1768        checkpoint: bool,
1769    }
1770
1771    #[derive(Default)]
1772    struct RunResidualHistory {
1773        entries: Vec<ResidualHistoryEntry>,
1774    }
1775
1776    #[derive(Clone, Debug)]
1777    struct GlobalReferenceCheck {
1778        true_residual: R,
1779        true_residual_rel: R,
1780        x_error_rel: Option<R>,
1781    }
1782
1783    struct RowScaledOp {
1784        base: Arc<dyn KLinOp<Scalar = S>>,
1785        d: Vec<R>,
1786    }
1787
1788    impl KLinOp for RowScaledOp {
1789        type Scalar = S;
1790
1791        fn dims(&self) -> (usize, usize) {
1792            self.base.dims()
1793        }
1794
1795        fn matvec_s(&self, x: &[S], y: &mut [S], scratch: &mut BridgeScratch) {
1796            self.base.matvec_s(x, y, scratch);
1797            for (yi, di) in y.iter_mut().zip(self.d.iter().copied()) {
1798                *yi *= S::from_real(di);
1799            }
1800        }
1801
1802        fn supports_t_matvec_s(&self) -> bool {
1803            self.base.supports_t_matvec_s()
1804        }
1805
1806        fn t_matvec_s(&self, x: &[S], y: &mut [S], scratch: &mut BridgeScratch) {
1807            self.base.t_matvec_s(x, y, scratch);
1808        }
1809    }
1810
1811    fn compute_row_scaling(matrix: &SparseCsrMatrix<S>, tiny: f64) -> Vec<R> {
1812        let row_ptr = matrix.row_ptr();
1813        let vals = matrix.values();
1814        (0..matrix.nrows())
1815            .map(|r| {
1816                let mut row_inf = 0.0f64;
1817                for nz in row_ptr[r]..row_ptr[r + 1] {
1818                    row_inf = row_inf.max(vals[nz].abs());
1819                }
1820                if row_inf > tiny { 1.0 / row_inf } else { 1.0 }
1821            })
1822            .collect()
1823    }
1824
1825    fn scale_csr_rows(matrix: &SparseCsrMatrix<S>, row_scale: &[R]) -> SparseCsrMatrix<S> {
1826        debug_assert_eq!(matrix.nrows(), row_scale.len());
1827        let row_ptr = matrix.row_ptr().to_vec();
1828        let col_idx = matrix.col_idx().to_vec();
1829        let mut values = matrix.values().to_vec();
1830        for r in 0..matrix.nrows() {
1831            let d = S::from_real(row_scale[r]);
1832            for nz in row_ptr[r]..row_ptr[r + 1] {
1833                values[nz] *= d;
1834            }
1835        }
1836        SparseCsrMatrix::from_csr(matrix.nrows(), matrix.ncols(), row_ptr, col_idx, values)
1837    }
1838
1839    fn validate_replicated_full_apply_layout(
1840        x_local: &[S],
1841        y_local: &[S],
1842        global_n: usize,
1843        global_row_start: usize,
1844        local_n: usize,
1845    ) -> Result<(), KError> {
1846        if x_local.len() != local_n {
1847            return Err(KError::InvalidInput(format!(
1848                "replicated ILU apply expected x_local length {local_n}, got {}",
1849                x_local.len()
1850            )));
1851        }
1852        if y_local.len() != local_n {
1853            return Err(KError::InvalidInput(format!(
1854                "replicated ILU apply expected y_local length {local_n}, got {}",
1855                y_local.len()
1856            )));
1857        }
1858        if global_row_start > global_n || local_n > global_n - global_row_start {
1859            return Err(KError::InvalidInput(format!(
1860                "replicated ILU apply invalid owned segment: global_n={global_n}, \
1861                 global_row_start={global_row_start}, local_n={local_n}"
1862            )));
1863        }
1864        Ok(())
1865    }
1866
1867    fn allgather_owned_segment_in_rank_order(
1868        comm: &UniverseComm,
1869        x_local: &[S],
1870        global_n: usize,
1871        global_row_start: usize,
1872        local_n: usize,
1873        x_global: &mut [S],
1874    ) -> Result<(), KError> {
1875        validate_replicated_full_apply_layout(
1876            x_local,
1877            &vec![S::zero(); local_n],
1878            global_n,
1879            global_row_start,
1880            local_n,
1881        )?;
1882        if x_global.len() != global_n {
1883            return Err(KError::InvalidInput(format!(
1884                "replicated ILU apply expected x_global scratch length {global_n}, got {}",
1885                x_global.len()
1886            )));
1887        }
1888
1889        x_global.fill(S::zero());
1890
1891        #[cfg(feature = "mpi")]
1892        {
1893            use mpi::traits::*;
1894
1895            if let UniverseComm::Mpi(comm_impl) = comm {
1896                let rank = comm_impl.rank;
1897                let size = comm_impl.size;
1898                let local_len = i32::try_from(local_n).map_err(|_| {
1899                    KError::InvalidInput(format!(
1900                        "replicated ILU apply local_n={local_n} exceeds MPI i32 length limit"
1901                    ))
1902                })?;
1903                let mut lengths = vec![0i32; size];
1904                comm_impl
1905                    .world
1906                    .all_gather_into(&local_len, &mut lengths[..]);
1907
1908                let mut offset = 0usize;
1909                for (r, &len_i32) in lengths.iter().enumerate() {
1910                    if len_i32 < 0 {
1911                        return Err(KError::InvalidInput(format!(
1912                            "replicated ILU apply received negative segment length from rank {r}"
1913                        )));
1914                    }
1915                    let len = len_i32 as usize;
1916                    if r == rank {
1917                        if len != x_local.len() || len != local_n {
1918                            return Err(KError::InvalidInput(format!(
1919                                "replicated ILU apply local length mismatch: gathered={len}, \
1920                                 x_local={}, local_n={local_n}",
1921                                x_local.len()
1922                            )));
1923                        }
1924                        if offset != global_row_start {
1925                            return Err(KError::InvalidInput(format!(
1926                                "replicated ILU apply offset mismatch: gathered prefix={offset}, \
1927                                 global_row_start={global_row_start}"
1928                            )));
1929                        }
1930                    }
1931                    offset = offset.checked_add(len).ok_or_else(|| {
1932                        KError::InvalidInput(
1933                            "replicated ILU apply segment lengths overflowed usize".into(),
1934                        )
1935                    })?;
1936                }
1937                if offset != global_n {
1938                    return Err(KError::InvalidInput(format!(
1939                        "replicated ILU apply expected global_n={global_n}, gathered owned lengths sum={offset}"
1940                    )));
1941                }
1942
1943                let max_len = lengths.iter().copied().max().unwrap_or(0) as usize;
1944                let mut send = vec![0.0f64; 2 * max_len];
1945                for (i, value) in x_local.iter().copied().enumerate() {
1946                    send[2 * i] = value.real();
1947                    send[2 * i + 1] = value.imag();
1948                }
1949                let mut gathered = vec![0.0f64; 2 * max_len * size];
1950                if max_len > 0 {
1951                    comm_impl
1952                        .world
1953                        .all_gather_into(&send[..], &mut gathered[..]);
1954                }
1955
1956                let mut offset = 0usize;
1957                for (r, &len_i32) in lengths.iter().enumerate() {
1958                    let len = len_i32 as usize;
1959                    let start = 2 * r * max_len;
1960                    for i in 0..len {
1961                        x_global[offset + i] =
1962                            S::from_parts(gathered[start + 2 * i], gathered[start + 2 * i + 1]);
1963                    }
1964                    offset += len;
1965                }
1966                return Ok(());
1967            }
1968        }
1969
1970        if comm.size() != 1 || global_row_start != 0 || local_n != global_n {
1971            return Err(KError::InvalidInput(
1972                "replicated ILU apply requires MPI allgather for distributed owned segments".into(),
1973            ));
1974        }
1975        x_global.copy_from_slice(x_local);
1976        Ok(())
1977    }
1978
1979    fn gather_global_reference_vector(
1980        comm: &UniverseComm,
1981        local: &[S],
1982        global_n: usize,
1983        global_row_start: usize,
1984        local_n: usize,
1985        global: &mut [S],
1986    ) -> Result<(), KError> {
1987        if local.len() != local_n {
1988            return Err(KError::InvalidInput(format!(
1989                "global reference gather expected local length {local_n}, got {}",
1990                local.len()
1991            )));
1992        }
1993        if global.len() != global_n {
1994            return Err(KError::InvalidInput(format!(
1995                "global reference gather expected global scratch length {global_n}, got {}",
1996                global.len()
1997            )));
1998        }
1999        if global_row_start > global_n || local_n > global_n - global_row_start {
2000            return Err(KError::InvalidInput(format!(
2001                "global reference gather invalid owned segment: global_n={global_n}, \
2002                 global_row_start={global_row_start}, local_n={local_n}"
2003            )));
2004        }
2005
2006        global.fill(S::zero());
2007
2008        if comm.size() == 1 {
2009            if global_row_start != 0 || local_n != global_n {
2010                return Err(KError::InvalidInput(format!(
2011                    "single-rank global reference gather expected ownership [0..{global_n}), got [{global_row_start}..{})",
2012                    global_row_start + local_n
2013                )));
2014            }
2015            global.copy_from_slice(local);
2016            return Ok(());
2017        }
2018
2019        #[cfg(feature = "mpi")]
2020        {
2021            use mpi::traits::*;
2022
2023            if let UniverseComm::Mpi(comm_impl) = comm {
2024                let rank = comm_impl.rank;
2025                let size = comm_impl.size;
2026                let local_len = i32::try_from(local_n).map_err(|_| {
2027                    KError::InvalidInput(format!(
2028                        "global reference gather local_n={local_n} exceeds MPI i32 length limit"
2029                    ))
2030                })?;
2031                let mut lengths = vec![0i32; size];
2032                comm_impl
2033                    .world
2034                    .all_gather_into(&local_len, &mut lengths[..]);
2035
2036                let expected_part = DistCsrOp::partition_rows_balanced(global_n, comm);
2037                let mut offset = 0usize;
2038                for (r, &len_i32) in lengths.iter().enumerate() {
2039                    if len_i32 < 0 {
2040                        return Err(KError::InvalidInput(format!(
2041                            "global reference gather received negative segment length from rank {r}"
2042                        )));
2043                    }
2044                    let len = len_i32 as usize;
2045                    let expected_len = expected_part[r + 1] - expected_part[r];
2046                    if len != expected_len {
2047                        return Err(KError::InvalidInput(format!(
2048                            "global reference gather rank {r} length mismatch: gathered={len}, expected balanced={expected_len}"
2049                        )));
2050                    }
2051                    if r == rank {
2052                        if len != local.len() || len != local_n {
2053                            return Err(KError::InvalidInput(format!(
2054                                "global reference gather local length mismatch: gathered={len}, \
2055                                 local={}, local_n={local_n}",
2056                                local.len()
2057                            )));
2058                        }
2059                        if offset != global_row_start || expected_part[r] != global_row_start {
2060                            return Err(KError::InvalidInput(format!(
2061                                "global reference gather offset mismatch: gathered prefix={offset}, \
2062                                 expected balanced={}, global_row_start={global_row_start}",
2063                                expected_part[r]
2064                            )));
2065                        }
2066                    }
2067                    offset = offset.checked_add(len).ok_or_else(|| {
2068                        KError::InvalidInput(
2069                            "global reference gather segment lengths overflowed usize".into(),
2070                        )
2071                    })?;
2072                }
2073                if offset != global_n {
2074                    return Err(KError::InvalidInput(format!(
2075                        "global reference gather expected global_n={global_n}, gathered owned lengths sum={offset}"
2076                    )));
2077                }
2078
2079                let max_len = lengths.iter().copied().max().unwrap_or(0) as usize;
2080                let mut send = vec![0.0f64; 2 * max_len];
2081                for (i, value) in local.iter().copied().enumerate() {
2082                    send[2 * i] = value.real();
2083                    send[2 * i + 1] = value.imag();
2084                }
2085                let mut gathered = vec![0.0f64; 2 * max_len * size];
2086                if max_len > 0 {
2087                    comm_impl
2088                        .world
2089                        .all_gather_into(&send[..], &mut gathered[..]);
2090                }
2091
2092                let mut offset = 0usize;
2093                for (r, &len_i32) in lengths.iter().enumerate() {
2094                    let len = len_i32 as usize;
2095                    let start = 2 * r * max_len;
2096                    for i in 0..len {
2097                        global[offset + i] =
2098                            S::from_parts(gathered[start + 2 * i], gathered[start + 2 * i + 1]);
2099                    }
2100                    offset += len;
2101                }
2102                return Ok(());
2103            }
2104        }
2105
2106        Err(KError::InvalidInput(
2107            "global reference gather requires MPI for multi-rank owned segments".into(),
2108        ))
2109    }
2110
2111    fn global_reference_residual(
2112        problem: &Problem,
2113        x_local: &[S],
2114        b_local: &[S],
2115    ) -> Result<GlobalReferenceCheck, KError> {
2116        if problem.global_csr.ncols() != problem.global_n {
2117            return Err(KError::InvalidInput(format!(
2118                "global reference residual requires square row-owned solution layout, got matrix {}x{} with global_n={}",
2119                problem.global_csr.nrows(),
2120                problem.global_csr.ncols(),
2121                problem.global_n
2122            )));
2123        }
2124
2125        let mut x_global = vec![S::zero(); problem.global_n];
2126        let mut b_global = vec![S::zero(); problem.global_n];
2127        gather_global_reference_vector(
2128            &problem.comm,
2129            x_local,
2130            problem.global_n,
2131            problem.global_row_start,
2132            problem.local_n,
2133            &mut x_global,
2134        )?;
2135        gather_global_reference_vector(
2136            &problem.comm,
2137            b_local,
2138            problem.global_n,
2139            problem.global_row_start,
2140            problem.local_n,
2141            &mut b_global,
2142        )?;
2143
2144        let mut ax_global = vec![S::zero(); problem.global_csr.nrows()];
2145        problem.global_csr.spmv(&x_global, &mut ax_global);
2146        let true_global = b_global
2147            .iter()
2148            .zip(ax_global.iter())
2149            .map(|(bi, ai)| (*bi - *ai).abs2())
2150            .sum::<f64>()
2151            .sqrt();
2152        let b_norm = b_global.iter().map(|bi| bi.abs2()).sum::<f64>().sqrt();
2153        let true_global_rel = true_global / b_norm.max(f64::MIN_POSITIVE);
2154        let x_error_rel = if problem.rhs_source == RhsSource::GeneratedAOnes
2155            && problem.solution_reference.valid_for_x_error()
2156        {
2157            let err = x_global
2158                .iter()
2159                .map(|xi| (*xi - S::one()).abs2())
2160                .sum::<f64>()
2161                .sqrt();
2162            let one_norm = x_global.iter().map(|_| S::one().abs2()).sum::<f64>().sqrt();
2163            Some(err / one_norm.max(f64::MIN_POSITIVE))
2164        } else {
2165            None
2166        };
2167
2168        Ok(GlobalReferenceCheck {
2169            true_residual: true_global,
2170            true_residual_rel: true_global_rel,
2171            x_error_rel,
2172        })
2173    }
2174
2175    fn apply_replicated_full_ilu_owned_segment(
2176        ilu: &IluCsr,
2177        comm: &UniverseComm,
2178        side: PcSide,
2179        x_local: &[S],
2180        y_local: &mut [S],
2181        scratch: &mut BridgeScratch,
2182        global_n: usize,
2183        global_row_start: usize,
2184        local_n: usize,
2185        scratch_in: &std::sync::Mutex<Vec<S>>,
2186        scratch_out: &std::sync::Mutex<Vec<S>>,
2187    ) -> Result<(), KError> {
2188        validate_replicated_full_apply_layout(
2189            x_local,
2190            y_local,
2191            global_n,
2192            global_row_start,
2193            local_n,
2194        )?;
2195
2196        let mut in_full = scratch_in.lock().map_err(|_| {
2197            KError::InvalidInput("failed to lock replicated ILU input scratch".into())
2198        })?;
2199        let mut out_full = scratch_out.lock().map_err(|_| {
2200            KError::InvalidInput("failed to lock replicated ILU output scratch".into())
2201        })?;
2202        if out_full.len() != global_n {
2203            return Err(KError::InvalidInput(format!(
2204                "replicated ILU apply expected output scratch length {global_n}, got {}",
2205                out_full.len()
2206            )));
2207        }
2208
2209        allgather_owned_segment_in_rank_order(
2210            comm,
2211            x_local,
2212            global_n,
2213            global_row_start,
2214            local_n,
2215            &mut in_full,
2216        )?;
2217        out_full.fill(S::zero());
2218        ilu.apply_s(side, &in_full, &mut out_full, scratch)?;
2219        y_local.copy_from_slice(&out_full[global_row_start..global_row_start + local_n]);
2220        Ok(())
2221    }
2222
2223    enum PcHandle {
2224        Jacobi(Jacobi),
2225        Ilu0(IluCsr),
2226        MpiBlockJacobiIlu0(IluCsr),
2227        OverlapIlu(OverlapIluPc),
2228        ReplicatedFull {
2229            ilu: IluCsr,
2230            comm: UniverseComm,
2231            global_n: usize,
2232            global_row_start: usize,
2233            local_n: usize,
2234            scratch_in: std::sync::Mutex<Vec<S>>,
2235            scratch_out: std::sync::Mutex<Vec<S>>,
2236        },
2237    }
2238
2239    impl PcHandle {
2240        fn as_kpc_mut(&mut self) -> &mut dyn KPreconditioner<Scalar = S> {
2241            match self {
2242                Self::Jacobi(pc) => pc,
2243                Self::Ilu0(pc) => pc,
2244                Self::MpiBlockJacobiIlu0(pc) => pc,
2245                Self::OverlapIlu(pc) => pc,
2246                Self::ReplicatedFull { .. } => self,
2247            }
2248        }
2249    }
2250    impl KPreconditioner for PcHandle {
2251        type Scalar = S;
2252        fn dims(&self) -> (usize, usize) {
2253            match self {
2254                Self::Jacobi(pc) => KPreconditioner::dims(pc),
2255                Self::Ilu0(pc) => KPreconditioner::dims(pc),
2256                Self::MpiBlockJacobiIlu0(pc) => KPreconditioner::dims(pc),
2257                Self::OverlapIlu(pc) => KPreconditioner::dims(pc),
2258                Self::ReplicatedFull { global_n, .. } => (*global_n, *global_n),
2259            }
2260        }
2261        fn apply_s(
2262            &self,
2263            side: PcSide,
2264            x: &[S],
2265            y: &mut [S],
2266            scratch: &mut BridgeScratch,
2267        ) -> Result<(), KError> {
2268            match self {
2269                Self::Jacobi(pc) => pc.apply_s(side, x, y, scratch),
2270                Self::Ilu0(pc) => pc.apply_s(side, x, y, scratch),
2271                Self::MpiBlockJacobiIlu0(pc) => pc.apply_s(side, x, y, scratch),
2272                Self::OverlapIlu(pc) => pc.apply_s(side, x, y, scratch),
2273                Self::ReplicatedFull {
2274                    ilu,
2275                    comm,
2276                    global_n,
2277                    global_row_start,
2278                    local_n,
2279                    scratch_in,
2280                    scratch_out,
2281                } => apply_replicated_full_ilu_owned_segment(
2282                    ilu,
2283                    comm,
2284                    side,
2285                    x,
2286                    y,
2287                    scratch,
2288                    *global_n,
2289                    *global_row_start,
2290                    *local_n,
2291                    scratch_in,
2292                    scratch_out,
2293                ),
2294            }
2295        }
2296    }
2297
2298    fn setup_preconditioner_for_run_once(
2299        problem: &Problem,
2300        spec: &RunSpec,
2301        bench_cfg: &BenchmarkConfig,
2302        pc_csr: &Arc<SparseCsrMatrix<S>>,
2303        global_pc_csr: &Arc<SparseCsrMatrix<S>>,
2304        csr_pc_diag: &CsrForPcDiagnostics,
2305    ) -> Result<Option<PcHandle>, KError> {
2306        let mut pc: Option<PcHandle> = None;
2307        match spec.pc.dispatch_branch() {
2308            PcDispatchBranch::None => {}
2309            PcDispatchBranch::JacobiWeak => {
2310                let mut jacobi = Jacobi::new();
2311                jacobi.setup(pc_csr.as_ref())?;
2312                pc = Some(PcHandle::Jacobi(jacobi));
2313            }
2314            PcDispatchBranch::Ilu0Local => {
2315                validate_local_ilu_owned_block(pc_csr.as_ref())?;
2316                let mut cfg = IluCsrConfig::default();
2317                cfg.kind = match spec.pc {
2318                    PcKind::Ilu0Local | PcKind::MpiBlockJacobiIlu0Local => IluKind::Ilu0,
2319                    PcKind::LocalIluk { k } => IluKind::Iluk { k },
2320                    PcKind::AsmIlu0Overlap { .. }
2321                    | PcKind::RasIlu0Overlap { .. }
2322                    | PcKind::RasIlukOverlap { .. }
2323                    | PcKind::IlutLocal
2324                    | PcKind::ReplicatedFullIlu0
2325                    | PcKind::ReplicatedFullIluk { .. }
2326                    | PcKind::None
2327                    | PcKind::JacobiWeak => unreachable!(),
2328                };
2329                cfg.reordering = bench_cfg.ilu_reordering.clone();
2330                let mut ilu = IluCsr::new_with_config(cfg);
2331                ilu.setup(pc_csr.as_ref())?;
2332                if bench_cfg.run_mode == RunMode::Correctness {
2333                    let pivot_perturbation_count = None::<usize>;
2334                    let diag_global = reduce_csr_for_pc_diagnostics(
2335                        &problem.comm,
2336                        &csr_pc_diag,
2337                        pivot_perturbation_count,
2338                    );
2339                    if problem.comm.rank() == 0 {
2340                        print_csr_for_pc_diagnostics(&spec.method_label(), &diag_global);
2341                    }
2342                }
2343                pc = Some(match spec.pc {
2344                    PcKind::Ilu0Local | PcKind::LocalIluk { .. } => PcHandle::Ilu0(ilu),
2345                    PcKind::MpiBlockJacobiIlu0Local => PcHandle::MpiBlockJacobiIlu0(ilu),
2346                    PcKind::AsmIlu0Overlap { .. }
2347                    | PcKind::RasIlu0Overlap { .. }
2348                    | PcKind::RasIlukOverlap { .. }
2349                    | PcKind::IlutLocal
2350                    | PcKind::ReplicatedFullIlu0
2351                    | PcKind::ReplicatedFullIluk { .. }
2352                    | PcKind::None
2353                    | PcKind::JacobiWeak => unreachable!(),
2354                });
2355            }
2356            PcDispatchBranch::ReplicatedFullIlu => {
2357                let mut cfg = IluCsrConfig::default();
2358                cfg.kind = match spec.pc {
2359                    PcKind::ReplicatedFullIlu0 => IluKind::Ilu0,
2360                    PcKind::ReplicatedFullIluk { k } => IluKind::Iluk { k },
2361                    _ => unreachable!(),
2362                };
2363                cfg.reordering = bench_cfg.ilu_reordering.clone();
2364                let mut ilu = IluCsr::new_with_config(cfg);
2365                ilu.setup(global_pc_csr.as_ref())?;
2366                if bench_cfg.run_mode == RunMode::Correctness {
2367                    let pivot_perturbation_count = None::<usize>;
2368                    let diag_global = reduce_csr_for_pc_diagnostics(
2369                        &problem.comm,
2370                        &csr_pc_diag,
2371                        pivot_perturbation_count,
2372                    );
2373                    if problem.comm.rank() == 0 {
2374                        print_csr_for_pc_diagnostics(&spec.method_label(), &diag_global);
2375                    }
2376                }
2377                pc = Some(PcHandle::ReplicatedFull {
2378                    ilu,
2379                    comm: problem.comm.clone(),
2380                    global_n: problem.global_n,
2381                    global_row_start: problem.global_row_start,
2382                    local_n: problem.local_n,
2383                    scratch_in: std::sync::Mutex::new(vec![S::zero(); problem.global_n]),
2384                    scratch_out: std::sync::Mutex::new(vec![S::zero(); problem.global_n]),
2385                });
2386            }
2387            PcDispatchBranch::OverlapIlu => {
2388                let (kind, overlap, restriction) = match spec.pc {
2389                    PcKind::AsmIlu0Overlap { overlap } => {
2390                        (IluKind::Ilu0, overlap, OverlapRestriction::Asm)
2391                    }
2392                    PcKind::RasIlu0Overlap { overlap } => {
2393                        (IluKind::Ilu0, overlap, OverlapRestriction::Ras)
2394                    }
2395                    PcKind::RasIlukOverlap { k, overlap } => {
2396                        (IluKind::Iluk { k }, overlap, OverlapRestriction::Ras)
2397                    }
2398                    _ => unreachable!(),
2399                };
2400                let mut cfg = IluCsrConfig::default();
2401                cfg.reordering = bench_cfg.ilu_reordering.clone();
2402                let pc_overlap = OverlapIluPc::setup_from_global_csr(
2403                    global_pc_csr.as_ref(),
2404                    problem.comm.clone(),
2405                    problem.global_row_start,
2406                    problem.global_row_start + problem.local_n,
2407                    overlap,
2408                    kind,
2409                    cfg,
2410                    restriction,
2411                )?;
2412                pc = Some(PcHandle::OverlapIlu(pc_overlap));
2413            }
2414            PcDispatchBranch::IlutLocal => {
2415                validate_local_ilu_owned_block(pc_csr.as_ref())?;
2416                let mut cfg = IluCsrConfig::default();
2417                cfg.kind = IluKind::Ilut {
2418                    params: Default::default(),
2419                };
2420                cfg.reordering = bench_cfg.ilu_reordering.clone();
2421                let mut ilu = IluCsr::new_with_config(cfg);
2422                ilu.setup(pc_csr.as_ref())?;
2423                if bench_cfg.run_mode == RunMode::Correctness {
2424                    let pivot_perturbation_count = None::<usize>;
2425                    let diag_global = reduce_csr_for_pc_diagnostics(
2426                        &problem.comm,
2427                        &csr_pc_diag,
2428                        pivot_perturbation_count,
2429                    );
2430                    if problem.comm.rank() == 0 {
2431                        print_csr_for_pc_diagnostics(&spec.method_label(), &diag_global);
2432                    }
2433                }
2434                pc = Some(PcHandle::Ilu0(ilu));
2435            }
2436        }
2437        Ok(pc)
2438    }
2439
2440    fn run_once(
2441        problem: &Problem,
2442        spec: &RunSpec,
2443        bench_cfg: &BenchmarkConfig,
2444    ) -> Result<ResultRow, KError> {
2445        let b_unscaled = &problem.rhs;
2446        let (row_scaling, op_scaled): (Option<Vec<R>>, Arc<dyn KLinOp<Scalar = S>>) =
2447            if bench_cfg.row_scale {
2448                let d = compute_row_scaling(problem.csr_for_pc.as_ref(), bench_cfg.row_scale_tiny);
2449                (
2450                    Some(d.clone()),
2451                    Arc::new(RowScaledOp {
2452                        base: problem.op.clone(),
2453                        d,
2454                    }),
2455                )
2456            } else {
2457                (None, problem.op.clone())
2458            };
2459        let pc_csr: Arc<SparseCsrMatrix<S>> = if bench_cfg.row_scale {
2460            let row_scale = row_scaling.as_ref().ok_or_else(|| {
2461                KError::InvalidInput("row scaling requested but factors were not computed".into())
2462            })?;
2463            Arc::new(scale_csr_rows(problem.csr_for_pc.as_ref(), row_scale))
2464        } else {
2465            problem.csr_for_pc.clone()
2466        };
2467        let global_pc_csr: Arc<SparseCsrMatrix<S>> = if bench_cfg.row_scale {
2468            let row_scale = row_scaling.as_ref().ok_or_else(|| {
2469                KError::InvalidInput("row scaling requested but factors were not computed".into())
2470            })?;
2471            Arc::new(scale_csr_rows(problem.global_csr.as_ref(), row_scale))
2472        } else {
2473            problem.global_csr.clone()
2474        };
2475        let b_scaled: Vec<S> = if let Some(d) = &row_scaling {
2476            b_unscaled
2477                .iter()
2478                .zip(d.iter())
2479                .map(|(bi, di)| *bi * S::from_real(*di))
2480                .collect()
2481        } else {
2482            b_unscaled.clone()
2483        };
2484        let b = &b_scaled;
2485        let effective_pc_side = normalized_fgmres_side(spec.pc_side);
2486        let csr_pc_diag = csr_for_pc_diagnostics(
2487            pc_csr.as_ref(),
2488            problem.local_rows_nnz,
2489            problem.zero_global_rows_local,
2490        );
2491        let setup_start = Instant::now();
2492        let mut pc = setup_preconditioner_for_run_once(
2493            problem,
2494            spec,
2495            bench_cfg,
2496            &pc_csr,
2497            &global_pc_csr,
2498            &csr_pc_diag,
2499        )?;
2500        let setup_secs = setup_start.elapsed().as_secs_f64();
2501        for _ in 0..bench_cfg.warmup_runs {
2502            let mut x = vec![S::zero(); problem.local_n];
2503            let mut workspace = Workspace::new(problem.local_n);
2504            let _ = solve_with_selected_ksp(
2505                spec,
2506                bench_cfg,
2507                problem,
2508                op_scaled.as_ref(),
2509                pc.as_mut().map(PcHandle::as_kpc_mut),
2510                b,
2511                &mut x,
2512                effective_pc_side,
2513                None,
2514                Some(&mut workspace),
2515            )?;
2516        }
2517
2518        let mut solve_times = Vec::with_capacity(bench_cfg.measured_runs);
2519        let mut x_last = vec![S::zero(); problem.local_n];
2520        let mut final_stats = None;
2521        let mut residual_history_last = RunResidualHistory::default();
2522        for _ in 0..bench_cfg.measured_runs {
2523            let mut x = vec![S::zero(); problem.local_n];
2524            problem.comm.barrier();
2525            let start = Instant::now();
2526            let mut workspace = Workspace::new(problem.local_n);
2527            let mut run_history = RunResidualHistory::default();
2528            let mut monitors: Vec<Box<MonitorCallback<R>>> = Vec::new();
2529            if bench_cfg.residual_history {
2530                monitors.push(Box::new(|it, res, _| {
2531                    let _ = (it, res);
2532                    MonitorAction::Continue
2533                }));
2534            }
2535            let history_ref = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(usize, R)>::new()));
2536            if bench_cfg.residual_history {
2537                let history_ref_c = history_ref.clone();
2538                monitors.clear();
2539                monitors.push(Box::new(move |it, res, _| {
2540                    if let Ok(mut h) = history_ref_c.lock() {
2541                        h.push((it, res));
2542                    }
2543                    MonitorAction::Continue
2544                }));
2545            }
2546            let stats = solve_with_selected_ksp(
2547                spec,
2548                bench_cfg,
2549                problem,
2550                op_scaled.as_ref(),
2551                pc.as_mut().map(PcHandle::as_kpc_mut),
2552                b,
2553                &mut x,
2554                effective_pc_side,
2555                if monitors.is_empty() {
2556                    None
2557                } else {
2558                    Some(&monitors)
2559                },
2560                Some(&mut workspace),
2561            )?;
2562            if bench_cfg.residual_history {
2563                if let Ok(h) = history_ref.lock() {
2564                    run_history.entries = h
2565                        .iter()
2566                        .map(|(it, res)| ResidualHistoryEntry {
2567                            iter: *it,
2568                            recurrence_residual: *res,
2569                            true_residual: None,
2570                            checkpoint: false,
2571                        })
2572                        .collect();
2573                }
2574                residual_history_last = run_history;
2575            }
2576            problem.comm.barrier();
2577            let solve_secs = start.elapsed().as_secs_f64();
2578            solve_times.push(solve_secs);
2579            x_last = x;
2580            final_stats = Some(stats);
2581        }
2582        let stats = final_stats
2583            .ok_or_else(|| KError::InvalidInput("no measured solve run executed".into()))?;
2584        // Row scaling changes only equations (D_r A x = D_r b), so x is unchanged.
2585        // Keep an explicit "map-back" step so optional future column scaling can hook here.
2586        let x_unscaled = x_last;
2587        let min_solve_secs = solve_times.iter().copied().fold(f64::INFINITY, f64::min);
2588        let median_solve_secs = median(&mut solve_times);
2589
2590        let reductions = stats.counters.num_global_reductions;
2591        let overlapped_reduction_waits = stats.counters.overlap_global_reductions;
2592        let model_predicted_reductions = stats
2593            .reduction_model
2594            .as_ref()
2595            .map(|model| model.estimate_total(stats.iterations));
2596        let (explicit_true_residual, explicit_true_residual_rel) =
2597            if bench_cfg.run_mode == RunMode::Correctness {
2598                let mut ax = vec![S::zero(); b.len()];
2599                let mut scratch = BridgeScratch::default();
2600                problem.op.matvec_s(&x_unscaled, &mut ax, &mut scratch);
2601                for (ri, bi) in ax.iter_mut().zip(b_unscaled.iter().copied()) {
2602                    *ri = bi - *ri;
2603                }
2604                let r2_local = ax.iter().map(|v| v.abs2()).sum::<f64>();
2605                let true_res = problem.comm.all_reduce_f64(r2_local).sqrt();
2606                let rhs_norm2_local = b_unscaled.iter().map(|v| v.abs2()).sum::<f64>();
2607                let rhs_norm = problem.comm.all_reduce_f64(rhs_norm2_local).sqrt();
2608                let rel_true = true_res / rhs_norm.max(f64::MIN_POSITIVE);
2609                (Some(true_res), Some(rel_true))
2610            } else {
2611                (None, None)
2612            };
2613        if bench_cfg.residual_history && bench_cfg.measured_runs > 0 && problem.comm.rank() == 0 {
2614            let mut checkpoint_count = 0usize;
2615            // mark restart boundaries based on effective restart interval and policy
2616            let restart = stats.effective_restart.unwrap_or(spec.restart).max(1);
2617            for e in &mut residual_history_last.entries {
2618                if e.iter > 0 && e.iter % restart == 0 {
2619                    e.checkpoint = true;
2620                    if matches!(
2621                        spec.residual_check_policy,
2622                        ResidualCheckPolicy::RestartOnly
2623                            | ResidualCheckPolicy::OnConvergence
2624                            | ResidualCheckPolicy::EveryIteration
2625                            | ResidualCheckPolicy::Debug
2626                    ) {
2627                        e.true_residual = explicit_true_residual;
2628                    }
2629                    checkpoint_count += 1;
2630                }
2631            }
2632            println!(
2633                "[history][rank0] {}: {} points, {} restart checkpoints",
2634                format!(
2635                    "{} [row-scale={}]",
2636                    spec.method_label(),
2637                    if bench_cfg.row_scale { "on" } else { "off" }
2638                ),
2639                residual_history_last.entries.len(),
2640                checkpoint_count
2641            );
2642            if let Some(path) = &bench_cfg.residual_history_file {
2643                dump_residual_history(path, &residual_history_last)?;
2644                println!("[history][rank0] wrote {}", path.display());
2645            }
2646        }
2647
2648        let x_error_rel = if bench_cfg.run_mode == RunMode::Correctness
2649            && problem.rhs_source == RhsSource::GeneratedAOnes
2650            && problem.solution_reference.valid_for_x_error()
2651        {
2652            let err2_local = x_unscaled
2653                .iter()
2654                .map(|xi| (*xi - S::one()).abs2())
2655                .sum::<f64>();
2656            let one2_local = x_unscaled.iter().map(|_| S::one().abs2()).sum::<f64>();
2657            let err = problem.comm.all_reduce_f64(err2_local).sqrt();
2658            let one_norm = problem.comm.all_reduce_f64(one2_local).sqrt();
2659            Some(err / one_norm.max(f64::MIN_POSITIVE))
2660        } else {
2661            None
2662        };
2663        let global_reference_check = if bench_cfg.run_mode == RunMode::Correctness {
2664            Some(global_reference_residual(problem, &x_unscaled, b_unscaled)?)
2665        } else {
2666            None
2667        };
2668        let verdict_tol = correctness_verdict_tolerance(bench_cfg);
2669        let dist_ok = explicit_true_residual_rel.map(|rel| rel <= verdict_tol);
2670        let global_ok = global_reference_check
2671            .as_ref()
2672            .map(|check| check.true_residual_rel <= verdict_tol);
2673        let x_ok = if bench_cfg.run_mode == RunMode::Correctness
2674            && problem.rhs_source == RhsSource::GeneratedAOnes
2675            && problem.solution_reference.valid_for_x_error()
2676        {
2677            global_reference_check
2678                .as_ref()
2679                .and_then(|check| check.x_error_rel)
2680                .map(|rel| rel <= verdict_tol)
2681        } else {
2682            None
2683        };
2684        let dof_per_sec = if matches!(
2685            problem.backend,
2686            CsrBackend::Serial | CsrBackend::Distributed
2687        ) && median_solve_secs > 0.0
2688        {
2689            Some(problem.global_n as f64 / median_solve_secs)
2690        } else {
2691            None
2692        };
2693
2694        Ok(ResultRow {
2695            operator_storage: operator_storage_label(problem),
2696            execution_backend: execution_backend_label(problem),
2697            pc_domain: pc_domain_label(spec.pc, problem),
2698            pc_apply: pc_apply_label(spec.pc, problem),
2699            method: format!(
2700                "{} [row-scale={}]",
2701                spec.method_label(),
2702                if bench_cfg.row_scale { "on" } else { "off" }
2703            ),
2704            requested_policy: spec.requested_policy_label(),
2705            effective_policy: format!(
2706                "ksp={}, variant={}, restart={}, residual-check={}",
2707                spec.ksp.label(),
2708                stats
2709                    .effective_variant
2710                    .as_deref()
2711                    .unwrap_or(variant_label(spec.variant)),
2712                stats.effective_restart.unwrap_or(spec.restart),
2713                stats
2714                    .effective_residual_check_policy
2715                    .as_deref()
2716                    .unwrap_or(residual_check_policy_label(spec.residual_check_policy))
2717            ) + if bench_cfg.run_mode == RunMode::Correctness
2718                && bench_cfg.mark_replicated_check
2719            {
2720                " [replicated-check=enabled]"
2721            } else {
2722                ""
2723            },
2724            setup_secs,
2725            median_solve_secs,
2726            min_solve_secs,
2727            iterations: stats.iterations,
2728            reductions,
2729            overlapped_reduction_waits,
2730            model_predicted_reductions,
2731            restart_count: stats.fgmres_counters.as_ref().map(|c| c.restart_count),
2732            inner_iterations_last_cycle: stats
2733                .fgmres_counters
2734                .as_ref()
2735                .map(|c| c.inner_iterations_last_cycle),
2736            pipeline_fallbacks: stats.fgmres_counters.as_ref().map(|c| c.pipeline_fallbacks),
2737            reported_residual: stats.final_residual,
2738            explicit_true_residual,
2739            explicit_true_residual_rel,
2740            x_error_rel,
2741            global_true_residual: global_reference_check
2742                .as_ref()
2743                .map(|check| check.true_residual),
2744            global_true_residual_rel: global_reference_check
2745                .as_ref()
2746                .map(|check| check.true_residual_rel),
2747            global_x_error_rel: global_reference_check.and_then(|check| check.x_error_rel),
2748            dist_ok,
2749            global_ok,
2750            x_ok,
2751            reason: stats.reason,
2752            dof_per_sec,
2753        })
2754    }
2755
2756    fn validate_local_ilu_owned_block(matrix: &SparseCsrMatrix<S>) -> Result<(), KError> {
2757        if matrix.nrows() != matrix.ncols() {
2758            return Err(KError::InvalidInput(
2759                "local ILU requires square owned block".into(),
2760            ));
2761        }
2762        Ok(())
2763    }
2764
2765    fn csr_for_pc_diagnostics(
2766        matrix: &SparseCsrMatrix<S>,
2767        nnz_local_rows: usize,
2768        zero_global_rows: usize,
2769    ) -> CsrForPcDiagnostics {
2770        let mut diag_min_abs = f64::INFINITY;
2771        let mut diag_max_abs = 0.0f64;
2772        let mut zero_local_rows = 0usize;
2773        let mut structural_diag_missing = 0usize;
2774        let mut diag_tiny_or_missing_count = 0usize;
2775        let tiny_diag_threshold = 1e-14f64;
2776        let row_ptr = matrix.row_ptr();
2777        let col_idx = matrix.col_idx();
2778        let values = matrix.values();
2779        for r in 0..matrix.nrows() {
2780            if row_ptr[r] == row_ptr[r + 1] {
2781                zero_local_rows += 1;
2782            }
2783            let mut diag = None;
2784            for nz in row_ptr[r]..row_ptr[r + 1] {
2785                if col_idx[nz] == r {
2786                    diag = Some(values[nz].abs());
2787                    break;
2788                }
2789            }
2790            match diag {
2791                Some(v) => {
2792                    diag_min_abs = diag_min_abs.min(v);
2793                    diag_max_abs = diag_max_abs.max(v);
2794                    if v <= tiny_diag_threshold {
2795                        diag_tiny_or_missing_count += 1;
2796                    }
2797                }
2798                None => {
2799                    structural_diag_missing += 1;
2800                    diag_tiny_or_missing_count += 1;
2801                }
2802            }
2803        }
2804        if !diag_min_abs.is_finite() {
2805            diag_min_abs = 0.0;
2806        }
2807        let nnz_local_block = values.len();
2808        let nnz_ratio = nnz_local_block as f64 / (nnz_local_rows.max(1) as f64);
2809        CsrForPcDiagnostics {
2810            nnz_local_block,
2811            nnz_local_rows,
2812            nnz_ratio,
2813            zero_local_rows,
2814            zero_global_rows,
2815            structural_diag_missing,
2816            diag_min_abs,
2817            diag_max_abs,
2818            diag_tiny_or_missing_count,
2819        }
2820    }
2821
2822    fn rank_spread_from_locals(locals: &[f64], rank0_local: f64) -> RankSpread {
2823        let global_sum = locals.iter().sum::<f64>();
2824        let rank_min = locals.iter().copied().fold(f64::INFINITY, f64::min);
2825        let rank_max = locals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
2826        RankSpread {
2827            global_sum,
2828            rank_min: if rank_min.is_finite() { rank_min } else { 0.0 },
2829            rank_max: if rank_max.is_finite() { rank_max } else { 0.0 },
2830            rank0_local,
2831        }
2832    }
2833
2834    fn reduce_rank_spread<C: Comm<Vec = Vec<f64>>>(comm: &C, local: f64) -> RankSpread {
2835        if comm.size() <= 1 {
2836            return RankSpread {
2837                global_sum: local,
2838                rank_min: local,
2839                rank_max: local,
2840                rank0_local: local,
2841            };
2842        }
2843        let global_sum = comm.all_reduce_f64(local);
2844        let mut gathered = Vec::new();
2845        comm.gather(&[local], &mut gathered, 0);
2846        let (rank_min, rank_max) = if comm.rank() == 0 && !gathered.is_empty() {
2847            (
2848                gathered.iter().copied().fold(f64::INFINITY, f64::min),
2849                gathered.iter().copied().fold(f64::NEG_INFINITY, f64::max),
2850            )
2851        } else {
2852            (0.0, 0.0)
2853        };
2854        let rank0_local = if comm.rank() == 0 { local } else { 0.0 };
2855        RankSpread {
2856            global_sum,
2857            rank_min,
2858            rank_max,
2859            rank0_local,
2860        }
2861    }
2862
2863    fn reduce_csr_for_pc_diagnostics<C: Comm<Vec = Vec<f64>>>(
2864        comm: &C,
2865        local: &CsrForPcDiagnostics,
2866        pivot_perturbation_count: Option<usize>,
2867    ) -> CsrForPcDiagnosticsGlobal {
2868        let zero_local_rows_rank_count =
2869            reduce_rank_spread(comm, if local.zero_local_rows > 0 { 1.0 } else { 0.0 }).global_sum;
2870        CsrForPcDiagnosticsGlobal {
2871            nnz_local_block: reduce_rank_spread(comm, local.nnz_local_block as f64),
2872            nnz_local_rows: reduce_rank_spread(comm, local.nnz_local_rows as f64),
2873            nnz_ratio: reduce_rank_spread(comm, local.nnz_ratio),
2874            zero_local_rows_rank_count,
2875            zero_global_rows: reduce_rank_spread(comm, local.zero_global_rows as f64),
2876            structural_diag_missing: reduce_rank_spread(comm, local.structural_diag_missing as f64),
2877            diag_min_abs: reduce_rank_spread(comm, local.diag_min_abs),
2878            diag_max_abs: reduce_rank_spread(comm, local.diag_max_abs),
2879            diag_tiny_or_missing_count: reduce_rank_spread(
2880                comm,
2881                local.diag_tiny_or_missing_count as f64,
2882            ),
2883            pivot_perturbation_count: pivot_perturbation_count
2884                .map(|v| reduce_rank_spread(comm, v as f64)),
2885        }
2886    }
2887
2888    fn format_csr_for_pc_diagnostics(
2889        method_label: &str,
2890        diag: &CsrForPcDiagnosticsGlobal,
2891    ) -> String {
2892        let pivot_text = diag
2893            .pivot_perturbation_count
2894            .as_ref()
2895            .map(|v| {
2896                format!(
2897                    "global_sum={:.0}, rank_min={:.0}, rank_max={:.0}, rank0_local={:.0}",
2898                    v.global_sum, v.rank_min, v.rank_max, v.rank0_local
2899                )
2900            })
2901            .unwrap_or_else(|| "N/A".to_string());
2902        let nnz_ratio_global =
2903            diag.nnz_local_block.global_sum / diag.nnz_local_rows.global_sum.max(1.0);
2904        format!(
2905            "[diag][rank0] {method_label}: nnz(A_ii):global_sum={:.0},rank_min={:.0},rank_max={:.0},rank0_local={:.0}; nnz(local rows):global_sum={:.0},rank_min={:.0},rank_max={:.0},rank0_local={:.0}; nnz_ratio_global={:.3e},rank_local_min={:.3e},rank_local_max={:.3e},rank0_local={:.3e}; zero_local_rows_rank_count={:.0}; zero_global_rows:global_sum={:.0},rank_min={:.0},rank_max={:.0},rank0_local={:.0}; structural_diag_missing:global_sum={:.0},rank_min={:.0},rank_max={:.0},rank0_local={:.0}; |diag|min:global_sum={:.3e},rank_min={:.3e},rank_max={:.3e},rank0_local={:.3e}; |diag|max:global_sum={:.3e},rank_min={:.3e},rank_max={:.3e},rank0_local={:.3e}; tiny/missing diag:global_sum={:.0},rank_min={:.0},rank_max={:.0},rank0_local={:.0}; pivot perturbations={}",
2906            diag.nnz_local_block.global_sum,
2907            diag.nnz_local_block.rank_min,
2908            diag.nnz_local_block.rank_max,
2909            diag.nnz_local_block.rank0_local,
2910            diag.nnz_local_rows.global_sum,
2911            diag.nnz_local_rows.rank_min,
2912            diag.nnz_local_rows.rank_max,
2913            diag.nnz_local_rows.rank0_local,
2914            nnz_ratio_global,
2915            diag.nnz_ratio.rank_min,
2916            diag.nnz_ratio.rank_max,
2917            diag.nnz_ratio.rank0_local,
2918            diag.zero_local_rows_rank_count,
2919            diag.zero_global_rows.global_sum,
2920            diag.zero_global_rows.rank_min,
2921            diag.zero_global_rows.rank_max,
2922            diag.zero_global_rows.rank0_local,
2923            diag.structural_diag_missing.global_sum,
2924            diag.structural_diag_missing.rank_min,
2925            diag.structural_diag_missing.rank_max,
2926            diag.structural_diag_missing.rank0_local,
2927            diag.diag_min_abs.global_sum,
2928            diag.diag_min_abs.rank_min,
2929            diag.diag_min_abs.rank_max,
2930            diag.diag_min_abs.rank0_local,
2931            diag.diag_max_abs.global_sum,
2932            diag.diag_max_abs.rank_min,
2933            diag.diag_max_abs.rank_max,
2934            diag.diag_max_abs.rank0_local,
2935            diag.diag_tiny_or_missing_count.global_sum,
2936            diag.diag_tiny_or_missing_count.rank_min,
2937            diag.diag_tiny_or_missing_count.rank_max,
2938            diag.diag_tiny_or_missing_count.rank0_local,
2939            pivot_text
2940        )
2941    }
2942
2943    fn print_csr_for_pc_diagnostics(method_label: &str, diag: &CsrForPcDiagnosticsGlobal) {
2944        println!("{}", format_csr_for_pc_diagnostics(method_label, diag));
2945    }
2946
2947    fn configured_fgmres_solver(spec: &RunSpec, bench_cfg: &BenchmarkConfig) -> FgmresSolver {
2948        let mut solver = FgmresSolver::new(bench_cfg.rtol, bench_cfg.maxits, spec.restart);
2949        solver.variant = spec.variant;
2950        solver.residual_check_policy = spec.residual_check_policy;
2951        solver.orthog = spec.orthog;
2952        solver.reorth = spec.reorth;
2953        solver.atol = bench_cfg.atol;
2954        solver.dtol = 1e6;
2955        solver.haptol = bench_cfg.fgmres_haptol;
2956        solver.min_inner_before_fallback = bench_cfg.min_inner_before_fallback.max(1);
2957        solver.stagnation_policy =
2958            if bench_cfg.run_mode == RunMode::Correctness || !bench_cfg.allow_stagnation_fallback {
2959                FgmresStagnationPolicy::Disabled
2960            } else {
2961                match spec.variant {
2962                    FgmresVariant::Classical => FgmresStagnationPolicy::RestartClassicalToo,
2963                    FgmresVariant::Pipelined => FgmresStagnationPolicy::PipelineFallbackOnly,
2964                }
2965            };
2966        solver
2967    }
2968
2969    fn apply_fgmres_dist_plan_policy(solver: &mut FgmresSolver, problem: &Problem) {
2970        solver.apply_distcsr_policy(&problem.dist_plan_diagnostics, problem.comm.size());
2971    }
2972
2973    fn gmres_variant_from_fgmres(variant: FgmresVariant) -> PlainGmresVariant {
2974        match variant {
2975            FgmresVariant::Classical => PlainGmresVariant::Classical,
2976            FgmresVariant::Pipelined => PlainGmresVariant::Pipelined,
2977        }
2978    }
2979
2980    fn gmres_orthog_from_fgmres(orthog: OrthogMethod) -> GmresOrthog {
2981        match orthog {
2982            OrthogMethod::ClassicalGS => GmresOrthog::Cgs,
2983            OrthogMethod::ModifiedGS => GmresOrthog::Mgs,
2984        }
2985    }
2986
2987    fn configured_gmres_solver(spec: &RunSpec, bench_cfg: &BenchmarkConfig) -> GmresSolver {
2988        let mut solver = GmresSolver::new(spec.restart, bench_cfg.rtol, bench_cfg.maxits);
2989        solver.variant = gmres_variant_from_fgmres(spec.variant);
2990        solver.orthog = gmres_orthog_from_fgmres(spec.orthog);
2991        solver.reorth = spec.reorth;
2992        solver.conv.atol = bench_cfg.atol;
2993        solver.conv.dtol = 1e6;
2994        solver.haptol = bench_cfg.fgmres_haptol;
2995        solver
2996    }
2997
2998    #[allow(clippy::too_many_arguments)]
2999    fn solve_with_selected_ksp(
3000        spec: &RunSpec,
3001        bench_cfg: &BenchmarkConfig,
3002        problem: &Problem,
3003        op: &(dyn KLinOp<Scalar = S> + '_),
3004        mut pc: Option<&mut dyn KPreconditioner<Scalar = S>>,
3005        b: &[S],
3006        x: &mut [S],
3007        pc_side: PcSide,
3008        monitors: Option<&[Box<MonitorCallback<R>>]>,
3009        mut work: Option<&mut Workspace>,
3010    ) -> Result<kryst::utils::convergence::SolveStats<R>, KError> {
3011        match spec.ksp {
3012            KspKind::Gmres => {
3013                let mut solver = configured_gmres_solver(spec, bench_cfg);
3014                if let Some(w) = work.as_deref_mut() {
3015                    solver.setup_workspace(w);
3016                }
3017                let pc_ref = pc
3018                    .as_deref_mut()
3019                    .map(|p| p as &dyn KPreconditioner<Scalar = S>);
3020                solver.solve(op, pc_ref, b, x, pc_side, &problem.comm, monitors, work)
3021            }
3022            KspKind::Fgmres => {
3023                let mut solver = configured_fgmres_solver(spec, bench_cfg);
3024                if bench_cfg.dist_policy == DistPolicyMode::Auto {
3025                    apply_fgmres_dist_plan_policy(&mut solver, problem);
3026                }
3027                if let Some(w) = work.as_deref_mut() {
3028                    solver.setup_workspace(w);
3029                }
3030                solver.solve_k(op, pc, b, x, pc_side, &problem.comm, monitors, work)
3031            }
3032        }
3033    }
3034
3035    fn dump_residual_history(path: &Path, history: &RunResidualHistory) -> Result<(), KError> {
3036        let mut f = File::create(path)
3037            .map_err(|e| KError::InvalidInput(format!("failed to create history file: {e}")))?;
3038        if path.extension().and_then(|e| e.to_str()) == Some("json") {
3039            writeln!(f, "[").map_err(|e| KError::InvalidInput(format!("write failed: {e}")))?;
3040            for (i, e) in history.entries.iter().enumerate() {
3041                writeln!(f, "  {{\"iter\":{},\"recurrence_residual\":{},\"true_residual\":{},\"checkpoint\":{}}}{}", e.iter, e.recurrence_residual, e.true_residual.map(|v| v.to_string()).unwrap_or_else(|| "null".to_string()), e.checkpoint, if i + 1 == history.entries.len() {""} else {","})
3042                    .map_err(|e| KError::InvalidInput(format!("write failed: {e}")))?;
3043            }
3044            writeln!(f, "]").map_err(|e| KError::InvalidInput(format!("write failed: {e}")))?;
3045        } else {
3046            writeln!(f, "iter,recurrence_residual,true_residual,checkpoint")
3047                .map_err(|e| KError::InvalidInput(format!("write failed: {e}")))?;
3048            for e in &history.entries {
3049                writeln!(
3050                    f,
3051                    "{},{},{},{}",
3052                    e.iter,
3053                    e.recurrence_residual,
3054                    e.true_residual.map(|v| v.to_string()).unwrap_or_default(),
3055                    e.checkpoint
3056                )
3057                .map_err(|e| KError::InvalidInput(format!("write failed: {e}")))?;
3058            }
3059        }
3060        Ok(())
3061    }
3062
3063    fn median(samples: &mut [f64]) -> f64 {
3064        samples.sort_by(f64::total_cmp);
3065        let n = samples.len();
3066        if n % 2 == 1 {
3067            samples[n / 2]
3068        } else {
3069            (samples[n / 2 - 1] + samples[n / 2]) * 0.5
3070        }
3071    }
3072
3073    fn render_verdict(flag: Option<bool>) -> &'static str {
3074        match flag {
3075            Some(true) => "OK",
3076            Some(false) => "FAIL",
3077            None => "N/A",
3078        }
3079    }
3080
3081    fn render_reason(row: &ResultRow, problem: &Problem) -> String {
3082        if row.reason == ConvergedReason::ConvergedRtol
3083            && problem.generated_case
3084            && problem.comm.size() > 1
3085            && (row.global_ok == Some(false) || row.x_ok == Some(false))
3086        {
3087            "ConvergedRtolDistOpButFailedGlobalReference".to_string()
3088        } else {
3089            format!("{:?}", row.reason)
3090        }
3091    }
3092
3093    fn render_result_row(row: &ResultRow, mode: RunMode, problem: &Problem) -> String {
3094        let rst = row
3095            .restart_count
3096            .map(|v| v.to_string())
3097            .unwrap_or_else(|| "N/A".to_string());
3098        let inn = row
3099            .inner_iterations_last_cycle
3100            .map(|v| v.to_string())
3101            .unwrap_or_else(|| "N/A".to_string());
3102        let pfb = row
3103            .pipeline_fallbacks
3104            .map(|v| v.to_string())
3105            .unwrap_or_else(|| "N/A".to_string());
3106        let include_dof_col = matches!(
3107            problem.backend,
3108            CsrBackend::Serial | CsrBackend::Distributed
3109        );
3110        if mode == RunMode::Scalability {
3111            let dof = row
3112                .dof_per_sec
3113                .map(|v| format!("{v:.2e}"))
3114                .unwrap_or_else(|| "N/A".to_string());
3115            return format!(
3116                "{:<7} {:<8} {:<6} {:<42} {:<36} {:<34} {:>9.3} {:>9.3} {:>7} {:>5} {:>5} {:>5} {:>17} {:>14.2e} {:>12}",
3117                row.operator_storage,
3118                row.execution_backend,
3119                row.pc_domain,
3120                row.pc_apply,
3121                row.method,
3122                row.effective_policy,
3123                row.median_solve_secs,
3124                row.min_solve_secs,
3125                row.iterations,
3126                rst,
3127                inn,
3128                pfb,
3129                format!(
3130                    "{}/{}/{}",
3131                    row.reductions,
3132                    row.overlapped_reduction_waits,
3133                    row.model_predicted_reductions
3134                        .map(|v| v.to_string())
3135                        .unwrap_or_else(|| "n/a".to_string())
3136                ),
3137                row.reported_residual,
3138                dof
3139            );
3140        }
3141        let explicit_true = row
3142            .explicit_true_residual
3143            .map(|v| format!("{v:.2e}"))
3144            .unwrap_or_else(|| "N/A".to_string());
3145        let explicit_true_rel = row
3146            .explicit_true_residual_rel
3147            .map(|v| format!("{v:.2e}"))
3148            .unwrap_or_else(|| "N/A".to_string());
3149        let x_error_rel = row
3150            .x_error_rel
3151            .map(|v| format!("{v:.2e}"))
3152            .unwrap_or_else(|| "N/A".to_string());
3153        let global_true = row
3154            .global_true_residual
3155            .map(|v| format!("{v:.2e}"))
3156            .unwrap_or_else(|| "N/A".to_string());
3157        let global_true_rel = row
3158            .global_true_residual_rel
3159            .map(|v| format!("{v:.2e}"))
3160            .unwrap_or_else(|| "N/A".to_string());
3161        let global_x_error_rel = row
3162            .global_x_error_rel
3163            .map(|v| format!("{v:.2e}"))
3164            .unwrap_or_else(|| "N/A".to_string());
3165        let dist_ok = render_verdict(row.dist_ok);
3166        let global_ok = render_verdict(row.global_ok);
3167        let x_ok = render_verdict(row.x_ok);
3168        let reason = render_reason(row, problem);
3169        if include_dof_col {
3170            let dof = row
3171                .dof_per_sec
3172                .map(|v| format!("{v:.2e}"))
3173                .unwrap_or_else(|| "N/A".to_string());
3174            format!(
3175                "{:<7} {:<8} {:<6} {:<42} {:<36} {:<34} {:<34} {:>9.3} {:>9.3} {:>9.3} {:>7} {:>5} {:>5} {:>5} {:>14.2e} {:>14} {:>14} {:>14} {:>14} {:>14} {:>14} {:>8} {:>8} {:>8} {:>26} {:>12}",
3176                row.operator_storage,
3177                row.execution_backend,
3178                row.pc_domain,
3179                row.pc_apply,
3180                row.method,
3181                row.requested_policy,
3182                row.effective_policy,
3183                row.setup_secs,
3184                row.median_solve_secs,
3185                row.min_solve_secs,
3186                row.iterations,
3187                rst,
3188                inn,
3189                pfb,
3190                row.reported_residual,
3191                explicit_true,
3192                explicit_true_rel,
3193                x_error_rel,
3194                global_true,
3195                global_true_rel,
3196                global_x_error_rel,
3197                dist_ok,
3198                global_ok,
3199                x_ok,
3200                reason,
3201                dof
3202            )
3203        } else {
3204            format!(
3205                "{:<7} {:<8} {:<6} {:<42} {:<36} {:<34} {:<34} {:>9.3} {:>9.3} {:>9.3} {:>7} {:>5} {:>5} {:>5} {:>14.2e} {:>14} {:>14} {:>14} {:>14} {:>14} {:>14} {:>8} {:>8} {:>8} {:>26}",
3206                row.operator_storage,
3207                row.execution_backend,
3208                row.pc_domain,
3209                row.pc_apply,
3210                row.method,
3211                row.requested_policy,
3212                row.effective_policy,
3213                row.setup_secs,
3214                row.median_solve_secs,
3215                row.min_solve_secs,
3216                row.iterations,
3217                rst,
3218                inn,
3219                pfb,
3220                row.reported_residual,
3221                explicit_true,
3222                explicit_true_rel,
3223                x_error_rel,
3224                global_true,
3225                global_true_rel,
3226                global_x_error_rel,
3227                dist_ok,
3228                global_ok,
3229                x_ok,
3230                reason
3231            )
3232        }
3233    }
3234
3235    fn render_failure_result_row(spec: &RunSpec, mode: RunMode, problem: &Problem) -> String {
3236        let include_dof_col = matches!(
3237            problem.backend,
3238            CsrBackend::Serial | CsrBackend::Distributed
3239        );
3240        if mode == RunMode::Scalability {
3241            return format!(
3242                "{:<7} {:<8} {:<6} {:<42} {:<36} {:<34} {:>9} {:>9} {:>7} {:>5} {:>5} {:>5} {:>17} {:>14} {:>12}",
3243                operator_storage_label(problem),
3244                execution_backend_label(problem),
3245                pc_domain_label(spec.pc, problem),
3246                pc_apply_label(spec.pc, problem),
3247                spec.method_label(),
3248                "N/A",
3249                "FAIL",
3250                "FAIL",
3251                "N/A",
3252                "N/A",
3253                "N/A",
3254                "N/A",
3255                "N/A",
3256                "N/A",
3257                "N/A"
3258            );
3259        }
3260        if include_dof_col {
3261            format!(
3262                "{:<7} {:<8} {:<6} {:<42} {:<36} {:<34} {:<34} {:>9} {:>9} {:>9} {:>7} {:>5} {:>5} {:>5} {:>14} {:>14} {:>14} {:>14} {:>14} {:>14} {:>14} {:>8} {:>8} {:>8} {:>26} {:>12}",
3263                operator_storage_label(problem),
3264                execution_backend_label(problem),
3265                pc_domain_label(spec.pc, problem),
3266                pc_apply_label(spec.pc, problem),
3267                spec.method_label(),
3268                spec.requested_policy_label(),
3269                "N/A",
3270                "FAIL",
3271                "FAIL",
3272                "FAIL",
3273                "N/A",
3274                "N/A",
3275                "N/A",
3276                "N/A",
3277                "N/A",
3278                "N/A",
3279                "N/A",
3280                "N/A",
3281                "N/A",
3282                "N/A",
3283                "N/A",
3284                "N/A",
3285                "N/A",
3286                "N/A",
3287                "N/A",
3288                "N/A"
3289            )
3290        } else {
3291            format!(
3292                "{:<7} {:<8} {:<6} {:<42} {:<36} {:<34} {:<34} {:>9} {:>9} {:>9} {:>7} {:>5} {:>5} {:>5} {:>14} {:>14} {:>14} {:>14} {:>14} {:>14} {:>14} {:>8} {:>8} {:>8} {:>26}",
3293                operator_storage_label(problem),
3294                execution_backend_label(problem),
3295                pc_domain_label(spec.pc, problem),
3296                pc_apply_label(spec.pc, problem),
3297                spec.method_label(),
3298                spec.requested_policy_label(),
3299                "N/A",
3300                "FAIL",
3301                "FAIL",
3302                "FAIL",
3303                "N/A",
3304                "N/A",
3305                "N/A",
3306                "N/A",
3307                "N/A",
3308                "N/A",
3309                "N/A",
3310                "N/A",
3311                "N/A",
3312                "N/A",
3313                "N/A",
3314                "N/A",
3315                "N/A",
3316                "N/A",
3317                "N/A"
3318            )
3319        }
3320    }
3321
3322    fn solution_reference_diagnostics(matrix: &SparseCsrMatrix<S>) -> SolutionReferenceDiagnostics {
3323        let tiny_diag_threshold = 1e-14f64;
3324        let row_ptr = matrix.row_ptr();
3325        let col_idx = matrix.col_idx();
3326        let values = matrix.values();
3327        let mut zero_row_count = 0usize;
3328        let mut missing_diagonal_count = 0usize;
3329        let mut tiny_diagonal_count = 0usize;
3330
3331        for row in 0..matrix.nrows() {
3332            let start = row_ptr[row];
3333            let end = row_ptr[row + 1];
3334            if start == end {
3335                zero_row_count += 1;
3336            }
3337            let mut diag = None;
3338            for nz in start..end {
3339                if col_idx[nz] == row {
3340                    diag = Some(values[nz].abs());
3341                    break;
3342                }
3343            }
3344            match diag {
3345                Some(v) if v <= tiny_diag_threshold => tiny_diagonal_count += 1,
3346                Some(_) => {}
3347                None => missing_diagonal_count += 1,
3348            }
3349        }
3350
3351        SolutionReferenceDiagnostics {
3352            global_rows: matrix.nrows(),
3353            global_cols: matrix.ncols(),
3354            zero_row_count,
3355            missing_diagonal_count,
3356            tiny_diagonal_count,
3357        }
3358    }
3359
3360    fn build_shifted_poisson_case(
3361        comm: &UniverseComm,
3362        nx: usize,
3363        ny: usize,
3364        alpha: f64,
3365        beta: f64,
3366        convection_x: f64,
3367        convection_y: f64,
3368    ) -> Result<Problem, KError> {
3369        if nx == 0 || ny == 0 {
3370            return Err(KError::InvalidInput(
3371                "shifted Poisson grid dimensions must be positive".into(),
3372            ));
3373        }
3374        let n = nx.checked_mul(ny).ok_or_else(|| {
3375            KError::InvalidInput(format!("shifted Poisson grid {nx}x{ny} overflows usize"))
3376        })?;
3377        if n < comm.size() {
3378            return Err(KError::InvalidInput(format!(
3379                "shifted Poisson grid {nx}x{ny} has {n} rows, fewer than {} MPI ranks",
3380                comm.size()
3381            )));
3382        }
3383
3384        let csr_sparse = build_shifted_poisson_csr(nx, ny, alpha, beta, convection_x, convection_y);
3385        let solution_reference = solution_reference_diagnostics(&csr_sparse);
3386        let row_part = DistCsrOp::partition_rows_balanced(n, comm);
3387        let row_start = row_part[comm.rank()];
3388        let row_end = row_part[comm.rank() + 1];
3389        let local_csr = slice_csr_rows(&csr_sparse, row_start, row_end);
3390        let local_pc_block = slice_csr_rows_owned_cols(&csr_sparse, row_start, row_end);
3391
3392        let op = DistCsrOp::from_local_rows(n, row_start, &local_csr, &row_part, comm.clone())?;
3393        let dist_plan_diagnostics = op.plan_diagnostics().clone();
3394        let op_arc: Arc<dyn KLinOp<Scalar = S>> = Arc::new(op);
3395
3396        let ones = vec![S::one(); n];
3397        let mut rhs_global = vec![S::zero(); n];
3398        csr_sparse.spmv(&ones, &mut rhs_global);
3399        let rhs = rhs_global[row_start..row_end].to_vec();
3400
3401        let local_n = row_end - row_start;
3402        let backend = classify_backend(comm.size(), local_n, n);
3403
3404        Ok(Problem {
3405            op: op_arc,
3406            dist_plan_diagnostics,
3407            rhs,
3408            rhs_source: RhsSource::GeneratedAOnes,
3409            solution_reference,
3410            csr_for_pc: Arc::new(local_pc_block),
3411            global_csr: Arc::new(csr_sparse),
3412            local_rows_nnz: local_csr.values().len(),
3413            zero_global_rows_local: count_zero_rows(&local_csr),
3414            local_n,
3415            global_n: n,
3416            global_row_start: row_start,
3417            comm: comm.clone(),
3418            backend,
3419            backend_descr: format!(
3420                "Generated shifted Poisson CSR ({}x{} grid, shift={alpha:.3}+{beta:.3}i, convection=({convection_x:.3},{convection_y:.3}))",
3421                nx, ny
3422            ),
3423            generated_case: true,
3424        })
3425    }
3426
3427    fn build_shifted_poisson_csr(
3428        nx: usize,
3429        ny: usize,
3430        alpha: f64,
3431        beta: f64,
3432        convection_x: f64,
3433        convection_y: f64,
3434    ) -> SparseCsrMatrix<S> {
3435        let n = nx * ny;
3436        let shift = S::from_parts(alpha, beta);
3437        let mut row_ptr = Vec::with_capacity(n + 1);
3438        let mut col_idx = Vec::with_capacity(n * 5);
3439        let mut values = Vec::with_capacity(n * 5);
3440        row_ptr.push(0);
3441
3442        for iy in 0..ny {
3443            for ix in 0..nx {
3444                let row = iy * nx + ix;
3445                let mut entries = Vec::with_capacity(5);
3446                if iy > 0 {
3447                    entries.push((row - nx, S::from_real(-1.0 - 0.5 * convection_y)));
3448                }
3449                if ix > 0 {
3450                    entries.push((row - 1, S::from_real(-1.0 - 0.5 * convection_x)));
3451                }
3452                entries.push((row, S::from_real(4.0) + shift));
3453                if ix + 1 < nx {
3454                    entries.push((row + 1, S::from_real(-1.0 + 0.5 * convection_x)));
3455                }
3456                if iy + 1 < ny {
3457                    entries.push((row + nx, S::from_real(-1.0 + 0.5 * convection_y)));
3458                }
3459                entries.sort_by_key(|&(col, _)| col);
3460                for (col, val) in entries {
3461                    col_idx.push(col);
3462                    values.push(val);
3463                }
3464                row_ptr.push(col_idx.len());
3465            }
3466        }
3467
3468        SparseCsrMatrix::from_csr(n, n, row_ptr, col_idx, values)
3469    }
3470
3471    fn load_problem_complex(
3472        mat_path: &Path,
3473        comm: &UniverseComm,
3474        _run_mode: RunMode,
3475        _mark_replicated_check: bool,
3476    ) -> Result<Problem, KError> {
3477        let mm = read_matrix_market(mat_path)?;
3478        let csr_sparse: SparseCsrMatrix<S> = mm.to_csr_matrix_scalar()?;
3479        let nrows = csr_sparse.nrows();
3480        let ncols = csr_sparse.ncols();
3481        let solution_reference = solution_reference_diagnostics(&csr_sparse);
3482
3483        let row_part = DistCsrOp::partition_rows_balanced(nrows, comm);
3484        let row_start = row_part[comm.rank()];
3485        let row_end = row_part[comm.rank() + 1];
3486        let local_csr = slice_csr_rows(&csr_sparse, row_start, row_end);
3487        let local_pc_block = slice_csr_rows_owned_cols(&csr_sparse, row_start, row_end);
3488
3489        let op = DistCsrOp::from_local_rows(nrows, row_start, &local_csr, &row_part, comm.clone())?;
3490        let dist_plan_diagnostics = op.plan_diagnostics().clone();
3491        let op_arc: Arc<dyn KLinOp<Scalar = S>> = Arc::new(op);
3492
3493        let (rhs_global, rhs_source) = match try_load_rhs_s(mat_path, nrows) {
3494            Some(vec) => (vec, RhsSource::LoadedFromFile),
3495            None => {
3496                let ones = vec![S::one(); ncols];
3497                let mut b = vec![S::zero(); nrows];
3498                csr_sparse.spmv(&ones, &mut b);
3499                (b, RhsSource::GeneratedAOnes)
3500            }
3501        };
3502        let rhs = rhs_global[row_start..row_end].to_vec();
3503
3504        let local_n = row_end - row_start;
3505        let global_n = nrows;
3506        let backend = classify_backend(comm.size(), local_n, global_n);
3507
3508        Ok(Problem {
3509            op: op_arc,
3510            dist_plan_diagnostics,
3511            rhs,
3512            rhs_source,
3513            solution_reference,
3514            csr_for_pc: Arc::new(local_pc_block),
3515            global_csr: Arc::new(csr_sparse),
3516            local_rows_nnz: local_csr.values().len(),
3517            zero_global_rows_local: count_zero_rows(&local_csr),
3518            local_n,
3519            global_n,
3520            global_row_start: row_start,
3521            comm: comm.clone(),
3522            backend,
3523            backend_descr: "Distributed CSR (complex)".to_string(),
3524            generated_case: false,
3525        })
3526    }
3527
3528    fn count_zero_rows(matrix: &SparseCsrMatrix<S>) -> usize {
3529        let row_ptr = matrix.row_ptr();
3530        (0..matrix.nrows())
3531            .filter(|&r| row_ptr[r] == row_ptr[r + 1])
3532            .count()
3533    }
3534
3535    fn slice_csr_rows(matrix: &SparseCsrMatrix<S>, start: usize, end: usize) -> SparseCsrMatrix<S> {
3536        let row_ptr = matrix.row_ptr();
3537        let col_idx = matrix.col_idx();
3538        let values = matrix.values();
3539        let start_nnz = row_ptr[start];
3540        let end_nnz = row_ptr[end];
3541
3542        let mut local_rp = Vec::with_capacity(end - start + 1);
3543        for r in start..=end {
3544            local_rp.push(row_ptr[r] - start_nnz);
3545        }
3546        let local_ci = col_idx[start_nnz..end_nnz].to_vec();
3547        let local_vals = values[start_nnz..end_nnz].to_vec();
3548
3549        SparseCsrMatrix::from_csr(end - start, matrix.ncols(), local_rp, local_ci, local_vals)
3550    }
3551
3552    fn slice_csr_rows_owned_cols(
3553        matrix: &SparseCsrMatrix<S>,
3554        row_start: usize,
3555        row_end: usize,
3556    ) -> SparseCsrMatrix<S> {
3557        let row_ptr = matrix.row_ptr();
3558        let col_idx = matrix.col_idx();
3559        let values = matrix.values();
3560        let local_n = row_end - row_start;
3561
3562        let mut local_rp = Vec::with_capacity(local_n + 1);
3563        let mut local_ci = Vec::new();
3564        let mut local_vals = Vec::new();
3565        local_rp.push(0);
3566
3567        for global_r in row_start..row_end {
3568            let mut row_nnz = 0usize;
3569            for nz in row_ptr[global_r]..row_ptr[global_r + 1] {
3570                let global_c = col_idx[nz];
3571                if (row_start..row_end).contains(&global_c) {
3572                    local_ci.push(global_c - row_start);
3573                    local_vals.push(values[nz]);
3574                    row_nnz += 1;
3575                }
3576            }
3577            local_rp.push(local_rp.last().copied().unwrap_or(0) + row_nnz);
3578        }
3579
3580        SparseCsrMatrix::from_csr(local_n, local_n, local_rp, local_ci, local_vals)
3581    }
3582
3583    fn partition_rows_balanced_for_size(n_global: usize, size: usize) -> Vec<usize> {
3584        let base = n_global / size;
3585        let rem = n_global % size;
3586        let mut out = Vec::with_capacity(size + 1);
3587        out.push(0);
3588        for rank in 0..size {
3589            let take = base + usize::from(rank < rem);
3590            out.push(out[rank] + take);
3591        }
3592        out
3593    }
3594
3595    fn classify_backend(size: usize, local_n: usize, global_n: usize) -> CsrBackend {
3596        if size <= 1 {
3597            CsrBackend::Serial
3598        } else if local_n == global_n {
3599            CsrBackend::Replicated
3600        } else {
3601            CsrBackend::Distributed
3602        }
3603    }
3604
3605    fn try_load_rhs_s(mat_path: &Path, n: usize) -> Option<Vec<S>> {
3606        let mut rhs_path = mat_path.to_path_buf();
3607        if let Some(stem) = mat_path.file_stem().and_then(|s| s.to_str()) {
3608            rhs_path.set_file_name(format!("{stem}_rhs.mtx"));
3609            if rhs_path.exists() {
3610                if let Ok(mm) = read_matrix_market(&rhs_path) {
3611                    if let Ok(v) = mm.to_vector_scalar() {
3612                        if v.len() == n {
3613                            return Some(v);
3614                        }
3615                    }
3616                }
3617            }
3618        }
3619        None
3620    }
3621
3622    fn assert_shifted_poisson_valid_nonzero_diagonal(matrix: &SparseCsrMatrix<S>) {
3623        assert_eq!(matrix.nrows(), matrix.ncols());
3624        assert_eq!(matrix.row_ptr().len(), matrix.nrows() + 1);
3625        assert!(matrix.values().len() >= matrix.nrows());
3626
3627        for row in 0..matrix.nrows() {
3628            let start = matrix.row_ptr()[row];
3629            let end = matrix.row_ptr()[row + 1];
3630            let diag = (start..end)
3631                .find(|&nz| matrix.col_idx()[nz] == row)
3632                .map(|nz| matrix.values()[nz]);
3633            assert!(
3634                diag.is_some_and(|v| v.abs() > 0.0),
3635                "row {row} must have a nonzero diagonal"
3636            );
3637        }
3638    }
3639
3640    #[test]
3641    fn shifted_poisson_generated_easy_and_hard_matrices_are_valid() {
3642        for (alpha, beta) in [(1.0, 0.25), (0.01, 0.25)] {
3643            let matrix = build_shifted_poisson_csr(4, 3, alpha, beta, 0.0, 0.0);
3644            assert_eq!(matrix.nrows(), 12);
3645            assert_eq!(matrix.ncols(), 12);
3646            assert_shifted_poisson_valid_nonzero_diagonal(&matrix);
3647        }
3648    }
3649
3650    #[test]
3651    fn shifted_poisson_zero_convection_preserves_existing_stencil() {
3652        let matrix = build_shifted_poisson_csr(3, 3, 1.0, 0.25, 0.0, 0.0);
3653        let row = 4;
3654        let start = matrix.row_ptr()[row];
3655        let end = matrix.row_ptr()[row + 1];
3656        let entries = (start..end)
3657            .map(|nz| (matrix.col_idx()[nz], matrix.values()[nz]))
3658            .collect::<Vec<_>>();
3659
3660        assert_eq!(entries.len(), 5);
3661        assert_eq!(entries[0].0, 1);
3662        assert_eq!(entries[0].1.real(), -1.0);
3663        assert_eq!(entries[1].0, 3);
3664        assert_eq!(entries[1].1.real(), -1.0);
3665        assert_eq!(entries[2].0, 4);
3666        assert_eq!(entries[2].1.real(), 5.0);
3667        assert_eq!(entries[2].1.imag(), 0.25);
3668        assert_eq!(entries[3].0, 5);
3669        assert_eq!(entries[3].1.real(), -1.0);
3670        assert_eq!(entries[4].0, 7);
3671        assert_eq!(entries[4].1.real(), -1.0);
3672    }
3673
3674    #[test]
3675    fn shifted_poisson_partition_has_nonzero_local_rows_for_typical_rank_counts() {
3676        let matrix = build_shifted_poisson_csr(4, 4, 1.0, 0.25, 0.0, 0.0);
3677        for size in [1usize, 2, 3, 4, 8] {
3678            let part = partition_rows_balanced_for_size(matrix.nrows(), size);
3679            for rank in 0..size {
3680                let row_start = part[rank];
3681                let row_end = part[rank + 1];
3682                let local = slice_csr_rows(&matrix, row_start, row_end);
3683                assert!(
3684                    local.nrows() > 0,
3685                    "rank {rank}/{size} should own at least one generated Poisson row"
3686                );
3687                assert!(
3688                    local.values().len() > 0,
3689                    "rank {rank}/{size} should own generated Poisson nonzeros"
3690                );
3691            }
3692        }
3693    }
3694
3695    #[test]
3696    fn shifted_poisson_dist_spmv_matches_global_spmv_for_ones_and_random_x() {
3697        #[cfg(feature = "mpi")]
3698        let comm = UniverseComm::Mpi(Arc::new(MpiComm::new()));
3699        #[cfg(not(feature = "mpi"))]
3700        let comm = UniverseComm::NoComm(NoComm);
3701
3702        let nx = 8usize;
3703        let ny = 7usize;
3704        let alpha = 1.0;
3705        let beta = 0.25;
3706        let global_csr = build_shifted_poisson_csr(nx, ny, alpha, beta, 0.0, 0.0);
3707        let global_n = global_csr.nrows();
3708        if global_n < comm.size() {
3709            eprintln!(
3710                "skipping shifted Poisson distributed SpMV check: {global_n} rows for {} ranks",
3711                comm.size()
3712            );
3713            return;
3714        }
3715
3716        let row_part = DistCsrOp::partition_rows_balanced(global_n, &comm);
3717        let global_row_start = row_part[comm.rank()];
3718        let global_row_end = row_part[comm.rank() + 1];
3719        let local_n = global_row_end - global_row_start;
3720        let local_csr = slice_csr_rows(&global_csr, global_row_start, global_row_end);
3721        let op = DistCsrOp::from_local_rows(
3722            global_n,
3723            global_row_start,
3724            &local_csr,
3725            &row_part,
3726            comm.clone(),
3727        )
3728        .expect("build distributed shifted Poisson CSR operator");
3729
3730        let ones = vec![S::one(); global_n];
3731        let ramp = (0..global_n)
3732            .map(|i| S::from_parts(0.125 * (i as f64 + 1.0), -0.0625 * (i as f64 + 3.0)))
3733            .collect::<Vec<_>>();
3734        let mut state = 0x9E37_79B9_7F4A_7C15_u64;
3735        let random = (0..global_n)
3736            .map(|_| {
3737                state = state
3738                    .wrapping_mul(6364136223846793005)
3739                    .wrapping_add(1442695040888963407);
3740                let re = ((state >> 33) as f64) / ((1_u64 << 31) as f64) - 0.5;
3741                state = state
3742                    .wrapping_mul(6364136223846793005)
3743                    .wrapping_add(1442695040888963407);
3744                let im = ((state >> 33) as f64) / ((1_u64 << 31) as f64) - 0.5;
3745                S::from_parts(re, im)
3746            })
3747            .collect::<Vec<_>>();
3748
3749        let cases = [
3750            ("ones", ones),
3751            ("complex ramp", ramp),
3752            ("pseudo-random", random),
3753        ];
3754        let mut scratch = BridgeScratch::default();
3755        for (label, x_global) in cases {
3756            let x_local = x_global[global_row_start..global_row_end].to_vec();
3757            let mut y_dist = vec![S::zero(); local_n];
3758            op.matvec_s(&x_local, &mut y_dist, &mut scratch);
3759
3760            let mut y_global = vec![S::zero(); global_n];
3761            global_csr.spmv(&x_global, &mut y_global);
3762            let expected = &y_global[global_row_start..global_row_end];
3763
3764            assert_eq!(
3765                y_dist.len(),
3766                expected.len(),
3767                "{label} local result length mismatch on rank {}",
3768                comm.rank()
3769            );
3770            for (local_row, (&actual, &want)) in y_dist.iter().zip(expected).enumerate() {
3771                let diff = (actual - want).abs();
3772                assert!(
3773                    diff <= 1.0e-12,
3774                    "{label} SpMV mismatch on rank {}, local row {local_row} (global row {}): actual={actual:?}, expected={want:?}, diff={diff:e}",
3775                    comm.rank(),
3776                    global_row_start + local_row
3777                );
3778            }
3779        }
3780    }
3781
3782    #[test]
3783    fn shifted_poisson_problem_rhs_matches_a_times_ones() {
3784        #[cfg(feature = "mpi")]
3785        let comm = UniverseComm::Mpi(Arc::new(MpiComm::new()));
3786        #[cfg(not(feature = "mpi"))]
3787        let comm = UniverseComm::NoComm(NoComm);
3788
3789        let nx = 5usize;
3790        let ny = 4usize;
3791        let alpha = 1.0;
3792        let beta = 0.25;
3793        let problem = build_shifted_poisson_case(&comm, nx, ny, alpha, beta, 0.0, 0.0)
3794            .expect("build generated shifted Poisson problem");
3795        let matrix = build_shifted_poisson_csr(nx, ny, alpha, beta, 0.0, 0.0);
3796        let ones = vec![S::one(); matrix.ncols()];
3797        let mut expected_global = vec![S::zero(); matrix.nrows()];
3798        matrix.spmv(&ones, &mut expected_global);
3799        let expected =
3800            &expected_global[problem.global_row_start..problem.global_row_start + problem.local_n];
3801
3802        assert_eq!(problem.global_n, nx * ny);
3803        assert_eq!(problem.local_n, problem.rhs.len());
3804        assert_eq!(problem.rhs_source, RhsSource::GeneratedAOnes);
3805        assert!(problem.generated_case);
3806        assert_eq!(problem.rhs, expected);
3807    }
3808
3809    #[test]
3810    fn shifted_poisson_default_pcs_include_requested_generated_set() {
3811        #[cfg(feature = "mpi")]
3812        let comm = UniverseComm::Mpi(Arc::new(MpiComm::new()));
3813        #[cfg(not(feature = "mpi"))]
3814        let comm = UniverseComm::NoComm(NoComm);
3815
3816        let problem = build_shifted_poisson_case(&comm, 4, 4, 1.0, 0.25, 0.0, 0.0)
3817            .expect("build generated shifted Poisson problem");
3818        let cfg = BenchmarkConfig {
3819            restarts: vec![20],
3820            ..BenchmarkConfig::default()
3821        };
3822        let pcs = RunSpec::build_default_matrix(&cfg, &problem)
3823            .into_iter()
3824            .map(|spec| spec.pc)
3825            .collect::<Vec<_>>();
3826
3827        assert!(pcs.contains(&PcKind::None));
3828        assert!(pcs.contains(&PcKind::JacobiWeak));
3829        assert!(pcs.contains(&PcKind::LocalIluk { k: 1 }));
3830        if problem.comm.size() > 1 {
3831            assert!(pcs.contains(&PcKind::MpiBlockJacobiIlu0Local));
3832        } else {
3833            assert!(pcs.contains(&PcKind::Ilu0Local));
3834        }
3835    }
3836
3837    #[test]
3838    fn qc324_owned_pc_block_is_local_square_on_4_way_partition() {
3839        let mat_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/mtx/qc324.mtx");
3840        if !mat_path.exists() {
3841            eprintln!("qc324.mtx unavailable; skipping local PC block-shape check.");
3842            return;
3843        }
3844
3845        let mm = read_matrix_market(&mat_path).expect("read qc324");
3846        let csr_sparse: SparseCsrMatrix<S> = mm.to_csr_matrix_scalar().expect("qc324 to CSR");
3847        let n = csr_sparse.nrows();
3848        let part = partition_rows_balanced_for_size(n, 4);
3849
3850        for rank in 0..4 {
3851            let row_start = part[rank];
3852            let row_end = part[rank + 1];
3853            let local_n = row_end - row_start;
3854            let pc_block = slice_csr_rows_owned_cols(&csr_sparse, row_start, row_end);
3855            eprintln!(
3856                "qc324 rank {rank}: rows=[{row_start}..{row_end}), pc_block=({} x {})",
3857                pc_block.nrows(),
3858                pc_block.ncols()
3859            );
3860            assert_eq!(pc_block.nrows(), local_n, "rank {rank} local PC rows");
3861            assert_eq!(pc_block.ncols(), local_n, "rank {rank} local PC cols");
3862            assert!(
3863                pc_block.col_idx().iter().all(|&c| c < local_n),
3864                "rank {rank} has out-of-range local column index"
3865            );
3866        }
3867    }
3868
3869    #[cfg(feature = "mpi")]
3870    #[test]
3871    fn replicated_full_ilu_apply_matches_serial_reference_under_mpi() {
3872        let comm = UniverseComm::Mpi(Arc::new(MpiComm::new()));
3873        let rank = comm.rank();
3874        let size = comm.size();
3875        let global_n = 4usize;
3876        let row_part = partition_rows_balanced_for_size(global_n, size);
3877        let row_start = row_part[rank];
3878        let row_end = row_part[rank + 1];
3879        let local_n = row_end - row_start;
3880
3881        let matrix = SparseCsrMatrix::from_csr(
3882            global_n,
3883            global_n,
3884            vec![0, 1, 2, 3, 4],
3885            vec![0, 1, 2, 3],
3886            vec![
3887                S::from_parts(2.0, 0.0),
3888                S::from_parts(4.0, 0.0),
3889                S::from_parts(5.0, 0.0),
3890                S::from_parts(10.0, 0.0),
3891            ],
3892        );
3893        let x_global = vec![
3894            S::from_parts(2.0, 1.0),
3895            S::from_parts(8.0, -4.0),
3896            S::from_parts(15.0, 5.0),
3897            S::from_parts(40.0, -10.0),
3898        ];
3899        let x_local = x_global[row_start..row_end].to_vec();
3900
3901        let mut cfg = IluCsrConfig::default();
3902        cfg.kind = IluKind::Ilu0;
3903        let mut serial_ilu = IluCsr::new_with_config(cfg.clone());
3904        serial_ilu.setup(&matrix).expect("serial ILU setup");
3905        let mut replicated_ilu = IluCsr::new_with_config(cfg);
3906        replicated_ilu.setup(&matrix).expect("replicated ILU setup");
3907
3908        let mut scratch = BridgeScratch::default();
3909        let mut expected_global = vec![S::zero(); global_n];
3910        serial_ilu
3911            .apply_s(PcSide::Right, &x_global, &mut expected_global, &mut scratch)
3912            .expect("serial ILU apply");
3913
3914        let mut y_local = vec![S::zero(); local_n];
3915        let scratch_in = std::sync::Mutex::new(vec![S::zero(); global_n]);
3916        let scratch_out = std::sync::Mutex::new(vec![S::zero(); global_n]);
3917        apply_replicated_full_ilu_owned_segment(
3918            &replicated_ilu,
3919            &comm,
3920            PcSide::Right,
3921            &x_local,
3922            &mut y_local,
3923            &mut scratch,
3924            global_n,
3925            row_start,
3926            local_n,
3927            &scratch_in,
3928            &scratch_out,
3929        )
3930        .expect("replicated ILU apply");
3931
3932        let expected_local = &expected_global[row_start..row_end];
3933        assert_eq!(y_local.len(), expected_local.len());
3934        for (got, expected) in y_local.iter().zip(expected_local.iter()) {
3935            assert!(
3936                (*got - *expected).abs() <= 1.0e-12,
3937                "rank {rank} replicated apply mismatch: got={got:?}, expected={expected:?}"
3938            );
3939        }
3940    }
3941
3942    #[test]
3943    fn replicated_full_ilu0_setup_helper_uses_global_dimensions_for_shifted_poisson() {
3944        #[cfg(feature = "mpi")]
3945        let comm = UniverseComm::Mpi(Arc::new(MpiComm::new()));
3946        #[cfg(not(feature = "mpi"))]
3947        let comm = UniverseComm::NoComm(NoComm);
3948
3949        let problem = build_shifted_poisson_case(&comm, 5, 5, 1.0, 0.25, 0.0, 0.0)
3950            .expect("build generated shifted Poisson problem");
3951        assert_eq!(problem.global_n, 25);
3952        assert_eq!(
3953            problem.global_n % 4,
3954            1,
3955            "test matrix should not divide evenly over 4 ranks"
3956        );
3957        assert_eq!(problem.csr_for_pc.nrows(), problem.local_n);
3958        assert_eq!(problem.csr_for_pc.ncols(), problem.local_n);
3959        assert_eq!(problem.global_csr.nrows(), problem.global_n);
3960        assert_eq!(problem.global_csr.ncols(), problem.global_n);
3961
3962        let spec = RunSpec {
3963            restart: 5,
3964            ksp: KspKind::Gmres,
3965            variant: FgmresVariant::Classical,
3966            residual_check_policy: ResidualCheckPolicy::OnConvergence,
3967            orthog: OrthogMethod::ClassicalGS,
3968            reorth: ReorthPolicy::IfNeeded,
3969            pc_side: PcSide::Right,
3970            pc: PcKind::ReplicatedFullIlu0,
3971        };
3972        let bench_cfg = BenchmarkConfig {
3973            run_mode: RunMode::Scalability,
3974            warmup_runs: 0,
3975            measured_runs: 0,
3976            ..BenchmarkConfig::default()
3977        };
3978        let pc_csr = problem.csr_for_pc.clone();
3979        let global_pc_csr = problem.global_csr.clone();
3980        let csr_pc_diag = csr_for_pc_diagnostics(
3981            pc_csr.as_ref(),
3982            problem.local_rows_nnz,
3983            problem.zero_global_rows_local,
3984        );
3985
3986        let pc = setup_preconditioner_for_run_once(
3987            &problem,
3988            &spec,
3989            &bench_cfg,
3990            &pc_csr,
3991            &global_pc_csr,
3992            &csr_pc_diag,
3993        )
3994        .expect("replicated full ILU0 setup through run_once helper")
3995        .expect("replicated full ILU0 preconditioner handle");
3996
3997        assert!(
3998            matches!(pc, PcHandle::ReplicatedFull { .. }),
3999            "expected replicated full preconditioner handle"
4000        );
4001        assert_eq!(
4002            KPreconditioner::dims(&pc),
4003            (problem.global_n, problem.global_n)
4004        );
4005
4006        let x_local = (0..problem.local_n)
4007            .map(|i| S::from_parts(1.0 + (problem.global_row_start + i) as f64, -0.25))
4008            .collect::<Vec<_>>();
4009        let mut y_local = vec![S::zero(); problem.local_n];
4010        let mut scratch = BridgeScratch::default();
4011        pc.apply_s(PcSide::Right, &x_local, &mut y_local, &mut scratch)
4012            .expect(
4013                "replicated full ILU0 applies to owned local vector without dimension mismatch",
4014            );
4015    }
4016
4017    #[test]
4018    fn replicated_full_ilu_dispatches_separately_from_local_ilu() {
4019        assert_eq!(
4020            PcKind::ReplicatedFullIlu0.dispatch_branch(),
4021            PcDispatchBranch::ReplicatedFullIlu
4022        );
4023        assert_eq!(
4024            PcKind::ReplicatedFullIluk { k: 2 }.dispatch_branch(),
4025            PcDispatchBranch::ReplicatedFullIlu
4026        );
4027        assert_eq!(
4028            PcKind::Ilu0Local.dispatch_branch(),
4029            PcDispatchBranch::Ilu0Local
4030        );
4031        assert_eq!(
4032            PcKind::MpiBlockJacobiIlu0Local.dispatch_branch(),
4033            PcDispatchBranch::Ilu0Local
4034        );
4035        assert_eq!(
4036            PcKind::LocalIluk { k: 1 }.dispatch_branch(),
4037            PcDispatchBranch::Ilu0Local
4038        );
4039        assert_eq!(
4040            PcKind::IlutLocal.dispatch_branch(),
4041            PcDispatchBranch::IlutLocal
4042        );
4043    }
4044
4045    #[test]
4046    fn replicated_full_ilu_uses_global_csr_even_when_local_pc_block_is_rectangular() {
4047        #[cfg(feature = "mpi")]
4048        let comm = UniverseComm::Mpi(Arc::new(MpiComm::new()));
4049        #[cfg(not(feature = "mpi"))]
4050        let comm = UniverseComm::NoComm(NoComm);
4051
4052        let op_local = SparseCsrMatrix::from_csr(
4053            2,
4054            2,
4055            vec![0, 1, 2],
4056            vec![0, 1],
4057            vec![S::from_parts(2.0, 0.0), S::from_parts(3.0, 0.0)],
4058        );
4059        let row_part = vec![0, 2];
4060        let op = DistCsrOp::from_local_rows(2, 0, &op_local, &row_part, comm.clone())
4061            .expect("build local dist op");
4062
4063        let rectangular_pc = SparseCsrMatrix::from_csr(
4064            2,
4065            3,
4066            vec![0, 1, 2],
4067            vec![0, 1],
4068            vec![S::from_parts(2.0, 0.0), S::from_parts(3.0, 0.0)],
4069        );
4070        let problem = Problem {
4071            op: Arc::new(op),
4072            dist_plan_diagnostics: DistributedPlanDiagnostics {
4073                overlap_mode: kryst::matrix::dist_csr::HaloOverlapMode::Disabled,
4074                kernel_strategy: kryst::matrix::dist_csr::DistLocalKernelStrategy::RowSplitScalar,
4075                local_spmv_kernel: None,
4076                row_locality_ratio: 1.0,
4077                border_ratio: 0.0,
4078                halo_recv_volume: 0,
4079                halo_send_volume: 0,
4080                expected_communication_fraction: 0.0,
4081                expected_computation_fraction: 1.0,
4082            },
4083            rhs: vec![S::from_parts(2.0, 0.0), S::from_parts(3.0, 0.0)],
4084            rhs_source: RhsSource::GeneratedAOnes,
4085            solution_reference: solution_reference_diagnostics(&op_local),
4086            csr_for_pc: Arc::new(rectangular_pc),
4087            global_csr: Arc::new(op_local.clone()),
4088            local_rows_nnz: op_local.values().len(),
4089            zero_global_rows_local: count_zero_rows(&op_local),
4090            local_n: 2,
4091            global_n: 2,
4092            global_row_start: 0,
4093            comm,
4094            backend: CsrBackend::Serial,
4095            backend_descr: "unit-test".to_string(),
4096            generated_case: false,
4097        };
4098        let spec = RunSpec {
4099            restart: 5,
4100            ksp: KspKind::Gmres,
4101            variant: FgmresVariant::Classical,
4102            residual_check_policy: ResidualCheckPolicy::OnConvergence,
4103            orthog: OrthogMethod::ClassicalGS,
4104            reorth: ReorthPolicy::IfNeeded,
4105            pc_side: PcSide::Right,
4106            pc: PcKind::ReplicatedFullIlu0,
4107        };
4108        let bench_cfg = BenchmarkConfig {
4109            warmup_runs: 0,
4110            measured_runs: 1,
4111            maxits: 10,
4112            ..BenchmarkConfig::default()
4113        };
4114
4115        run_once(&problem, &spec, &bench_cfg)
4116            .expect("replicated full ILU should set up from global CSR, not local PC CSR");
4117    }
4118
4119    #[test]
4120    fn local_ilu_rejects_rectangular_owned_block() {
4121        #[cfg(feature = "mpi")]
4122        let comm = UniverseComm::Mpi(Arc::new(MpiComm::new()));
4123        #[cfg(not(feature = "mpi"))]
4124        let comm = UniverseComm::NoComm(NoComm);
4125
4126        let op_local =
4127            SparseCsrMatrix::from_csr(2, 2, vec![0, 1, 2], vec![0, 1], vec![S::one(), S::one()]);
4128        let row_part = vec![0, 2];
4129        let op = DistCsrOp::from_local_rows(2, 0, &op_local, &row_part, comm.clone())
4130            .expect("build local dist op");
4131
4132        let rectangular_pc =
4133            SparseCsrMatrix::from_csr(2, 3, vec![0, 1, 2], vec![0, 1], vec![S::one(), S::one()]);
4134        let problem = Problem {
4135            op: Arc::new(op),
4136            dist_plan_diagnostics: DistributedPlanDiagnostics {
4137                overlap_mode: kryst::matrix::dist_csr::HaloOverlapMode::Disabled,
4138                kernel_strategy: kryst::matrix::dist_csr::DistLocalKernelStrategy::RowSplitScalar,
4139                local_spmv_kernel: None,
4140                row_locality_ratio: 1.0,
4141                border_ratio: 0.0,
4142                halo_recv_volume: 0,
4143                halo_send_volume: 0,
4144                expected_communication_fraction: 0.0,
4145                expected_computation_fraction: 1.0,
4146            },
4147            rhs: vec![S::one(), S::one()],
4148            rhs_source: RhsSource::GeneratedAOnes,
4149            solution_reference: solution_reference_diagnostics(&rectangular_pc),
4150            csr_for_pc: Arc::new(rectangular_pc.clone()),
4151            global_csr: Arc::new(op_local.clone()),
4152            local_rows_nnz: op_local.values().len(),
4153            zero_global_rows_local: count_zero_rows(&op_local),
4154            local_n: 2,
4155            global_n: 2,
4156            global_row_start: 0,
4157            comm,
4158            backend: CsrBackend::Serial,
4159            backend_descr: "unit-test".to_string(),
4160            generated_case: false,
4161        };
4162        let spec = RunSpec {
4163            restart: 10,
4164            ksp: KspKind::Gmres,
4165            variant: FgmresVariant::Classical,
4166            residual_check_policy: ResidualCheckPolicy::OnConvergence,
4167            orthog: OrthogMethod::ClassicalGS,
4168            reorth: ReorthPolicy::IfNeeded,
4169            pc_side: PcSide::Right,
4170            pc: PcKind::Ilu0Local,
4171        };
4172        let bench_cfg = BenchmarkConfig {
4173            warmup_runs: 0,
4174            measured_runs: 1,
4175            ..BenchmarkConfig::default()
4176        };
4177
4178        match run_once(&problem, &spec, &bench_cfg) {
4179            Err(KError::InvalidInput(msg)) => {
4180                assert_eq!(msg, "local ILU requires square owned block");
4181            }
4182            Err(other) => panic!("unexpected error variant: {other:?}"),
4183            Ok(_) => panic!("expected rectangular ILU error"),
4184        }
4185    }
4186
4187    #[test]
4188    fn run_once_reports_requested_and_effective_policy_after_dist_policy_mutation() {
4189        #[cfg(feature = "mpi")]
4190        let comm = UniverseComm::Mpi(Arc::new(MpiComm::new()));
4191        #[cfg(not(feature = "mpi"))]
4192        let comm = UniverseComm::NoComm(NoComm);
4193
4194        let op_local =
4195            SparseCsrMatrix::from_csr(2, 2, vec![0, 1, 2], vec![0, 1], vec![S::one(), S::one()]);
4196        let row_part = vec![0, 2];
4197        let op = DistCsrOp::from_local_rows(2, 0, &op_local, &row_part, comm.clone())
4198            .expect("build local dist op");
4199
4200        let problem = Problem {
4201            op: Arc::new(op),
4202            dist_plan_diagnostics: DistributedPlanDiagnostics {
4203                overlap_mode: kryst::matrix::dist_csr::HaloOverlapMode::Disabled,
4204                kernel_strategy: kryst::matrix::dist_csr::DistLocalKernelStrategy::RowSplitScalar,
4205                local_spmv_kernel: None,
4206                row_locality_ratio: 1.0,
4207                border_ratio: 0.0,
4208                halo_recv_volume: 0,
4209                halo_send_volume: 0,
4210                expected_communication_fraction: 0.1,
4211                expected_computation_fraction: 0.5,
4212            },
4213            rhs: vec![S::one(), S::one()],
4214            rhs_source: RhsSource::GeneratedAOnes,
4215            solution_reference: solution_reference_diagnostics(&op_local),
4216            csr_for_pc: Arc::new(op_local.clone()),
4217            global_csr: Arc::new(op_local.clone()),
4218            local_rows_nnz: 2,
4219            zero_global_rows_local: count_zero_rows(&op_local),
4220            local_n: 2,
4221            global_n: 2,
4222            global_row_start: 0,
4223            comm,
4224            backend: CsrBackend::Serial,
4225            backend_descr: "unit-test".to_string(),
4226            generated_case: false,
4227        };
4228        let spec = RunSpec {
4229            restart: 5,
4230            ksp: KspKind::Fgmres,
4231            variant: FgmresVariant::Classical,
4232            residual_check_policy: ResidualCheckPolicy::OnConvergence,
4233            orthog: OrthogMethod::ClassicalGS,
4234            reorth: ReorthPolicy::IfNeeded,
4235            pc_side: PcSide::Right,
4236            pc: PcKind::None,
4237        };
4238        let bench_cfg = BenchmarkConfig {
4239            warmup_runs: 0,
4240            measured_runs: 1,
4241            ksp: KspKind::Fgmres,
4242            dist_policy: DistPolicyMode::Auto,
4243            ..BenchmarkConfig::default()
4244        };
4245
4246        let row = run_once(&problem, &spec, &bench_cfg).expect("run once");
4247        assert!(row.requested_policy.contains("restart=5"));
4248        assert!(
4249            row.requested_policy
4250                .contains("residual-check=on-convergence")
4251        );
4252        assert!(row.effective_policy.contains("restart=16"));
4253        assert!(
4254            row.effective_policy
4255                .contains("residual-check=every-iteration")
4256        );
4257
4258        let rendered = render_result_row(&row, RunMode::Correctness, &problem);
4259        assert!(rendered.contains(&row.requested_policy));
4260        assert!(rendered.contains(&row.effective_policy));
4261        assert_eq!(row.pc_apply, "none");
4262        assert_eq!(row.dist_ok, Some(true));
4263        assert_eq!(row.global_ok, Some(true));
4264        assert_eq!(row.x_ok, Some(true));
4265        assert!(rendered.contains("OK       OK       OK"));
4266        assert!(rendered.contains("csr-cx"));
4267        assert!(rendered.contains("ser"));
4268        assert!(rendered.contains("none"));
4269    }
4270
4271    #[test]
4272    fn generated_aones_empty_row_suppresses_x_error_rel() {
4273        #[cfg(feature = "mpi")]
4274        let comm = UniverseComm::Mpi(Arc::new(MpiComm::new()));
4275        #[cfg(not(feature = "mpi"))]
4276        let comm = UniverseComm::NoComm(NoComm);
4277
4278        let op_local = SparseCsrMatrix::from_csr(2, 2, vec![0, 1, 1], vec![0], vec![S::one()]);
4279        let row_part = vec![0, 2];
4280        let op = DistCsrOp::from_local_rows(2, 0, &op_local, &row_part, comm.clone())
4281            .expect("build local dist op");
4282        let diagnostics = solution_reference_diagnostics(&op_local);
4283        assert_eq!(diagnostics.zero_row_count, 1);
4284        assert_eq!(diagnostics.missing_diagonal_count, 1);
4285        assert!(!diagnostics.valid_for_x_error());
4286
4287        let problem = Problem {
4288            op: Arc::new(op),
4289            dist_plan_diagnostics: DistributedPlanDiagnostics {
4290                overlap_mode: kryst::matrix::dist_csr::HaloOverlapMode::Disabled,
4291                kernel_strategy: kryst::matrix::dist_csr::DistLocalKernelStrategy::RowSplitScalar,
4292                local_spmv_kernel: None,
4293                row_locality_ratio: 1.0,
4294                border_ratio: 0.0,
4295                halo_recv_volume: 0,
4296                halo_send_volume: 0,
4297                expected_communication_fraction: 0.0,
4298                expected_computation_fraction: 1.0,
4299            },
4300            rhs: vec![S::one(), S::zero()],
4301            rhs_source: RhsSource::GeneratedAOnes,
4302            solution_reference: diagnostics,
4303            csr_for_pc: Arc::new(op_local.clone()),
4304            global_csr: Arc::new(op_local.clone()),
4305            local_rows_nnz: 1,
4306            zero_global_rows_local: count_zero_rows(&op_local),
4307            local_n: 2,
4308            global_n: 2,
4309            global_row_start: 0,
4310            comm,
4311            backend: CsrBackend::Serial,
4312            backend_descr: "unit-test".to_string(),
4313            generated_case: false,
4314        };
4315        let spec = RunSpec {
4316            restart: 5,
4317            ksp: KspKind::Gmres,
4318            variant: FgmresVariant::Classical,
4319            residual_check_policy: ResidualCheckPolicy::OnConvergence,
4320            orthog: OrthogMethod::ClassicalGS,
4321            reorth: ReorthPolicy::IfNeeded,
4322            pc_side: PcSide::Right,
4323            pc: PcKind::None,
4324        };
4325        let bench_cfg = BenchmarkConfig {
4326            run_mode: RunMode::Correctness,
4327            warmup_runs: 0,
4328            measured_runs: 1,
4329            ..BenchmarkConfig::default()
4330        };
4331
4332        let row = run_once(&problem, &spec, &bench_cfg).expect("run once");
4333        assert_eq!(problem.rhs_source, RhsSource::GeneratedAOnes);
4334        assert_eq!(row.x_error_rel, None);
4335        assert_eq!(row.global_x_error_rel, None);
4336        assert_eq!(row.x_ok, None);
4337        let rendered = render_result_row(&row, RunMode::Correctness, &problem);
4338        assert!(rendered.contains("OK       OK      N/A"));
4339    }
4340
4341    #[test]
4342    fn correctness_default_dist_policy_off_preserves_requested_restart() {
4343        #[cfg(feature = "mpi")]
4344        let comm = UniverseComm::Mpi(Arc::new(MpiComm::new()));
4345        #[cfg(not(feature = "mpi"))]
4346        let comm = UniverseComm::NoComm(NoComm);
4347
4348        let op_local =
4349            SparseCsrMatrix::from_csr(2, 2, vec![0, 1, 2], vec![0, 1], vec![S::one(), S::one()]);
4350        let row_part = vec![0, 2];
4351        let op = DistCsrOp::from_local_rows(2, 0, &op_local, &row_part, comm.clone())
4352            .expect("build local dist op");
4353
4354        let problem = Problem {
4355            op: Arc::new(op),
4356            dist_plan_diagnostics: DistributedPlanDiagnostics {
4357                overlap_mode: kryst::matrix::dist_csr::HaloOverlapMode::Disabled,
4358                kernel_strategy: kryst::matrix::dist_csr::DistLocalKernelStrategy::RowSplitScalar,
4359                local_spmv_kernel: None,
4360                row_locality_ratio: 1.0,
4361                border_ratio: 0.0,
4362                halo_recv_volume: 0,
4363                halo_send_volume: 0,
4364                expected_communication_fraction: 0.1,
4365                expected_computation_fraction: 0.5,
4366            },
4367            rhs: vec![S::one(), S::one()],
4368            rhs_source: RhsSource::GeneratedAOnes,
4369            solution_reference: solution_reference_diagnostics(&op_local),
4370            csr_for_pc: Arc::new(op_local.clone()),
4371            global_csr: Arc::new(op_local.clone()),
4372            local_rows_nnz: 2,
4373            zero_global_rows_local: count_zero_rows(&op_local),
4374            local_n: 2,
4375            global_n: 2,
4376            global_row_start: 0,
4377            comm,
4378            backend: CsrBackend::Serial,
4379            backend_descr: "unit-test".to_string(),
4380            generated_case: false,
4381        };
4382        let spec = RunSpec {
4383            restart: 5,
4384            ksp: KspKind::Gmres,
4385            variant: FgmresVariant::Classical,
4386            residual_check_policy: ResidualCheckPolicy::OnConvergence,
4387            orthog: OrthogMethod::ClassicalGS,
4388            reorth: ReorthPolicy::IfNeeded,
4389            pc_side: PcSide::Right,
4390            pc: PcKind::None,
4391        };
4392        let bench_cfg = BenchmarkConfig {
4393            run_mode: RunMode::Correctness,
4394            warmup_runs: 0,
4395            measured_runs: 1,
4396            ..BenchmarkConfig::default()
4397        };
4398
4399        assert_eq!(bench_cfg.dist_policy, DistPolicyMode::Off);
4400        let row = run_once(&problem, &spec, &bench_cfg).expect("run once");
4401        assert!(row.requested_policy.contains("restart=5"));
4402        assert!(row.effective_policy.contains("restart=5"));
4403        assert!(
4404            row.effective_policy
4405                .contains("residual-check=on-convergence")
4406        );
4407    }
4408
4409    #[test]
4410    fn metadata_labels_snapshot_serial() {
4411        assert_eq!(
4412            pc_domain_label_for_size(PcKind::ReplicatedFullIlu0, 1),
4413            "full"
4414        );
4415        assert_eq!(
4416            pc_domain_label_for_size(PcKind::ReplicatedFullIluk { k: 1 }, 1),
4417            "full"
4418        );
4419        assert_eq!(pc_domain_label_for_size(PcKind::Ilu0Local, 1), "full");
4420        assert_eq!(pc_domain_label_for_size(PcKind::IlutLocal, 1), "full");
4421        assert_eq!(
4422            pc_domain_label_for_size(PcKind::LocalIluk { k: 1 }, 1),
4423            "full"
4424        );
4425        assert_eq!(
4426            pc_domain_label_for_size(PcKind::MpiBlockJacobiIlu0Local, 1),
4427            "own0"
4428        );
4429        assert_eq!(pc_domain_label_for_size(PcKind::None, 1), "n/a");
4430        assert_eq!(pc_domain_label_for_size(PcKind::JacobiWeak, 1), "n/a");
4431        assert_eq!(
4432            pc_apply_label_for_size(PcKind::ReplicatedFullIlu0, 1),
4433            "replicated/full"
4434        );
4435    }
4436
4437    #[test]
4438    fn metadata_labels_snapshot_mpi() {
4439        assert_eq!(
4440            pc_domain_label_for_size(PcKind::ReplicatedFullIlu0, 2),
4441            "full"
4442        );
4443        assert_eq!(
4444            pc_apply_label_for_size(PcKind::ReplicatedFullIlu0, 2),
4445            "replicated/full/allgather, not scalable"
4446        );
4447        assert_eq!(
4448            pc_domain_label_for_size(PcKind::ReplicatedFullIluk { k: 1 }, 2),
4449            "full"
4450        );
4451        assert_eq!(
4452            pc_apply_label_for_size(PcKind::ReplicatedFullIluk { k: 1 }, 2),
4453            "replicated/full/allgather, not scalable"
4454        );
4455        assert_eq!(pc_domain_label_for_size(PcKind::Ilu0Local, 2), "own0");
4456        assert_eq!(pc_domain_label_for_size(PcKind::IlutLocal, 2), "own0");
4457        assert_eq!(
4458            pc_domain_label_for_size(PcKind::LocalIluk { k: 1 }, 2),
4459            "own0"
4460        );
4461        assert_eq!(
4462            pc_domain_label_for_size(PcKind::MpiBlockJacobiIlu0Local, 2),
4463            "own0"
4464        );
4465        assert_eq!(
4466            pc_apply_label_for_size(PcKind::Ilu0Local, 2),
4467            "owned-block/overlap0"
4468        );
4469        assert_eq!(
4470            pc_domain_label_for_size(PcKind::AsmIlu0Overlap { overlap: 1 }, 2),
4471            "own+ghost1"
4472        );
4473        assert_eq!(
4474            pc_domain_label_for_size(PcKind::AsmIlu0Overlap { overlap: 2 }, 2),
4475            "own+ghost2"
4476        );
4477        assert_eq!(
4478            pc_apply_label_for_size(PcKind::AsmIlu0Overlap { overlap: 1 }, 2),
4479            "asm/overlap-gather/owned-output"
4480        );
4481        assert_eq!(
4482            pc_apply_label_for_size(PcKind::RasIlu0Overlap { overlap: 1 }, 2),
4483            "ras/overlap-gather/restricted-owned"
4484        );
4485        assert_eq!(pc_domain_label_for_size(PcKind::None, 2), "n/a");
4486        assert_eq!(pc_apply_label_for_size(PcKind::None, 2), "none");
4487        assert_eq!(
4488            pc_apply_label_for_size(PcKind::JacobiWeak, 2),
4489            "jacobi/local"
4490        );
4491    }
4492
4493    #[test]
4494    fn row_scaled_mode_scales_pc_setup_matrix_rows() {
4495        let pc = SparseCsrMatrix::from_csr(
4496            2,
4497            2,
4498            vec![0, 2, 4],
4499            vec![0, 1, 0, 1],
4500            vec![
4501                S::new(2.0, 0.0),
4502                S::new(0.0, 0.0),
4503                S::new(0.0, 0.0),
4504                S::new(4.0, 0.0),
4505            ],
4506        );
4507        let row_scale = compute_row_scaling(&pc, 1e-15);
4508        let pc_scaled = scale_csr_rows(&pc, &row_scale);
4509
4510        assert_eq!(row_scale, vec![0.5, 0.25]);
4511        assert_eq!(pc_scaled.values()[0], S::one());
4512        assert_eq!(pc_scaled.values()[3], S::one());
4513        assert_ne!(pc_scaled.values(), pc.values());
4514    }
4515
4516    #[test]
4517    fn mpi_mode_run_matrix_has_no_duplicate_semantic_pc_experiments() {
4518        let cfg = BenchmarkConfig::default();
4519        let mpi_mode = true;
4520        let pcs = if cfg.pcs.is_empty() {
4521            match cfg.run_mode {
4522                RunMode::Correctness => {
4523                    if mpi_mode {
4524                        vec![
4525                            PcKind::ReplicatedFullIlu0,
4526                            PcKind::None,
4527                            PcKind::JacobiWeak,
4528                            PcKind::MpiBlockJacobiIlu0Local,
4529                        ]
4530                    } else {
4531                        vec![PcKind::Ilu0Local, PcKind::JacobiWeak, PcKind::None]
4532                    }
4533                }
4534                RunMode::Scalability => {
4535                    if mpi_mode {
4536                        vec![PcKind::MpiBlockJacobiIlu0Local]
4537                    } else {
4538                        vec![PcKind::Ilu0Local]
4539                    }
4540                }
4541                RunMode::Robustness => {
4542                    if mpi_mode {
4543                        vec![PcKind::MpiBlockJacobiIlu0Local, PcKind::JacobiWeak]
4544                    } else {
4545                        vec![PcKind::Ilu0Local, PcKind::JacobiWeak]
4546                    }
4547                }
4548            }
4549        } else {
4550            cfg.pcs.clone()
4551        };
4552
4553        assert_eq!(
4554            pcs,
4555            vec![
4556                PcKind::ReplicatedFullIlu0,
4557                PcKind::None,
4558                PcKind::JacobiWeak,
4559                PcKind::MpiBlockJacobiIlu0Local,
4560            ]
4561        );
4562
4563        let mut seen = std::collections::BTreeSet::new();
4564        for &restart in &cfg.restarts {
4565            for &variant in &cfg.variants {
4566                for &orthog in &cfg.orthogs {
4567                    for &reorth in &cfg.reorths {
4568                        for &pc in &pcs {
4569                            let key = format!(
4570                                "restart={restart}|variant={}|orthog={}|reorth={}|pc={}",
4571                                variant_label(variant),
4572                                orthog_label(orthog),
4573                                reorth_label(reorth),
4574                                pc.semantic_experiment_key(mpi_mode)
4575                            );
4576                            assert!(
4577                                seen.insert(key.clone()),
4578                                "duplicate semantic benchmark row: {key}"
4579                            );
4580                        }
4581                    }
4582                }
4583            }
4584        }
4585    }
4586
4587    #[test]
4588    fn parse_pc_accepts_documented_sweep_names() {
4589        assert_eq!(
4590            parse_pc_csv(
4591                "--pcs",
4592                "replicated-full-ilu0,none,jacobi,block-jacobi-ilu0"
4593            )
4594            .expect("parse documented correctness preconditioners"),
4595            vec![
4596                PcKind::ReplicatedFullIlu0,
4597                PcKind::None,
4598                PcKind::JacobiWeak,
4599                PcKind::MpiBlockJacobiIlu0Local,
4600            ]
4601        );
4602        assert_eq!(
4603            parse_pc_csv("--pcs", "none,jacobi,block-jacobi-ilu0")
4604                .expect("parse documented scalability preconditioners"),
4605            vec![
4606                PcKind::None,
4607                PcKind::JacobiWeak,
4608                PcKind::MpiBlockJacobiIlu0Local,
4609            ]
4610        );
4611    }
4612
4613    #[test]
4614    fn parse_pc_accepts_replicated_full_ilu0_alias() {
4615        assert_eq!(
4616            parse_pc("replicated-full-ilu0").expect("parse replicated-full-ilu0"),
4617            PcKind::ReplicatedFullIlu0
4618        );
4619        assert_eq!(
4620            parse_pc("replicated-ilu0").expect("parse replicated-ilu0"),
4621            PcKind::ReplicatedFullIlu0
4622        );
4623    }
4624
4625    #[test]
4626    fn parse_pc_accepts_block_jacobi_iluk_aliases() {
4627        assert_eq!(
4628            parse_pc("block-jacobi-iluk1").expect("parse block-jacobi-iluk1"),
4629            PcKind::LocalIluk { k: 1 }
4630        );
4631        assert_eq!(
4632            parse_pc("block-jacobi-iluk1-overlap0").expect("parse block-jacobi-iluk1-overlap0"),
4633            PcKind::LocalIluk { k: 1 }
4634        );
4635        assert_eq!(
4636            parse_pc("block-jacobi-iluk:2").expect("parse block-jacobi-iluk:2"),
4637            PcKind::LocalIluk { k: 2 }
4638        );
4639        assert_eq!(
4640            parse_pc("block-jacobi-iluk3-overlap0").expect("parse block-jacobi-iluk3-overlap0"),
4641            PcKind::LocalIluk { k: 3 }
4642        );
4643    }
4644
4645    #[test]
4646    fn parse_pc_accepts_overlap_aliases() {
4647        assert_eq!(
4648            parse_pc("asm-ilu0-overlap1").expect("parse asm-ilu0-overlap1"),
4649            PcKind::AsmIlu0Overlap { overlap: 1 }
4650        );
4651        assert_eq!(
4652            parse_pc("asm-ilu0-overlap2").expect("parse asm-ilu0-overlap2"),
4653            PcKind::AsmIlu0Overlap { overlap: 2 }
4654        );
4655        assert_eq!(
4656            parse_pc("ras-ilu0-overlap1").expect("parse ras-ilu0-overlap1"),
4657            PcKind::RasIlu0Overlap { overlap: 1 }
4658        );
4659        assert_eq!(
4660            parse_pc("ras-iluk1-overlap1").expect("parse ras-iluk1-overlap1"),
4661            PcKind::RasIlukOverlap { k: 1, overlap: 1 }
4662        );
4663    }
4664
4665    #[test]
4666    fn local_iluk_label_renders_block_jacobi_alias() {
4667        assert_eq!(
4668            PcKind::LocalIluk { k: 3 }.label(),
4669            "block-jacobi-iluk3-overlap0"
4670        );
4671    }
4672
4673    #[test]
4674    fn preconditioner_dispatch_variants_are_distinct_or_explicit_aliases() {
4675        let variants = [
4676            PcKind::None,
4677            PcKind::JacobiWeak,
4678            PcKind::Ilu0Local,
4679            PcKind::IlutLocal,
4680            PcKind::MpiBlockJacobiIlu0Local,
4681        ];
4682
4683        for (idx, left) in variants.iter().copied().enumerate() {
4684            for right in variants.iter().copied().skip(idx + 1) {
4685                if left.dispatch_branch() == right.dispatch_branch() {
4686                    let explicit_alias = left.explicit_alias_of() == Some(right)
4687                        || right.explicit_alias_of() == Some(left);
4688                    assert!(
4689                        explicit_alias,
4690                        "dispatch collision without alias marker: {left:?} vs {right:?}"
4691                    );
4692                }
4693            }
4694        }
4695    }
4696
4697    #[test]
4698    fn method_label_distinguishes_gmres_and_fgmres() {
4699        let base = RunSpec {
4700            restart: 50,
4701            ksp: KspKind::Gmres,
4702            variant: FgmresVariant::Classical,
4703            residual_check_policy: ResidualCheckPolicy::OnConvergence,
4704            orthog: OrthogMethod::ClassicalGS,
4705            reorth: ReorthPolicy::IfNeeded,
4706            pc_side: PcSide::Right,
4707            pc: PcKind::JacobiWeak,
4708        };
4709        assert!(base.method_label().starts_with("GMRES+"));
4710
4711        let flexible = RunSpec {
4712            ksp: KspKind::Fgmres,
4713            ..base
4714        };
4715        assert!(flexible.method_label().starts_with("FGMRES+"));
4716    }
4717
4718    #[test]
4719    fn fixed_pc_default_run_specs_select_gmres() {
4720        #[cfg(feature = "mpi")]
4721        let comm = UniverseComm::Mpi(Arc::new(MpiComm::new()));
4722        #[cfg(not(feature = "mpi"))]
4723        let comm = UniverseComm::NoComm(NoComm);
4724
4725        let problem = build_shifted_poisson_case(&comm, 4, 4, 1.0, 0.25, 0.0, 0.0)
4726            .expect("build generated shifted Poisson problem");
4727        let cfg = BenchmarkConfig {
4728            restarts: vec![8],
4729            pcs: vec![PcKind::None, PcKind::JacobiWeak, PcKind::Ilu0Local],
4730            ..BenchmarkConfig::default()
4731        };
4732        let runs = RunSpec::build_default_matrix(&cfg, &problem);
4733        assert_eq!(runs.len(), 3);
4734        assert!(runs.iter().all(|spec| spec.ksp == KspKind::Gmres));
4735    }
4736
4737    #[test]
4738    fn ilut_complex_label_explicitly_marks_real_projection_fallback_path() {
4739        let label = PcKind::IlutLocal.label();
4740        assert!(label.contains(ILUT_REAL_PROJECTION_FALLBACK_LABEL));
4741        assert!(label.contains("degraded/provisional"));
4742        assert!(label.contains("real-projection fallback"));
4743    }
4744
4745    #[test]
4746    fn ilut_method_row_and_export_labels_are_self_contained() {
4747        let spec = RunSpec {
4748            restart: 50,
4749            ksp: KspKind::Gmres,
4750            variant: FgmresVariant::Classical,
4751            residual_check_policy: ResidualCheckPolicy::OnConvergence,
4752            orthog: OrthogMethod::ClassicalGS,
4753            reorth: ReorthPolicy::IfNeeded,
4754            pc_side: PcSide::Right,
4755            pc: PcKind::IlutLocal,
4756        };
4757
4758        assert!(
4759            spec.method_label()
4760                .contains(ILUT_REAL_PROJECTION_FALLBACK_LABEL)
4761        );
4762        assert!(
4763            PcKind::IlutLocal
4764                .semantic_experiment_key(false)
4765                .contains("real-projection-fallback")
4766        );
4767    }
4768
4769    #[test]
4770    fn correctness_mode_disables_stagnation_fallback_and_keeps_restart_target() {
4771        let spec = RunSpec {
4772            restart: 50,
4773            ksp: KspKind::Gmres,
4774            variant: FgmresVariant::Classical,
4775            residual_check_policy: ResidualCheckPolicy::OnConvergence,
4776            orthog: OrthogMethod::ClassicalGS,
4777            reorth: ReorthPolicy::IfNeeded,
4778            pc_side: PcSide::Right,
4779            pc: PcKind::None,
4780        };
4781        let cfg = BenchmarkConfig {
4782            run_mode: RunMode::Correctness,
4783            allow_stagnation_fallback: true,
4784            min_inner_before_fallback: 12,
4785            ..BenchmarkConfig::default()
4786        };
4787        let solver = configured_fgmres_solver(&spec, &cfg);
4788        assert_eq!(solver.restart, 50);
4789        assert_eq!(solver.stagnation_policy, FgmresStagnationPolicy::Disabled);
4790        assert_eq!(solver.min_inner_before_fallback, 12);
4791    }
4792
4793    #[test]
4794    fn benchmark_config_defaults_dist_policy_from_run_mode() {
4795        let default_cfg = BenchmarkConfig::default();
4796        assert_eq!(default_cfg.dist_policy, DistPolicyMode::Off);
4797
4798        let scalability_cfg =
4799            BenchmarkConfig::from_args(vec!["--mode".to_string(), "scalability".to_string()])
4800                .expect("parse scalability mode");
4801        assert_eq!(scalability_cfg.dist_policy, DistPolicyMode::Auto);
4802
4803        let robustness_cfg =
4804            BenchmarkConfig::from_args(vec!["--mode".to_string(), "robustness".to_string()])
4805                .expect("parse robustness mode");
4806        assert_eq!(robustness_cfg.dist_policy, DistPolicyMode::Off);
4807    }
4808
4809    #[test]
4810    fn benchmark_config_parses_ksp_selection() {
4811        let gmres_cfg = BenchmarkConfig::from_args(vec!["--ksp".to_string(), "gmres".to_string()])
4812            .expect("parse --ksp gmres");
4813        assert_eq!(gmres_cfg.ksp, KspKind::Gmres);
4814
4815        let fgmres_cfg =
4816            BenchmarkConfig::from_args(vec!["--ksp".to_string(), "fgmres".to_string()])
4817                .expect("parse --ksp fgmres");
4818        assert_eq!(fgmres_cfg.ksp, KspKind::Fgmres);
4819    }
4820
4821    #[test]
4822    fn benchmark_config_rejects_invalid_ksp_selection() {
4823        let err = BenchmarkConfig::from_args(vec!["--ksp".to_string(), "bicgstab".to_string()])
4824            .expect_err("invalid --ksp should be rejected");
4825        match err {
4826            KError::InvalidInput(msg) => assert!(msg.contains("--ksp") || msg.contains("KSP")),
4827            other => panic!("unexpected error variant: {other:?}"),
4828        }
4829    }
4830
4831    #[test]
4832    fn benchmark_config_parses_explicit_dist_policy_override() {
4833        let cfg = BenchmarkConfig::from_args(vec![
4834            "--mode".to_string(),
4835            "scalability".to_string(),
4836            "--dist-policy".to_string(),
4837            "off".to_string(),
4838        ])
4839        .expect("parse explicit dist policy");
4840        assert_eq!(cfg.dist_policy, DistPolicyMode::Off);
4841    }
4842
4843    #[test]
4844    fn benchmark_config_parses_poisson_shift_and_convection_flags() {
4845        let cfg = BenchmarkConfig::from_args(vec![
4846            "--poisson-shift-real".to_string(),
4847            "0.01".to_string(),
4848            "--poisson-shift-imag".to_string(),
4849            "-0.5".to_string(),
4850            "--poisson-convection".to_string(),
4851            "1.25,-2.5".to_string(),
4852        ])
4853        .expect("parse shifted Poisson overrides");
4854
4855        assert_eq!(cfg.poisson_shift_real, 0.01);
4856        assert_eq!(cfg.poisson_shift_imag, -0.5);
4857        assert_eq!(cfg.poisson_convection_x, 1.25);
4858        assert_eq!(cfg.poisson_convection_y, -2.5);
4859    }
4860
4861    #[test]
4862    fn benchmark_config_rejects_invalid_poisson_convection_flag() {
4863        let err = BenchmarkConfig::from_args(vec![
4864            "--poisson-convection".to_string(),
4865            "1.0;2.0".to_string(),
4866        ])
4867        .expect_err("invalid convection pair should be rejected");
4868        match err {
4869            KError::InvalidInput(msg) => assert!(msg.contains("--poisson-convection")),
4870            other => panic!("unexpected error variant: {other:?}"),
4871        }
4872    }
4873
4874    #[test]
4875    fn benchmark_config_parses_block_jacobi_iluk_overlap_pc() {
4876        let cfg = BenchmarkConfig::from_args(vec![
4877            "--pcs".to_string(),
4878            "block-jacobi-iluk1-overlap0".to_string(),
4879        ])
4880        .expect("parse block Jacobi ILU(k) pc alias");
4881
4882        assert_eq!(cfg.pcs, vec![PcKind::LocalIluk { k: 1 }]);
4883    }
4884
4885    #[test]
4886    fn benchmark_config_uses_conservative_default_fgmres_haptol() {
4887        let cfg = BenchmarkConfig::default();
4888        assert_eq!(cfg.fgmres_haptol, 1e-30);
4889    }
4890
4891    #[test]
4892    fn benchmark_config_parses_fgmres_haptol_override() {
4893        let cfg =
4894            BenchmarkConfig::from_args(vec!["--fgmres-haptol".to_string(), "1e-22".to_string()])
4895                .expect("parse args");
4896        assert_eq!(cfg.fgmres_haptol, 1e-22);
4897    }
4898
4899    #[test]
4900    fn benchmark_config_rejects_non_positive_or_non_finite_fgmres_haptol() {
4901        for bad in ["0", "-1", "NaN", "inf"] {
4902            let err =
4903                BenchmarkConfig::from_args(vec!["--fgmres-haptol".to_string(), bad.to_string()])
4904                    .expect_err("invalid --fgmres-haptol should be rejected");
4905            match err {
4906                KError::InvalidInput(msg) => assert!(msg.contains("--fgmres-haptol")),
4907                other => panic!("unexpected error variant: {other:?}"),
4908            }
4909        }
4910    }
4911
4912    #[test]
4913    fn benchmark_config_accepts_include_overlap_pcs_flag() {
4914        let cfg = BenchmarkConfig::from_args(vec!["--include-overlap-pcs".to_string()])
4915            .expect("parse args");
4916        assert!(cfg.include_overlap_pcs);
4917    }
4918
4919    #[test]
4920    fn include_overlap_pcs_is_limited_to_mpi_correctness_mode() {
4921        let correctness_cfg = BenchmarkConfig {
4922            include_overlap_pcs: true,
4923            run_mode: RunMode::Correctness,
4924            ..BenchmarkConfig::default()
4925        };
4926        assert!(should_include_overlap_pcs(&correctness_cfg, 4));
4927        assert!(!should_include_overlap_pcs(&correctness_cfg, 1));
4928
4929        let scalability_cfg = BenchmarkConfig {
4930            include_overlap_pcs: true,
4931            run_mode: RunMode::Scalability,
4932            ..BenchmarkConfig::default()
4933        };
4934        assert!(!should_include_overlap_pcs(&scalability_cfg, 4));
4935    }
4936
4937    #[test]
4938    fn include_overlap_pcs_appends_verdict_gated_mpi_rows_after_block_jacobi_baseline() {
4939        let mut pcs = vec![PcKind::ReplicatedFullIlu0, PcKind::None, PcKind::JacobiWeak];
4940        append_mpi_correctness_overlap_pcs(&mut pcs);
4941        assert_eq!(
4942            pcs,
4943            vec![
4944                PcKind::ReplicatedFullIlu0,
4945                PcKind::None,
4946                PcKind::JacobiWeak,
4947                PcKind::MpiBlockJacobiIlu0Local,
4948                PcKind::AsmIlu0Overlap { overlap: 1 },
4949                PcKind::RasIlu0Overlap { overlap: 1 },
4950                PcKind::RasIlukOverlap { k: 1, overlap: 1 },
4951            ]
4952        );
4953    }
4954
4955    #[test]
4956    fn overlap_pcs_dispatch_through_overlap_ilu_branch() {
4957        for pc in MPI_CORRECTNESS_OVERLAP_PCS {
4958            assert_eq!(pc.dispatch_branch(), PcDispatchBranch::OverlapIlu);
4959        }
4960    }
4961
4962    #[test]
4963    fn benchmark_config_accepts_mark_replicated_check_flag() {
4964        let cfg = BenchmarkConfig::from_args(vec!["--mark-replicated-check".to_string()])
4965            .expect("parse args");
4966        assert!(cfg.mark_replicated_check);
4967    }
4968
4969    #[test]
4970    fn benchmark_config_rejects_deprecated_correctness_replicated_check_flag() {
4971        let err = BenchmarkConfig::from_args(vec!["--correctness-replicated-check".to_string()])
4972            .expect_err("deprecated flag should be rejected");
4973        match err {
4974            KError::InvalidInput(msg) => {
4975                assert!(msg.contains("renamed to --mark-replicated-check"))
4976            }
4977            other => panic!("unexpected error variant: {other:?}"),
4978        }
4979    }
4980
4981    #[test]
4982    fn diagnostics_format_includes_global_and_rank_spread_fields() {
4983        let diag = CsrForPcDiagnosticsGlobal {
4984            nnz_local_block: rank_spread_from_locals(&[12.0, 30.0], 12.0),
4985            nnz_local_rows: rank_spread_from_locals(&[10.0, 20.0], 10.0),
4986            nnz_ratio: rank_spread_from_locals(&[1.2, 1.5], 1.2),
4987            zero_local_rows_rank_count: 1.0,
4988            zero_global_rows: rank_spread_from_locals(&[0.0, 2.0], 0.0),
4989            structural_diag_missing: rank_spread_from_locals(&[1.0, 3.0], 1.0),
4990            diag_min_abs: rank_spread_from_locals(&[1e-8, 1e-10], 1e-8),
4991            diag_max_abs: rank_spread_from_locals(&[9.0, 11.0], 9.0),
4992            diag_tiny_or_missing_count: rank_spread_from_locals(&[1.0, 4.0], 1.0),
4993            pivot_perturbation_count: Some(rank_spread_from_locals(&[0.0, 3.0], 0.0)),
4994        };
4995        let text = format_csr_for_pc_diagnostics("ilu0-local", &diag);
4996        assert!(text.contains("global_sum="));
4997        assert!(text.contains("rank_min="));
4998        assert!(text.contains("rank_max="));
4999        assert!(text.contains("rank0_local="));
5000        assert!(text.contains("nnz_ratio_global=1.400e0"));
5001        assert!(text.contains("rank_local_min=1.200e0"));
5002        assert!(text.contains("rank_local_max=1.500e0"));
5003        assert!(!text.contains("nnz_ratio:global_sum="));
5004        assert!(text.contains("zero_local_rows_rank_count=1"));
5005        assert!(text.contains("zero_global_rows:global_sum=2"));
5006        assert!(text.contains("structural_diag_missing:global_sum=4"));
5007    }
5008
5009    #[test]
5010    fn diagnostics_capture_nonzero_off_rank_contribution() {
5011        let spread = rank_spread_from_locals(&[5.0, 0.0, 7.0], 5.0);
5012        assert_eq!(spread.rank0_local, 5.0);
5013        assert_eq!(spread.global_sum, 12.0);
5014        assert!(spread.global_sum > spread.rank0_local);
5015        assert_eq!(spread.rank_max, 7.0);
5016    }
5017}
5018
5019#[cfg(feature = "complex")]
5020fn main() -> Result<(), KError> {
5021    complex_demo::run()
5022}