Skip to main content

kryst/context/
pc_context.rs

1use crate::algebra::prelude::*;
2use crate::config::kinds::SorMatSideKind;
3use crate::config::options::{KspOptions, PcOptions};
4use crate::error::KError;
5use crate::matrix::op::LinOp;
6#[cfg(feature = "backend-faer")]
7use crate::preconditioner::asm::AsmInnerPc;
8use crate::preconditioner::bddc::{BddcConstraintSelection, BddcScaling};
9use crate::preconditioner::mg::MgLevelPolicy;
10use crate::preconditioner::{PcSide, Preconditioner};
11use crate::utils::conditioning::ConditioningOptions;
12use std::str::FromStr;
13
14#[cfg(feature = "backend-faer")]
15use crate::preconditioner::amg::AMGConfig;
16#[cfg(feature = "backend-faer")]
17use crate::preconditioner::gamg::GamgConfig;
18
19#[cfg(not(feature = "backend-faer"))]
20#[derive(Clone, Debug)]
21pub struct AMGConfig;
22
23#[cfg(not(feature = "backend-faer"))]
24impl AMGConfig {
25    pub fn try_from_opts(_opts: &PcOptions) -> Result<Self, KError> {
26        Err(KError::Unsupported(
27            "AMG requires backend-faer; enable backend-faer to use AMG options",
28        ))
29    }
30}
31
32#[cfg(not(feature = "backend-faer"))]
33#[derive(Clone, Debug)]
34pub struct GamgConfig;
35
36#[cfg(not(feature = "backend-faer"))]
37impl GamgConfig {
38    pub fn try_from_opts(_opts: &PcOptions) -> Result<Self, KError> {
39        Err(KError::Unsupported(
40            "GAMG requires backend-faer; enable backend-faer to use GAMG options",
41        ))
42    }
43}
44
45#[cfg(feature = "backend-faer")]
46type MatSorSide = crate::preconditioner::sor::MatSorType;
47
48#[cfg(not(feature = "backend-faer"))]
49bitflags::bitflags! {
50    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
51    pub struct MatSorSide: u32 {
52        const APPLY_LOWER = 0b0001;
53        const APPLY_UPPER = 0b0010;
54        const SYMMETRIC_SWEEP = 0b0100;
55        const EISENSTAT = 0b1000;
56    }
57}
58
59#[cfg(not(feature = "backend-faer"))]
60#[derive(Clone, Copy, Debug, PartialEq)]
61pub enum AsmInnerPc {
62    Jacobi,
63    Ilu0,
64    Ilut {
65        drop_tol: R,
66        max_fill: usize,
67    },
68    Ilutp {
69        drop_tol: R,
70        max_fill: usize,
71        perm_tol: R,
72    },
73}
74
75#[cfg(feature = "backend-faer")]
76type ApproxInvKindAlias = crate::preconditioner::approxinv_csr::ApproxInvKind;
77#[cfg(not(feature = "backend-faer"))]
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub enum ApproxInvKindAlias {
80    FSAI,
81    SPAI,
82}
83
84#[cfg(test)]
85use std::cell::Cell;
86
87#[cfg(test)]
88thread_local! {
89    static CHAIN_STRICT_OVERRIDE: Cell<Option<bool>> = Cell::new(None);
90}
91
92#[cfg(test)]
93pub(crate) struct ChainStrictGuard(Option<bool>);
94
95#[cfg(test)]
96impl Drop for ChainStrictGuard {
97    fn drop(&mut self) {
98        CHAIN_STRICT_OVERRIDE.with(|cell| cell.set(self.0));
99    }
100}
101
102/// Supported preconditioner types.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum PcType {
105    Jacobi,
106    Ilu0,
107    None,
108    Ilu,
109    Ilut,
110    Ilutp,
111    Ilup,
112    BlockJacobi,
113    Sor,
114    Asm,
115    Chebyshev,
116    Amg,
117    ApproxInverse,
118    FieldSplit,
119    Shell,
120    Ksp,
121    Mg,
122    Bddc,
123    Gamg,
124    Lu,
125    Qr,
126    #[cfg_attr(docsrs, doc(cfg(feature = "superlu_dist")))]
127    #[cfg(feature = "superlu_dist")]
128    SuperLuDist,
129}
130
131impl FromStr for PcType {
132    type Err = KError;
133
134    fn from_str(s: &str) -> Result<Self, Self::Err> {
135        match s.to_lowercase().as_str() {
136            "jacobi" => Ok(PcType::Jacobi),
137            "ilu0" => Ok(PcType::Ilu0),
138            "none" => Ok(PcType::None),
139            "ilu" => Ok(PcType::Ilu),
140            "ilut" => Ok(PcType::Ilut),
141            "ilutp" => Ok(PcType::Ilutp),
142            "ilup" => Ok(PcType::Ilup),
143            "block_jacobi" => Ok(PcType::BlockJacobi),
144            "sor" => Ok(PcType::Sor),
145            "asm" | "ras" => Ok(PcType::Asm),
146            "chebyshev" => Ok(PcType::Chebyshev),
147            "amg" => Ok(PcType::Amg),
148            "approxinv" | "approxinverse" => Ok(PcType::ApproxInverse),
149            "fieldsplit" => Ok(PcType::FieldSplit),
150            "shell" => Ok(PcType::Shell),
151            "ksp" => Ok(PcType::Ksp),
152            "mg" => Ok(PcType::Mg),
153            "bddc" => Ok(PcType::Bddc),
154            "gamg" => Ok(PcType::Gamg),
155            "lu" => Ok(PcType::Lu),
156            "qr" => Ok(PcType::Qr),
157            "superludist" => {
158                #[cfg(feature = "superlu_dist")]
159                {
160                    Ok(PcType::SuperLuDist)
161                }
162                #[cfg(not(feature = "superlu_dist"))]
163                {
164                    Err(KError::Unsupported(
165                        "build without feature=\"superlu_dist\"".into(),
166                    ))
167                }
168            }
169            other => Err(KError::UnrecognizedPcType(other.to_string())),
170        }
171    }
172}
173
174/// Placeholder for deferred preconditioner construction info.
175#[derive(Debug, Clone)]
176pub struct DeferredPcInfo {
177    pub pc_type: PcType,
178    pub options: Option<PcOptions>,
179}
180
181/// Lightweight PC context for diagnostics and metadata reporting.
182#[derive(Debug, Clone)]
183pub struct PcContext {
184    pub pc_type: PcType,
185    pub options: Option<PcOptions>,
186}
187
188impl PcContext {
189    pub fn new(pc_type: PcType, options: Option<PcOptions>) -> Self {
190        Self { pc_type, options }
191    }
192
193    pub fn view(&self) -> crate::utils::diagnostics::PcDiagnostics {
194        crate::utils::diagnostics::PcDiagnostics::from_options(
195            Some(self.pc_type),
196            self.options.as_ref(),
197        )
198    }
199}
200
201impl From<DeferredPcInfo> for PcContext {
202    fn from(spec: DeferredPcInfo) -> Self {
203        Self::new(spec.pc_type, spec.options)
204    }
205}
206
207/// Simple no-op preconditioner.
208pub struct NoOpPreconditioner;
209
210impl Preconditioner for NoOpPreconditioner {
211    fn setup(&mut self, _a: &dyn LinOp<S = S>) -> Result<(), KError> {
212        Ok(())
213    }
214    fn apply(&self, _side: PcSide, r: &[S], z: &mut [S]) -> Result<(), KError> {
215        z.copy_from_slice(r);
216        Ok(())
217    }
218
219    fn apply_mut(&mut self, side: PcSide, x: &[S], y: &mut [S]) -> Result<(), KError> {
220        self.apply(side, x, y)
221    }
222}
223
224/// Typed configuration parsed from options.
225#[derive(Debug, Clone)]
226pub enum PcConfig {
227    None,
228    Jacobi,
229    BlockJacobi {
230        block: usize,
231    },
232    Ilu0 {
233        conditioning: ConditioningOptions,
234    },
235    Iluk {
236        level: usize,
237        conditioning: ConditioningOptions,
238    },
239    Ilut {
240        drop_tol: R,
241        max_fill: usize,
242        reordering: Option<String>,
243        conditioning: ConditioningOptions,
244    },
245    Ilutp {
246        drop_tol: R,
247        max_fill: usize,
248        perm_tol: R,
249        reordering: Option<String>,
250        conditioning: ConditioningOptions,
251    },
252    Milu0 {
253        conditioning: ConditioningOptions,
254    },
255    Sor {
256        omega: R,
257        sweeps: usize,
258        mat_side: MatSorSide,
259    },
260    Chebyshev {
261        degree: usize,
262        eig_lo: R,
263        eig_hi: R,
264    },
265    Asm {
266        overlap: usize,
267        subdomain_hint: Option<usize>,
268        block_solver: Option<String>,
269        mode: Option<String>,
270        weighting: Option<String>,
271        inner_pc: AsmInnerPc,
272    },
273    Amg {
274        config: AMGConfig,
275        conditioning: ConditioningOptions,
276    },
277    ApproxInv {
278        kind: ApproxInvKindAlias,
279        levels: usize,
280        max_per_col: usize,
281        drop_tol: R,
282        reg: R,
283        max_cond: R,
284        parallel: bool,
285    },
286    FieldSplit {
287        block_sizes: Vec<usize>,
288        child_pc_type: Option<String>,
289        options: PcOptions,
290    },
291    Shell {
292        name: Option<String>,
293        apply_transpose: Option<String>,
294        apply_conjugate_transpose: Option<String>,
295        apply_symmetric: Option<String>,
296        apply_symmetric_left: Option<String>,
297        apply_symmetric_right: Option<String>,
298        setup: Option<String>,
299        destroy: Option<String>,
300        context: Option<String>,
301    },
302    Ksp {
303        ksp_options: KspOptions,
304        pc_options: PcOptions,
305    },
306    Mg {
307        levels: usize,
308        cycle_type: Option<String>,
309        smoother: Option<String>,
310        smoother_steps: Option<usize>,
311        coarsen_type: Option<String>,
312        interpolation_type: Option<String>,
313        restriction_type: Option<String>,
314        coarse_pc_type: Option<String>,
315        coarse_ksp_type: Option<String>,
316        coarse_ksp_maxits: Option<usize>,
317        coarse_ksp_rtol: Option<R>,
318        level_policies: Vec<MgLevelPolicy>,
319    },
320    Bddc {
321        coarse_ksp_type: Option<String>,
322        coarse_pc_type: Option<String>,
323        use_vertices: bool,
324        constraint_selection: BddcConstraintSelection,
325        scaling: BddcScaling,
326    },
327    Gamg {
328        config: GamgConfig,
329        conditioning: ConditioningOptions,
330    },
331    Lu,
332    Qr,
333    #[cfg_attr(docsrs, doc(cfg(feature = "superlu_dist")))]
334    #[cfg(feature = "superlu_dist")]
335    SuperLuDist,
336}
337
338fn parse_mg_level_policy(value: &str) -> Result<MgLevelPolicy, KError> {
339    let mut policy = MgLevelPolicy::default();
340    for token in value.split(',').map(str::trim).filter(|t| !t.is_empty()) {
341        if let Some((k, v)) = token.split_once('=') {
342            match k.trim() {
343                "level" => {
344                    policy.level = v.trim().parse().map_err(|_| {
345                        KError::InvalidInput(format!("invalid mg policy level: {v}"))
346                    })?
347                }
348                "level_key" | "family_key" => policy.level_key = Some(v.trim().to_lowercase()),
349                "smoother" => policy.smoother_type = Some(v.trim().to_lowercase()),
350                "smoother_family" | "family" => {
351                    policy.smoother_family = Some(v.trim().to_lowercase())
352                }
353                "steps" | "sweeps" => {
354                    policy.smoother_steps = Some(v.trim().parse().map_err(|_| {
355                        KError::InvalidInput(format!("invalid mg policy steps: {v}"))
356                    })?)
357                }
358                "pre_sweeps" => {
359                    policy.pre_sweeps =
360                        Some(v.trim().parse().map_err(|_| {
361                            KError::InvalidInput(format!("invalid mg pre sweeps: {v}"))
362                        })?)
363                }
364                "post_sweeps" => {
365                    policy.post_sweeps = Some(v.trim().parse().map_err(|_| {
366                        KError::InvalidInput(format!("invalid mg post sweeps: {v}"))
367                    })?)
368                }
369                "side" | "smoother_side" => {
370                    policy.smoother_side = Some(PcSide::from_str(v.trim())?)
371                }
372                "coarse_pc" => policy.coarse_pc_type = Some(v.trim().to_lowercase()),
373                "coarse_ksp" => policy.coarse_ksp_type = Some(v.trim().to_lowercase()),
374                "coarse_maxits" => {
375                    policy.coarse_ksp_maxits = Some(v.trim().parse().map_err(|_| {
376                        KError::InvalidInput(format!("invalid mg coarse maxits: {v}"))
377                    })?)
378                }
379                "coarse_rtol" => {
380                    policy.coarse_ksp_rtol = Some(v.trim().parse().map_err(|_| {
381                        KError::InvalidInput(format!("invalid mg coarse rtol: {v}"))
382                    })?)
383                }
384                "coarse_side" => policy.coarse_side = Some(PcSide::from_str(v.trim())?),
385                "coarse_route" | "coarse_routes" => {
386                    let routes = v
387                        .split('|')
388                        .flat_map(|chunk| chunk.split(','))
389                        .map(str::trim)
390                        .filter(|s| !s.is_empty())
391                        .map(|s| s.to_lowercase())
392                        .collect::<Vec<_>>();
393                    if !routes.is_empty() {
394                        policy.coarse_routes = Some(routes);
395                    }
396                }
397                "ksp" | "ksp_type" => policy.level_ksp_type = Some(v.trim().to_lowercase()),
398                "pc" | "pc_type" => policy.level_pc_type = Some(v.trim().to_lowercase()),
399                "ksp_maxits" | "maxits" => {
400                    policy.level_ksp_maxits = Some(v.trim().parse().map_err(|_| {
401                        KError::InvalidInput(format!("invalid mg level maxits: {v}"))
402                    })?)
403                }
404                "ksp_rtol" | "rtol" => {
405                    policy.level_ksp_rtol =
406                        Some(v.trim().parse().map_err(|_| {
407                            KError::InvalidInput(format!("invalid mg level rtol: {v}"))
408                        })?)
409                }
410                _ => {}
411            }
412        }
413    }
414    Ok(policy)
415}
416
417fn mg_policy_from_scoped_level(
418    global: &PcOptions,
419    level: usize,
420    scoped: &PcOptions,
421) -> MgLevelPolicy {
422    let coarse_routes_from_policy = scoped
423        .amg_dist_coarse_policy
424        .as_deref()
425        .or(global.amg_dist_coarse_policy.as_deref())
426        .map(|policy| match policy {
427            "local" | "local_prototype" | "hybrid" => {
428                vec!["pc_apply".to_string(), "nested_ksp".to_string()]
429            }
430            "root" | "root_gather" | "auto" | "superlu_dist" => {
431                vec!["nested_ksp".to_string(), "pc_apply".to_string()]
432            }
433            _ => vec!["nested_ksp".to_string(), "pc_apply".to_string()],
434        });
435    let inherited_pc_type = scoped
436        .pc_type
437        .clone()
438        .or_else(|| scoped.amg_smoother.clone())
439        .or_else(|| global.pc_mg_smoother.clone());
440    let inherited_ksp_type = scoped
441        .pc_ksp_ksp_type
442        .clone()
443        .or_else(|| global.pc_ksp_ksp_type.clone());
444    let inherited_ksp_pc = scoped
445        .pc_ksp_pc_type
446        .clone()
447        .or_else(|| scoped.pc_type.clone())
448        .or_else(|| global.pc_ksp_pc_type.clone())
449        .or_else(|| global.pc_mg_smoother.clone());
450    MgLevelPolicy {
451        level,
452        level_key: None,
453        smoother_type: inherited_pc_type.clone().map(|v| v.to_lowercase()),
454        smoother_family: inherited_pc_type.map(|v| v.to_lowercase()),
455        smoother_steps: scoped.pc_mg_smoother_steps.or(global.pc_mg_smoother_steps),
456        pre_sweeps: scoped.amg_sweeps_down.or(global.amg_sweeps_down),
457        post_sweeps: scoped.amg_sweeps_up.or(global.amg_sweeps_up),
458        smoother_side: None,
459        coarse_pc_type: scoped
460            .pc_mg_coarse_pc_type
461            .clone()
462            .or(scoped.amg_coarse_solver.clone())
463            .or_else(|| scoped.pc_type.clone())
464            .or_else(|| global.pc_mg_coarse_pc_type.clone())
465            .or_else(|| global.amg_coarse_solver.clone())
466            .map(|v| v.to_lowercase()),
467        coarse_ksp_type: scoped
468            .pc_mg_coarse_ksp_type
469            .clone()
470            .or_else(|| global.pc_mg_coarse_ksp_type.clone())
471            .map(|v| v.to_lowercase()),
472        coarse_ksp_maxits: scoped
473            .pc_mg_coarse_ksp_maxits
474            .or(scoped.pc_ksp_maxits)
475            .or(global.pc_mg_coarse_ksp_maxits)
476            .or(global.pc_ksp_maxits),
477        coarse_ksp_rtol: scoped
478            .pc_mg_coarse_ksp_rtol
479            .or(scoped.pc_ksp_rtol)
480            .or(global.pc_mg_coarse_ksp_rtol)
481            .or(global.pc_ksp_rtol),
482        coarse_side: None,
483        coarse_routes: scoped
484            .amg_dist_coarse_solver_route
485            .as_ref()
486            .map(|v| {
487                v.split(',')
488                    .map(str::trim)
489                    .filter(|s| !s.is_empty())
490                    .map(|s| s.to_lowercase())
491                    .collect::<Vec<_>>()
492            })
493            .filter(|v| !v.is_empty())
494            .or(coarse_routes_from_policy)
495            .or_else(|| {
496                global.amg_dist_coarse_solver_route.as_ref().map(|v| {
497                    v.split(',')
498                        .map(str::trim)
499                        .filter(|s| !s.is_empty())
500                        .map(|s| s.to_lowercase())
501                        .collect::<Vec<_>>()
502                })
503            })
504            .filter(|v| !v.is_empty()),
505        level_ksp_type: inherited_ksp_type.map(|v| v.to_lowercase()),
506        level_pc_type: inherited_ksp_pc.map(|v| v.to_lowercase()),
507        level_ksp_maxits: scoped.pc_ksp_maxits.or(global.pc_ksp_maxits),
508        level_ksp_rtol: scoped.pc_ksp_rtol.or(global.pc_ksp_rtol),
509    }
510}
511
512fn merge_mg_policy(dst: &mut MgLevelPolicy, src: &MgLevelPolicy) {
513    if let Some(v) = src.level_key.as_ref() {
514        dst.level_key = Some(v.clone());
515    }
516    if let Some(v) = src.smoother_type.as_ref() {
517        dst.smoother_type = Some(v.clone());
518    }
519    if let Some(v) = src.smoother_family.as_ref() {
520        dst.smoother_family = Some(v.clone());
521    }
522    if let Some(v) = src.smoother_steps {
523        dst.smoother_steps = Some(v);
524    }
525    if let Some(v) = src.pre_sweeps {
526        dst.pre_sweeps = Some(v);
527    }
528    if let Some(v) = src.post_sweeps {
529        dst.post_sweeps = Some(v);
530    }
531    if let Some(v) = src.smoother_side {
532        dst.smoother_side = Some(v);
533    }
534    if let Some(v) = src.coarse_pc_type.as_ref() {
535        dst.coarse_pc_type = Some(v.clone());
536    }
537    if let Some(v) = src.coarse_ksp_type.as_ref() {
538        dst.coarse_ksp_type = Some(v.clone());
539    }
540    if let Some(v) = src.coarse_ksp_maxits {
541        dst.coarse_ksp_maxits = Some(v);
542    }
543    if let Some(v) = src.coarse_ksp_rtol {
544        dst.coarse_ksp_rtol = Some(v);
545    }
546    if let Some(v) = src.coarse_side {
547        dst.coarse_side = Some(v);
548    }
549    if let Some(v) = src.coarse_routes.as_ref() {
550        dst.coarse_routes = Some(v.clone());
551    }
552    if let Some(v) = src.level_ksp_type.as_ref() {
553        dst.level_ksp_type = Some(v.clone());
554    }
555    if let Some(v) = src.level_pc_type.as_ref() {
556        dst.level_pc_type = Some(v.clone());
557    }
558    if let Some(v) = src.level_ksp_maxits {
559        dst.level_ksp_maxits = Some(v);
560    }
561    if let Some(v) = src.level_ksp_rtol {
562        dst.level_ksp_rtol = Some(v);
563    }
564}
565
566impl PcConfig {
567    pub fn from_type_and_options(
568        pc_type: PcType,
569        opts: Option<&PcOptions>,
570    ) -> Result<Self, KError> {
571        use PcType::*;
572        let default_opts = PcOptions::default();
573        let o = opts.unwrap_or(&default_opts);
574        let conditioning = o.conditioning_options()?;
575        Ok(match pc_type {
576            None => PcConfig::None,
577
578            Jacobi => match o.jacobi_block_size {
579                Some(b) if b > 1 => PcConfig::BlockJacobi { block: b },
580                _ => PcConfig::Jacobi,
581            },
582
583            Ilu0 => PcConfig::Ilu0 {
584                conditioning: conditioning.clone(),
585            },
586
587            Ilu => match o.ilu_variant.as_deref() {
588                Some("ilu0") | Option::None
589                    if o.ilu_level.is_none() && o.ilut_drop_tol.is_none() =>
590                {
591                    PcConfig::Ilu0 {
592                        conditioning: conditioning.clone(),
593                    }
594                }
595                Some("iluk") | Option::None if o.ilu_level.is_some() => {
596                    let level = o.ilu_level.ok_or_else(|| {
597                        KError::InvalidInput("iluk requires PcOptions.ilu_level".into())
598                    })?;
599                    PcConfig::Iluk {
600                        level,
601                        conditioning: conditioning.clone(),
602                    }
603                }
604                Some("ilut") | Option::None if o.ilut_drop_tol.is_some() => PcConfig::Ilut {
605                    drop_tol: o.ilut_drop_tol.unwrap_or(1e-4),
606                    max_fill: o.ilut_max_fill.unwrap_or(20),
607                    reordering: o.ilu_reordering.clone(),
608                    conditioning: conditioning.clone(),
609                },
610                Some("milu0") => PcConfig::Milu0 {
611                    conditioning: conditioning.clone(),
612                },
613                Some(other) => {
614                    return Err(KError::InvalidInput(format!(
615                        "unknown ilu_variant: {other}"
616                    )));
617                }
618                Option::None => PcConfig::Ilu0 {
619                    conditioning: conditioning.clone(),
620                },
621            },
622            Ilut => PcConfig::Ilut {
623                drop_tol: o.ilut_drop_tol.unwrap_or(1e-4),
624                max_fill: o.ilut_max_fill.unwrap_or(20),
625                reordering: o.ilu_reordering.clone(),
626                conditioning: conditioning.clone(),
627            },
628            Ilutp => PcConfig::Ilutp {
629                drop_tol: o.ilutp_drop_tol.unwrap_or(1e-4),
630                max_fill: o.ilutp_max_fill.unwrap_or(10),
631                perm_tol: o.ilutp_perm_tol.unwrap_or(0.1),
632                reordering: o.ilu_reordering.clone(),
633                conditioning: conditioning.clone(),
634            },
635            Ilup => PcConfig::Iluk {
636                level: o.ilu_level.unwrap_or(0),
637                conditioning: conditioning.clone(),
638            },
639
640            Sor => {
641                let mut mat_side = if let Some(ref side) = o.sor_mat_side {
642                    match SorMatSideKind::from_str(side)? {
643                        SorMatSideKind::Lower => MatSorSide::APPLY_LOWER,
644                        SorMatSideKind::Upper => MatSorSide::APPLY_UPPER,
645                        SorMatSideKind::Symmetric => MatSorSide::SYMMETRIC_SWEEP,
646                        SorMatSideKind::Eisenstat => {
647                            MatSorSide::SYMMETRIC_SWEEP | MatSorSide::EISENSTAT
648                        }
649                    }
650                } else {
651                    MatSorSide::APPLY_LOWER
652                };
653                if o.sor_symmetric.unwrap_or(false) {
654                    mat_side |= MatSorSide::SYMMETRIC_SWEEP;
655                }
656                let omega = o.sor_omega.unwrap_or(1.0);
657                if !(0.0..2.0).contains(&omega) {
658                    return Err(KError::InvalidInput("sor_omega must be in (0,2)".into()));
659                }
660                PcConfig::Sor {
661                    omega,
662                    sweeps: o.sor_sweeps.unwrap_or(1),
663                    mat_side,
664                }
665            }
666
667            Chebyshev => {
668                let degree = o.cheb_degree.unwrap_or(2);
669                let eig_lo = o.cheb_eig_lo.unwrap_or(0.0);
670                let eig_hi = o.cheb_eig_hi.unwrap_or(1.0);
671                if degree < 1 || eig_hi <= eig_lo || eig_lo < 0.0 {
672                    return Err(KError::InvalidInput("invalid Chebyshev bounds".into()));
673                }
674                PcConfig::Chebyshev {
675                    degree,
676                    eig_lo,
677                    eig_hi,
678                }
679            }
680
681            Asm => PcConfig::Asm {
682                overlap: o.asm_overlap.unwrap_or(0),
683                subdomain_hint: o.asm_subdomain_size,
684                block_solver: o.asm_block_solver.clone(),
685                mode: o.asm_mode.clone(),
686                weighting: o.asm_weighting.clone(),
687                inner_pc: match o.asm_inner_pc.as_deref() {
688                    Some("jacobi") => AsmInnerPc::Jacobi,
689                    Some("ilut") => AsmInnerPc::Ilut {
690                        drop_tol: o.ilut_drop_tol.unwrap_or(1e-4),
691                        max_fill: o.ilut_max_fill.unwrap_or(20),
692                    },
693                    Some("ilutp") => AsmInnerPc::Ilutp {
694                        drop_tol: o.ilutp_drop_tol.unwrap_or(1e-4),
695                        max_fill: o.ilutp_max_fill.unwrap_or(10),
696                        perm_tol: o.ilutp_perm_tol.unwrap_or(0.1),
697                    },
698                    Some("ilu") | Some("ilu0") | std::option::Option::None => AsmInnerPc::Ilu0,
699                    Some(other) => {
700                        return Err(KError::InvalidInput(format!(
701                            "unknown pc_asm_inner_pc: {other}"
702                        )));
703                    }
704                },
705            },
706            Amg => {
707                let cfg = AMGConfig::try_from_opts(o)?;
708                PcConfig::Amg {
709                    config: cfg,
710                    conditioning: conditioning.clone(),
711                }
712            }
713
714            ApproxInverse => {
715                // Route production ApproxInverse through CSR-native SPAI/FSAI.
716                // Legacy `preconditioner::approxinv::ApproxInv` remains available
717                // only as a deprecated compatibility adapter.
718                let kind = match o
719                    .approxinv_kind
720                    .as_deref()
721                    .unwrap_or("fsai")
722                    .to_lowercase()
723                    .as_str()
724                {
725                    "fsai" => ApproxInvKindAlias::FSAI,
726                    "spai" => ApproxInvKindAlias::SPAI,
727                    other => {
728                        return Err(KError::InvalidInput(format!(
729                            "unknown pc_approxinv_kind: {other}"
730                        )));
731                    }
732                };
733                let levels = o.approxinv_levels.unwrap_or(1);
734                let max_per_col = o.approxinv_max_per_col.unwrap_or(20);
735                let drop_tol = o.approxinv_drop_tol.or(o.drop_tol).unwrap_or(1e-3);
736                let reg = o.approxinv_reg.unwrap_or(1e-12);
737                let max_cond = o.approxinv_max_cond.unwrap_or(1e12);
738                let parallel = o.approxinv_parallel.unwrap_or(cfg!(feature = "rayon"));
739                PcConfig::ApproxInv {
740                    kind,
741                    levels,
742                    max_per_col,
743                    drop_tol,
744                    reg,
745                    max_cond,
746                    parallel,
747                }
748            }
749            FieldSplit => {
750                let block_sizes = o
751                    .pc_fieldsplit_block_sizes
752                    .clone()
753                    .unwrap_or_else(|| vec![1]);
754                PcConfig::FieldSplit {
755                    block_sizes,
756                    child_pc_type: o.pc_fieldsplit_child_pc_type.clone(),
757                    options: o.clone(),
758                }
759            }
760            Shell => PcConfig::Shell {
761                name: o.pc_shell_apply.clone().or_else(|| o.pc_shell_name.clone()),
762                apply_transpose: o.pc_shell_apply_transpose.clone(),
763                apply_conjugate_transpose: o.pc_shell_apply_conjugate_transpose.clone(),
764                apply_symmetric: o.pc_shell_apply_symmetric.clone(),
765                apply_symmetric_left: o.pc_shell_apply_symmetric_left.clone(),
766                apply_symmetric_right: o.pc_shell_apply_symmetric_right.clone(),
767                setup: o.pc_shell_setup.clone(),
768                destroy: o.pc_shell_destroy.clone(),
769                context: o.pc_shell_context.clone(),
770            },
771            Ksp => {
772                let mut ksp_options = o.resolved_pc_ksp_ksp_options();
773                if let Some(scoped) = o.pc_ksp_ksp_options.clone() {
774                    ksp_options.overlay_from(scoped);
775                }
776                PcConfig::Ksp {
777                    ksp_options,
778                    pc_options: o.resolved_pc_ksp_pc_options(),
779                }
780            }
781            Mg => PcConfig::Mg {
782                levels: o.pc_mg_levels.unwrap_or(2),
783                cycle_type: o.pc_mg_cycle_type.clone(),
784                smoother: o.pc_mg_smoother.clone(),
785                smoother_steps: o.pc_mg_smoother_steps,
786                coarsen_type: o.pc_mg_coarsen_type.clone(),
787                interpolation_type: o.pc_mg_interpolation_type.clone(),
788                restriction_type: o.pc_mg_restriction_type.clone(),
789                coarse_pc_type: o.pc_mg_coarse_pc_type.clone(),
790                coarse_ksp_type: o.pc_mg_coarse_ksp_type.clone(),
791                coarse_ksp_maxits: o.pc_mg_coarse_ksp_maxits,
792                coarse_ksp_rtol: o.pc_mg_coarse_ksp_rtol,
793                level_policies: {
794                    let mut merged: std::collections::BTreeMap<usize, MgLevelPolicy> =
795                        std::collections::BTreeMap::new();
796                    for policy in o
797                        .pc_mg_level_policies
798                        .as_ref()
799                        .map(|entries| {
800                            entries
801                                .iter()
802                                .filter_map(|entry| parse_mg_level_policy(entry).ok())
803                                .collect::<Vec<_>>()
804                        })
805                        .unwrap_or_default()
806                    {
807                        let entry = merged.entry(policy.level).or_insert_with(|| MgLevelPolicy {
808                            level: policy.level,
809                            ..Default::default()
810                        });
811                        merge_mg_policy(entry, &policy);
812                    }
813                    for (level, scoped) in &o.pc_mg_level_scoped_options {
814                        let scoped_policy = mg_policy_from_scoped_level(o, *level, scoped);
815                        let entry = merged.entry(*level).or_insert_with(|| MgLevelPolicy {
816                            level: *level,
817                            ..Default::default()
818                        });
819                        merge_mg_policy(entry, &scoped_policy);
820                    }
821                    merged.into_values().collect()
822                },
823            },
824            Bddc => PcConfig::Bddc {
825                coarse_ksp_type: o.pc_bddc_coarse_ksp_type.clone(),
826                coarse_pc_type: o.pc_bddc_coarse_pc_type.clone(),
827                use_vertices: o.pc_bddc_use_vertices.unwrap_or(false),
828                constraint_selection: match o.pc_bddc_constraint_selection.as_deref() {
829                    Some("vertices") => BddcConstraintSelection::Vertices,
830                    Some("vertices_and_interface") | Some("all") => {
831                        BddcConstraintSelection::VerticesAndInterface
832                    }
833                    _ => BddcConstraintSelection::Interface,
834                },
835                scaling: match o.pc_bddc_scaling.as_deref() {
836                    Some("deluxe") | Some("deluxe_like") => BddcScaling::DeluxeLike,
837                    _ => BddcScaling::Uniform,
838                },
839            },
840            Gamg => {
841                let cfg = GamgConfig::try_from_opts(o)?;
842                PcConfig::Gamg {
843                    config: cfg,
844                    conditioning: conditioning.clone(),
845                }
846            }
847
848            Lu => PcConfig::Lu,
849            Qr => PcConfig::Qr,
850            #[cfg(feature = "superlu_dist")]
851            SuperLuDist => PcConfig::SuperLuDist,
852            BlockJacobi => PcConfig::BlockJacobi {
853                block: o.jacobi_block_size.unwrap_or(1),
854            },
855        })
856    }
857}
858
859/// # PcFactory
860///
861/// Runtime selection of preconditioners with option parsing.
862///
863/// - `PcOptions` → typed `PcConfig` → concrete builder
864/// - Feature gates:
865///   - `superlu_dist`: enables the SuperLU_DIST preconditioner
866///   - `legacy-pc-bridge`: enables adapters for legacy implementations (no per-apply allocs)
867///
868/// ## Chains
869/// - String form: `"jacobi->ilut"` via [`PcFactory::create_pc_chain_from_str`]
870/// - Structured form: `PcOptions.chain: Vec<PcOptions>`
871/// - Construction is deferred until a matrix is available (see KSP docs).
872///
873/// Suites of ILU/ILUT options stay available when the crate is built with `feature = "complex"`
874/// because the factory relies on the real `Ilu` and `Ilutp` implementations plus the `KPreconditioner`
875/// bridge (`BridgeScratch`). When the specialized `backend-faer` path is unavailable, we fall back
876/// to the generic `Ilup` or `Ilut` implementations that already operate over the Kryst scalar `S`.
877pub struct PcFactory;
878
879impl PcFactory {
880    fn composite_mode_from_opts(
881        opts: Option<&PcOptions>,
882    ) -> Result<crate::preconditioner::chain::PcCompositeMode, KError> {
883        match opts.and_then(|o| o.pc_composite_type.as_deref()) {
884            None | Some("multiplicative") | Some("mul") => {
885                Ok(crate::preconditioner::chain::PcCompositeMode::Multiplicative)
886            }
887            Some("additive") | Some("add") => {
888                Ok(crate::preconditioner::chain::PcCompositeMode::Additive)
889            }
890            Some("schur") => Ok(crate::preconditioner::chain::PcCompositeMode::Schur),
891            Some(other) => Err(KError::InvalidInput(format!(
892                "unknown pc_composite_type: {other}"
893            ))),
894        }
895    }
896
897    fn split_chain_tokens(chain: &str) -> Vec<String> {
898        chain
899            .replace("->", ",")
900            .replace('+', ",")
901            .split(',')
902            .map(|s| s.trim())
903            .filter(|s| !s.is_empty())
904            .map(|token| token.to_string())
905            .collect()
906    }
907
908    #[inline]
909    fn is_direct(pc: PcType) -> bool {
910        match pc {
911            PcType::Lu | PcType::Qr => true,
912            #[cfg(feature = "superlu_dist")]
913            PcType::SuperLuDist => true,
914            _ => false,
915        }
916    }
917
918    #[inline]
919    fn chain_strict() -> bool {
920        #[cfg(test)]
921        if let Some(val) = CHAIN_STRICT_OVERRIDE.with(|cell| cell.get()) {
922            return val;
923        }
924        // Opt-in strict mode via env var.
925        // KRYST_PC_CHAIN_STRICT=1|true enforces selected warnings as errors.
926        std::env::var("KRYST_PC_CHAIN_STRICT")
927            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
928            .unwrap_or(false)
929    }
930
931    #[cfg(test)]
932    pub(crate) fn override_chain_strict(value: Option<bool>) -> ChainStrictGuard {
933        CHAIN_STRICT_OVERRIDE.with(|cell| {
934            let prev = cell.replace(value);
935            ChainStrictGuard(prev)
936        })
937    }
938
939    /// Validate high-level invariants for a PC chain.
940    /// - Emits log::warn! for suspect patterns.
941    /// - If KRYST_PC_CHAIN_STRICT is set, some warnings become errors.
942    fn validate_chain_specs(specs: &[DeferredPcInfo]) -> Result<(), KError> {
943        if specs.is_empty() {
944            return Err(KError::InvalidInput("empty PC chain".into()));
945        }
946
947        let strict = Self::chain_strict();
948
949        // Rule 1: multiple direct PCs
950        let direct_positions: Vec<usize> = specs
951            .iter()
952            .enumerate()
953            .filter_map(|(i, s)| Self::is_direct(s.pc_type).then_some(i))
954            .collect();
955        if direct_positions.len() > 1 {
956            let msg = format!(
957                "PC chain contains multiple direct PCs at positions {direct_positions:?}. \
958                 Stacking direct factorizations is usually unintended."
959            );
960            if strict {
961                return Err(KError::InvalidInput(msg));
962            } else {
963                log::warn!("{msg}");
964            }
965        }
966
967        // Rule 2: direct PC should be last
968        if let Some((i, s)) = specs
969            .iter()
970            .enumerate()
971            .find(|(i, s)| Self::is_direct(s.pc_type) && *i + 1 != specs.len())
972        {
973            let msg = format!(
974                "Direct PC {:?} is not the last stage (index {}, chain len {}). \
975                 Subsequent stages will likely be redundant or ignored.",
976                s.pc_type,
977                i,
978                specs.len()
979            );
980            if strict {
981                return Err(KError::InvalidInput(msg));
982            } else {
983                log::warn!("{msg}");
984            }
985        }
986
987        // Rule 3: consecutive duplicates (same PcType twice)
988        // Intentionally warn-only (even in strict mode) to avoid flakiness when tests
989        // mutate environment variables concurrently. Redundant stages are allowed.
990        for w in specs.windows(2) {
991            if w[0].pc_type == w[1].pc_type {
992                let msg = format!(
993                    "Consecutive duplicate PCs: {:?} -> {:?}. \
994                     This is typically redundant unless options differ.",
995                    w[0].pc_type, w[1].pc_type
996                );
997                log::warn!("{msg}");
998            }
999        }
1000
1001        // Rule 4: BlockJacobi block_size <= 1 behaves like Jacobi
1002        for (i, spec) in specs.iter().enumerate() {
1003            if matches!(spec.pc_type, PcType::BlockJacobi)
1004                && let Some(ref o) = spec.options
1005                && o.jacobi_block_size.unwrap_or(1) <= 1
1006            {
1007                log::warn!(
1008                    "PC chain stage {i}: BlockJacobi with block_size <= 1 behaves like Jacobi; \
1009                             consider using 'jacobi' instead."
1010                );
1011            }
1012        }
1013
1014        Ok(())
1015    }
1016    pub fn create_preconditioner(
1017        pc_type: PcType,
1018        options: Option<&PcOptions>,
1019    ) -> Result<Box<dyn Preconditioner>, KError> {
1020        let cfg = PcConfig::from_type_and_options(pc_type, options)?;
1021        if let Some(pc) = crate::preconditioner::builders_none::try_build(&cfg)? {
1022            return Ok(pc);
1023        }
1024
1025        #[cfg(feature = "backend-faer")]
1026        if let Some(pc) = crate::preconditioner::builders_faer::try_build(&cfg)? {
1027            return Ok(pc);
1028        }
1029
1030        #[cfg(feature = "backend-nalgebra")]
1031        if let Some(pc) = crate::preconditioner::builders_nalgebra::try_build(&cfg)? {
1032            return Ok(pc);
1033        }
1034
1035        Err(KError::InvalidInput(format!(
1036            "Preconditioner {:?} requires a backend that is not enabled/supported for this build",
1037            pc_type
1038        )))
1039    }
1040
1041    /// Convenience: build directly from options (when `pc_type` lives inside options)
1042    pub fn create_from_options(opts: &PcOptions) -> Result<Box<dyn Preconditioner>, KError> {
1043        let pct = if let Some(ref s) = opts.pc_type {
1044            PcType::from_str(s)?
1045        } else {
1046            PcType::None
1047        };
1048        Self::create_preconditioner(pct, Some(opts))
1049    }
1050
1051    pub fn create_deferred_pc(
1052        pc_type: PcType,
1053        options: Option<PcOptions>,
1054    ) -> Result<DeferredPcInfo, KError> {
1055        Ok(DeferredPcInfo { pc_type, options })
1056    }
1057
1058    pub fn construct_deferred_preconditioner(
1059        info: DeferredPcInfo,
1060        _op: &dyn LinOp<S = S>,
1061    ) -> Result<Box<dyn Preconditioner>, KError> {
1062        // The concrete operator format is deferred to the preconditioner itself.
1063        Self::create_preconditioner(info.pc_type, info.options.as_ref())
1064    }
1065
1066    /// Parse a string chain and clone the same [`PcOptions`] for every stage.
1067    ///
1068    /// To tune stages individually, populate [`PcOptions::chain`].
1069    pub fn create_pc_chain_from_str(
1070        chain: &str,
1071        opts: Option<&PcOptions>,
1072    ) -> Result<Vec<DeferredPcInfo>, KError> {
1073        let mut specs = Vec::new();
1074        let prefixes = opts
1075            .and_then(|o| o.pc_composite_prefixes.clone())
1076            .unwrap_or_default();
1077        for (i, token) in Self::split_chain_tokens(chain).into_iter().enumerate() {
1078            let pct = PcType::from_str(&token)?;
1079            let mut stage_opts = opts.cloned();
1080            if let Some(prefix) = prefixes.get(i)
1081                && let Some(scoped) = opts.and_then(|o| o.scoped_child(prefix)).cloned()
1082            {
1083                let mut merged = stage_opts.unwrap_or_default();
1084                merged.overlay_from(scoped);
1085                stage_opts = Some(merged);
1086            }
1087            if token.eq_ignore_ascii_case("ras") {
1088                stage_opts.get_or_insert_with(PcOptions::default).asm_mode =
1089                    Some("ras".to_string());
1090            }
1091            specs.push(DeferredPcInfo {
1092                pc_type: pct,
1093                options: stage_opts,
1094            });
1095        }
1096        if specs.is_empty() {
1097            return Err(KError::InvalidInput("empty PC chain".into()));
1098        }
1099        // validate
1100        Self::validate_chain_specs(&specs)?;
1101        Ok(specs)
1102    }
1103
1104    /// Parse a string containing fallback chains separated by `||`.
1105    ///
1106    /// Example: `"amg||ras+ilutp"` tries AMG first, then RAS+ILUTP on setup failure.
1107    pub fn create_pc_chain_candidates_from_str(
1108        chain: &str,
1109        opts: Option<&PcOptions>,
1110    ) -> Result<Vec<Vec<DeferredPcInfo>>, KError> {
1111        let mut candidates = Vec::new();
1112        for candidate in chain
1113            .split("||")
1114            .map(|s| s.trim())
1115            .filter(|s| !s.is_empty())
1116        {
1117            candidates.push(Self::create_pc_chain_from_str(candidate, opts)?);
1118        }
1119        if candidates.is_empty() {
1120            return Err(KError::InvalidInput("empty PC chain".into()));
1121        }
1122        Ok(candidates)
1123    }
1124
1125    pub fn construct_deferred_pc_chain(
1126        specs: Vec<DeferredPcInfo>,
1127        op: &dyn LinOp<S = S>,
1128    ) -> Result<Box<dyn Preconditioner>, KError> {
1129        // validate again in case specs were assembled elsewhere
1130        Self::validate_chain_specs(&specs)?;
1131        use crate::preconditioner::chain::PcChain;
1132
1133        let mode = Self::composite_mode_from_opts(specs.first().and_then(|s| s.options.as_ref()))?;
1134        let mut stages: Vec<Box<dyn Preconditioner>> = Vec::with_capacity(specs.len());
1135        for (i, spec) in specs.into_iter().enumerate() {
1136            let pc_type = spec.pc_type;
1137            let stage = Self::construct_deferred_preconditioner(spec, op).map_err(|e| {
1138                KError::InvalidInput(format!("PC chain stage {i} ({pc_type:?}) failed: {e}",))
1139            })?;
1140            stages.push(stage);
1141        }
1142        Ok(Box::new(PcChain::with_mode(stages, mode)))
1143    }
1144
1145    pub fn create_pc_chain(
1146        chain: &str,
1147        op: &dyn LinOp<S = S>,
1148        opts: Option<PcOptions>,
1149    ) -> Result<Box<dyn Preconditioner>, KError> {
1150        let specs = Self::create_pc_chain_from_str(chain, opts.as_ref())?;
1151        Self::construct_deferred_pc_chain(specs, op)
1152    }
1153
1154    pub fn create_deferred_pc_chain_from_options(
1155        chain_opts: &[PcOptions],
1156    ) -> Result<Vec<DeferredPcInfo>, KError> {
1157        let mut specs = Vec::with_capacity(chain_opts.len());
1158        for co in chain_opts {
1159            let pct = if let Some(ref s) = co.pc_type {
1160                PcType::from_str(s)?
1161            } else {
1162                return Err(KError::InvalidInput(
1163                    "PcOptions in chain missing pc_type".into(),
1164                ));
1165            };
1166            specs.push(DeferredPcInfo {
1167                pc_type: pct,
1168                options: Some(co.clone()),
1169            });
1170        }
1171        if specs.is_empty() {
1172            return Err(KError::InvalidInput("empty PcOptions.chain".into()));
1173        }
1174        // validate
1175        Self::validate_chain_specs(&specs)?;
1176        Ok(specs)
1177    }
1178}
1179
1180/// Sparsity pattern for approximate inverse preconditioner.
1181#[derive(Clone, Debug)]
1182pub enum SparsityPattern {
1183    Manual(Vec<Vec<usize>>),
1184    Auto,
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189    use super::*;
1190    use crate::preconditioner::Preconditioner;
1191
1192    #[cfg(feature = "dense-direct")]
1193    #[test]
1194    fn factory_builds_lu_qr() {
1195        let lu = PcFactory::create_preconditioner(PcType::from_str("lu").unwrap(), None).unwrap();
1196        let qr = PcFactory::create_preconditioner(PcType::from_str("qr").unwrap(), None).unwrap();
1197
1198        fn _is_pc(_p: &Box<dyn Preconditioner>) {}
1199        _is_pc(&lu);
1200        _is_pc(&qr);
1201    }
1202
1203    #[cfg(feature = "legacy-pc-bridge")]
1204    #[test]
1205    fn factory_uses_options_for_ilut() {
1206        let opts = PcOptions {
1207            pc_type: Some("ilut".into()),
1208            ilut_drop_tol: Some(1e-6),
1209            ilut_max_fill: Some(50),
1210            ..Default::default()
1211        };
1212        let pc = PcFactory::create_from_options(&opts).unwrap();
1213        fn _is_pc(_: &Box<dyn Preconditioner>) {}
1214        _is_pc(&pc);
1215    }
1216
1217    #[cfg(feature = "legacy-pc-bridge")]
1218    #[test]
1219    fn factory_builds_sor_from_options() {
1220        let opts = PcOptions {
1221            pc_type: Some("sor".into()),
1222            sor_omega: Some(1.5),
1223            sor_sweeps: Some(2),
1224            sor_mat_side: Some("lower".into()),
1225            ..Default::default()
1226        };
1227        let pc = PcFactory::create_from_options(&opts).unwrap();
1228        fn _is_pc(_: &Box<dyn Preconditioner>) {}
1229        _is_pc(&pc);
1230    }
1231
1232    #[cfg(feature = "backend-faer")]
1233    #[test]
1234    fn chebyshev_validates_bounds() {
1235        let bad = PcOptions {
1236            pc_type: Some("chebyshev".into()),
1237            cheb_degree: Some(0),
1238            cheb_eig_lo: Some(2.0),
1239            cheb_eig_hi: Some(1.0),
1240            ..Default::default()
1241        };
1242        let err = PcFactory::create_from_options(&bad).err().unwrap();
1243        assert!(matches!(err, KError::InvalidInput(_)));
1244    }
1245
1246    #[cfg(feature = "backend-faer")]
1247    #[test]
1248    fn factory_builds_asm_from_options() {
1249        let opts = crate::config::options::PcOptions {
1250            pc_type: Some("asm".into()),
1251            asm_block_solver: Some("ludense".into()),
1252            ..Default::default()
1253        };
1254        let pc = PcFactory::create_from_options(&opts).unwrap_or_else(|_| {
1255            // When dense-direct is disabled, builder still constructs ASM (LuDense maps to CSR fallback)
1256            PcFactory::create_from_options(&crate::config::options::PcOptions {
1257                pc_type: Some("asm".into()),
1258                asm_block_solver: Some("csr".into()),
1259                ..Default::default()
1260            })
1261            .unwrap()
1262        });
1263        fn _is_pc(_: &Box<dyn Preconditioner>) {}
1264        _is_pc(&pc);
1265    }
1266
1267    #[test]
1268    fn chain_direct_not_last_is_error_in_strict_mode() {
1269        // flip strict mode via override for this test
1270        let _guard = PcFactory::override_chain_strict(Some(true));
1271        let opts = crate::config::options::PcOptions::default();
1272
1273        // "lu->jacobi" => direct not last
1274        let specs = PcFactory::create_pc_chain_from_str("lu->jacobi", Some(&opts));
1275        assert!(specs.is_err(), "expected validation error in strict mode");
1276    }
1277
1278    #[test]
1279    fn chain_duplicate_consecutive_warns_but_allows_by_default() {
1280        // Default (non-strict): should allow "ilu->ilu"
1281        let opts = crate::config::options::PcOptions::default();
1282        let specs = PcFactory::create_pc_chain_from_str("ilu->ilu", Some(&opts))
1283            .expect("duplicates allowed with warning by default");
1284        assert!(!specs.is_empty());
1285    }
1286
1287    #[test]
1288    fn chain_fallback_parses_candidates_and_aliases() {
1289        let opts = crate::config::options::PcOptions::default();
1290        let candidates =
1291            PcFactory::create_pc_chain_candidates_from_str("amg||ras+ilutp", Some(&opts))
1292                .expect("fallback parse");
1293        assert_eq!(candidates.len(), 2);
1294        assert_eq!(candidates[0][0].pc_type, PcType::Amg);
1295        assert_eq!(candidates[1][0].pc_type, PcType::Asm);
1296        assert_eq!(candidates[1][1].pc_type, PcType::Ilutp);
1297        assert_eq!(
1298            candidates[1][0]
1299                .options
1300                .as_ref()
1301                .and_then(|o| o.asm_mode.as_deref()),
1302            Some("ras")
1303        );
1304    }
1305    #[test]
1306    fn chain_prefix_scoped_stage_options_are_merged() {
1307        let args = vec![
1308            "-pc_chain",
1309            "jacobi->ilu",
1310            "-pc_composite_prefixes",
1311            "s0_,s1_",
1312            "-s1_pc_ilu_levels",
1313            "4",
1314        ];
1315        let opts = crate::config::options::PcOptions::from_args(&args).unwrap();
1316        let specs =
1317            PcFactory::create_pc_chain_from_str(opts.pc_chain.as_deref().unwrap(), Some(&opts))
1318                .unwrap();
1319        assert_eq!(specs.len(), 2);
1320        assert_eq!(specs[1].options.as_ref().and_then(|o| o.ilu_level), Some(4));
1321    }
1322
1323    #[test]
1324    fn chain_ilu0_to_ilut_to_ilutp_promotion_path_parses_in_order() {
1325        let opts = crate::config::options::PcOptions::default();
1326        let specs = PcFactory::create_pc_chain_from_str("ilu0->ilut->ilutp", Some(&opts)).unwrap();
1327        let labels: Vec<PcType> = specs.iter().map(|s| s.pc_type.clone()).collect();
1328        assert_eq!(labels, vec![PcType::Ilu0, PcType::Ilut, PcType::Ilutp]);
1329    }
1330}