Skip to main content

hybit_auto/
lib.rs

1use std::collections::VecDeque;
2use std::time::Instant;
3
4use hybit_core::{
5    l2_norm, HybitError, LinearOperator, MatrixBackend, Preconditioner, PreconditionerKind,
6    SolveReport, SolveStatus, SolverKind, SolverOptions,
7};
8use hybit_krylov::{
9    parallel_vector_worker_count, pcg_with_workspace, pcg_with_workspace_parallel_vectors,
10    KrylovOutcome, PcgWorkspace,
11};
12use hybit_matrix::{
13    analyze_csr32, AbtmConfig, AbtmMatrix, Csr32Matrix, DofMask, MatrixProfile,
14    ParallelCsr32Operator,
15};
16pub use hybit_precond::RigidBodyAggregation;
17use hybit_precond::{
18    recommend_rigid_body_aggregate_nodes, HybridPreconditioner, JacobiPreconditioner,
19    ParallelRigidBodyTwoLevelPreconditioner, RigidBodyTwoLevelBlockJacobiPreconditioner,
20};
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum BackendPolicy {
24    Auto,
25    Csr32,
26    Abtm,
27}
28
29#[derive(Clone, Copy, Debug)]
30pub struct HybridOptions {
31    pub enabled: bool,
32    pub probe_iterations: usize,
33    pub escalation_residual_ratio: f64,
34    pub coupling_risk_threshold: f64,
35    pub scale_jump_threshold: f64,
36    pub residual_seed_fraction: f64,
37    pub max_local_region_size: usize,
38    pub max_local_regions: usize,
39    pub overlap_layers: usize,
40}
41
42impl Default for HybridOptions {
43    fn default() -> Self {
44        Self {
45            enabled: true,
46            probe_iterations: 12,
47            escalation_residual_ratio: 0.50,
48            coupling_risk_threshold: 0.90,
49            scale_jump_threshold: 100.0,
50            residual_seed_fraction: 0.25,
51            max_local_region_size: 128,
52            max_local_regions: 8,
53            overlap_layers: 1,
54        }
55    }
56}
57
58/// Execution policy for the fine-grid CSR SpMV used by structural PCG.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum StructuralSpmvPolicy {
61    /// Use parallel CSR only for sufficiently large CSR systems.
62    Auto,
63    /// Always use the ordinary serial CSR/selected backend operator.
64    Serial,
65    /// Force Rayon-parallel CSR SpMV. Requires the CSR32 backend.
66    Parallel,
67}
68
69/// Auto switches to parallel CSR after this many nonzeros. Small matrices stay
70/// serial because Rayon scheduling overhead dominates there.
71pub const STRUCTURAL_PARALLEL_SPMV_MIN_NNZ: usize = 1_000_000;
72
73/// Execution policy for the structural rigid-body two-level preconditioner.
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75pub enum StructuralPreconditionerPolicy {
76    /// Use the parallel fine/coarse transfer kernels only for large structural systems.
77    Auto,
78    /// Use the serial preconditioner implementation.
79    Serial,
80    /// Force parallel block-Jacobi, restriction, and prolongation kernels.
81    Parallel,
82}
83
84/// Auto switches to the parallel structural preconditioner after this many
85/// nonzeros. The dense coarse triangular solve itself remains serial.
86pub const STRUCTURAL_PARALLEL_PRECONDITIONER_MIN_NNZ: usize = 1_000_000;
87
88/// Execution policy for dense vector kernels inside structural PCG.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub enum StructuralPcgVectorPolicy {
91    /// Use the parallel/fused vector path only for sufficiently large systems
92    /// when the shared Rayon pool has enough workers to amortize reductions.
93    Auto,
94    /// Use the established serial PCG vector kernels.
95    Serial,
96    /// Force parallel/fused PCG vector kernels.
97    Parallel,
98}
99
100/// Auto threshold for the parallel/fused PCG dense-vector path.
101pub const STRUCTURAL_PARALLEL_PCG_VECTOR_MIN_N: usize = 131_072;
102/// r24 showed a 2-worker pool can be slower than serial vector kernels.
103pub const STRUCTURAL_PARALLEL_PCG_VECTOR_MIN_THREADS: usize = 4;
104
105#[derive(Clone, Copy, Debug)]
106pub struct StructuralOptions {
107    /// Soft upper target for the dense rigid-body coarse space dimension.
108    pub target_coarse_dimension: usize,
109    /// How structural nodes are grouped into rigid-body coarse aggregates.
110    pub aggregation: RigidBodyAggregation,
111    /// Fine-grid SpMV execution policy for structural PCG.
112    pub spmv_policy: StructuralSpmvPolicy,
113    /// Execution policy for the rigid-body two-level preconditioner.
114    pub preconditioner_policy: StructuralPreconditionerPolicy,
115    /// Execution policy for dense vector kernels inside structural PCG.
116    pub pcg_vector_policy: StructuralPcgVectorPolicy,
117}
118
119impl Default for StructuralOptions {
120    fn default() -> Self {
121        Self {
122            target_coarse_dimension: 1536,
123            aggregation: RigidBodyAggregation::Auto,
124            spmv_policy: StructuralSpmvPolicy::Auto,
125            preconditioner_policy: StructuralPreconditionerPolicy::Auto,
126            pcg_vector_policy: StructuralPcgVectorPolicy::Auto,
127        }
128    }
129}
130
131impl StructuralOptions {
132    pub fn validate(&self) -> Result<(), HybitError> {
133        if self.target_coarse_dimension < 6 {
134            return Err(HybitError::InvalidArgument(
135                "target_coarse_dimension must be at least 6",
136            ));
137        }
138        Ok(())
139    }
140}
141
142impl HybridOptions {
143    pub fn validate(&self) -> Result<(), HybitError> {
144        if self.probe_iterations == 0 {
145            return Err(HybitError::InvalidArgument("probe_iterations must be > 0"));
146        }
147        if !self.escalation_residual_ratio.is_finite() || self.escalation_residual_ratio <= 0.0 {
148            return Err(HybitError::InvalidArgument(
149                "escalation_residual_ratio must be finite and > 0",
150            ));
151        }
152        if !self.coupling_risk_threshold.is_finite() || self.coupling_risk_threshold < 0.0 {
153            return Err(HybitError::InvalidArgument(
154                "coupling_risk_threshold must be finite and >= 0",
155            ));
156        }
157        if !self.scale_jump_threshold.is_finite() || self.scale_jump_threshold < 1.0 {
158            return Err(HybitError::InvalidArgument(
159                "scale_jump_threshold must be finite and >= 1",
160            ));
161        }
162        if !self.residual_seed_fraction.is_finite()
163            || self.residual_seed_fraction <= 0.0
164            || self.residual_seed_fraction > 1.0
165        {
166            return Err(HybitError::InvalidArgument(
167                "residual_seed_fraction must be in (0, 1]",
168            ));
169        }
170        if self.max_local_region_size == 0 || self.max_local_regions == 0 {
171            return Err(HybitError::InvalidArgument(
172                "local region limits must be > 0",
173            ));
174        }
175        if self.overlap_layers > 8 {
176            return Err(HybitError::InvalidArgument("overlap_layers must be <= 8"));
177        }
178        Ok(())
179    }
180}
181
182#[derive(Clone, Debug)]
183pub struct HybitSolver {
184    options: SolverOptions,
185    backend_policy: BackendPolicy,
186    hybrid_options: HybridOptions,
187    structural_options: StructuralOptions,
188}
189
190impl Default for HybitSolver {
191    fn default() -> Self {
192        Self {
193            options: SolverOptions::default(),
194            backend_policy: BackendPolicy::Auto,
195            hybrid_options: HybridOptions::default(),
196            structural_options: StructuralOptions::default(),
197        }
198    }
199}
200
201#[derive(Clone, Debug)]
202pub struct HybitAnalysis {
203    profile: MatrixProfile,
204    backend: MatrixBackend,
205    structure_signature: u64,
206    value_signature: u64,
207    analysis_seconds: f64,
208}
209
210impl HybitAnalysis {
211    pub fn profile(&self) -> &MatrixProfile {
212        &self.profile
213    }
214    pub fn backend(&self) -> MatrixBackend {
215        self.backend
216    }
217    pub fn analysis_seconds(&self) -> f64 {
218        self.analysis_seconds
219    }
220}
221
222#[derive(Debug)]
223pub struct HybitPreparedSystem {
224    options: SolverOptions,
225    hybrid_options: HybridOptions,
226    backend: MatrixBackend,
227    structure_signature: u64,
228    value_signature: u64,
229    analysis_seconds: f64,
230    prepare_seconds: f64,
231    jacobi: JacobiPreconditioner,
232    abtm: Option<AbtmMatrix>,
233    hybrid: Option<HybridPreconditioner>,
234    workspace: PcgWorkspace,
235    solve_sequence: usize,
236}
237
238impl HybitPreparedSystem {
239    pub fn backend(&self) -> MatrixBackend {
240        self.backend
241    }
242    pub fn analysis_seconds(&self) -> f64 {
243        self.analysis_seconds
244    }
245    pub fn prepare_seconds(&self) -> f64 {
246        self.prepare_seconds
247    }
248    pub fn solve_count(&self) -> usize {
249        self.solve_sequence
250    }
251    pub fn krylov_workspace_bytes(&self) -> usize {
252        self.workspace.bytes()
253    }
254    pub fn has_cached_hybrid(&self) -> bool {
255        self.hybrid.is_some()
256    }
257
258    fn validate_matrix(&self, matrix: &Csr32Matrix) -> Result<(), HybitError> {
259        let (structure, values) = matrix_signatures(matrix);
260        if structure != self.structure_signature {
261            return Err(HybitError::InvalidArgument(
262                "prepared context matrix structure changed; analyze and prepare again",
263            ));
264        }
265        if values != self.value_signature {
266            return Err(HybitError::InvalidArgument("prepared context matrix values changed; prepare again before reusing local factors"));
267        }
268        Ok(())
269    }
270
271    pub fn solve(
272        &mut self,
273        matrix: &Csr32Matrix,
274        b: &[f64],
275        x: &mut [f64],
276    ) -> Result<SolveReport, HybitError> {
277        self.validate_matrix(matrix)?;
278        if b.len() != matrix.nrows() {
279            return Err(HybitError::DimensionMismatch {
280                expected: matrix.nrows(),
281                actual: b.len(),
282            });
283        }
284        if x.len() != matrix.ncols() {
285            return Err(HybitError::DimensionMismatch {
286                expected: matrix.ncols(),
287                actual: x.len(),
288            });
289        }
290        self.solve_sequence += 1;
291        let sequence = self.solve_sequence;
292        let charge_context_setup = sequence == 1;
293
294        // Once a difficult subspace has been learned for this matrix, subsequent
295        // RHS vectors reuse the exact local Cholesky factors and skip probe/
296        // diagnostics/factorization entirely.
297        if let Some(hybrid) = self.hybrid.as_ref() {
298            let start = Instant::now();
299            let outcome = pcg_with_workspace(
300                operator_for_backend(matrix, self.abtm.as_ref(), self.backend),
301                hybrid,
302                b,
303                x,
304                self.options,
305                &mut self.workspace,
306            )?;
307            let elapsed = start.elapsed().as_secs_f64();
308            let metrics = ReportMetrics {
309                analysis_seconds: if charge_context_setup {
310                    self.analysis_seconds
311                } else {
312                    0.0
313                },
314                prepare_seconds: if charge_context_setup {
315                    self.prepare_seconds
316                } else {
317                    0.0
318                },
319                restart_seconds: elapsed,
320                local_direct_regions: hybrid.region_count(),
321                largest_local_region: hybrid.largest_region(),
322                local_factor_dofs: hybrid.local_dofs(),
323                unique_local_factor_dofs: hybrid.unique_local_dofs(),
324                local_factor_bytes: hybrid.factor_bytes(),
325                overlap_layers: self.hybrid_options.overlap_layers,
326                preconditioner_reused: true,
327                solve_sequence: sequence,
328                krylov_workspace_bytes: self.workspace.bytes(),
329                ..ReportMetrics::default()
330            };
331            return Ok(report_from_outcome(
332                outcome,
333                SolverKind::Hybrid,
334                PreconditionerKind::Hybrid,
335                self.backend,
336                b,
337                metrics,
338            ));
339        }
340
341        self.solve_uncached(matrix, b, x, sequence, charge_context_setup)
342    }
343
344    fn solve_uncached(
345        &mut self,
346        matrix: &Csr32Matrix,
347        b: &[f64],
348        x: &mut [f64],
349        sequence: usize,
350        charge_context_setup: bool,
351    ) -> Result<SolveReport, HybitError> {
352        let probe_budget = if self.options.max_iterations <= 1 {
353            self.options.max_iterations
354        } else {
355            self.hybrid_options
356                .probe_iterations
357                .min(self.options.max_iterations - 1)
358                .max(1)
359        };
360        let mut probe_options = self.options;
361        probe_options.max_iterations = probe_budget;
362
363        let probe_start = Instant::now();
364        let probe = pcg_with_workspace(
365            operator_for_backend(matrix, self.abtm.as_ref(), self.backend),
366            &self.jacobi,
367            b,
368            x,
369            probe_options,
370            &mut self.workspace,
371        )?;
372        let probe_seconds = probe_start.elapsed().as_secs_f64();
373        let probe_iterations = probe.iterations;
374        let probe_final_residual = probe.final_residual;
375        let base_metrics = ReportMetrics {
376            analysis_seconds: if charge_context_setup {
377                self.analysis_seconds
378            } else {
379                0.0
380            },
381            prepare_seconds: if charge_context_setup {
382                self.prepare_seconds
383            } else {
384                0.0
385            },
386            probe_seconds,
387            probe_iterations,
388            probe_final_residual,
389            overlap_layers: self.hybrid_options.overlap_layers,
390            solve_sequence: sequence,
391            krylov_workspace_bytes: self.workspace.bytes(),
392            ..ReportMetrics::default()
393        };
394
395        if probe.status == SolveStatus::Converged || probe.iterations >= self.options.max_iterations
396        {
397            return Ok(report_from_outcome(
398                probe,
399                SolverKind::Pcg,
400                PreconditionerKind::Jacobi,
401                self.backend,
402                b,
403                base_metrics,
404            ));
405        }
406
407        let poor_progress = self.hybrid_options.enabled
408            && probe.status == SolveStatus::MaxIterations
409            && probe.initial_residual > 0.0
410            && probe.final_residual / probe.initial_residual
411                > self.hybrid_options.escalation_residual_ratio;
412
413        let remaining = self.options.max_iterations.saturating_sub(probe_iterations);
414        if !poor_progress || remaining == 0 {
415            let (continuation, continuation_seconds) = run_continuation(
416                matrix,
417                self.abtm.as_ref(),
418                self.backend,
419                &self.jacobi,
420                b,
421                x,
422                self.options,
423                remaining,
424                &mut self.workspace,
425            )?;
426            let outcome = combine_outcomes(probe, continuation);
427            let mut metrics = base_metrics;
428            metrics.restart_seconds = continuation_seconds;
429            return Ok(report_from_outcome(
430                outcome,
431                SolverKind::Pcg,
432                PreconditionerKind::Jacobi,
433                self.backend,
434                b,
435                metrics,
436            ));
437        }
438
439        let diagnostics_start = Instant::now();
440        let residual = residual(matrix, b, x)?;
441        let risk = numerical_risk_mask(matrix, self.hybrid_options)?;
442        let seeds = residual_seed_mask(&residual, self.hybrid_options.residual_seed_fraction)?;
443        let selected =
444            select_risk_components(matrix, &risk, &seeds, &residual, self.hybrid_options)?;
445        let hard_dofs = selected.count_ones();
446        let core_regions = extract_core_regions(matrix, &selected, &residual, self.hybrid_options)?;
447        if self.abtm.is_none() {
448            self.abtm = Some(AbtmMatrix::from_csr32(matrix, AbtmConfig::default())?);
449        }
450        let abtm = self
451            .abtm
452            .as_ref()
453            .expect("ABTM topology initialized for hybrid escalation");
454        let regions =
455            expand_regions_with_overlap(abtm, &core_regions, &residual, self.hybrid_options)?;
456        let diagnostics_seconds = diagnostics_start.elapsed().as_secs_f64();
457
458        if regions.is_empty() {
459            let (continuation, continuation_seconds) = run_continuation(
460                matrix,
461                self.abtm.as_ref(),
462                self.backend,
463                &self.jacobi,
464                b,
465                x,
466                self.options,
467                remaining,
468                &mut self.workspace,
469            )?;
470            let outcome = combine_outcomes(probe, continuation);
471            let mut metrics = base_metrics;
472            metrics.diagnostics_seconds = diagnostics_seconds;
473            metrics.restart_seconds = continuation_seconds;
474            metrics.hard_dofs = hard_dofs;
475            return Ok(report_from_outcome(
476                outcome,
477                SolverKind::Pcg,
478                PreconditionerKind::Jacobi,
479                self.backend,
480                b,
481                metrics,
482            ));
483        }
484
485        let factor_start = Instant::now();
486        let hybrid = match HybridPreconditioner::from_csr32(matrix, regions) {
487            Ok(hybrid) => hybrid,
488            Err(HybitError::NumericalBreakdown(_)) | Err(HybitError::InvalidMatrix(_)) => {
489                let local_factor_seconds = factor_start.elapsed().as_secs_f64();
490                let (continuation, continuation_seconds) = run_continuation(
491                    matrix,
492                    self.abtm.as_ref(),
493                    self.backend,
494                    &self.jacobi,
495                    b,
496                    x,
497                    self.options,
498                    remaining,
499                    &mut self.workspace,
500                )?;
501                let outcome = combine_outcomes(probe, continuation);
502                let mut metrics = base_metrics;
503                metrics.diagnostics_seconds = diagnostics_seconds;
504                metrics.local_factor_seconds = local_factor_seconds;
505                metrics.restart_seconds = continuation_seconds;
506                metrics.hard_dofs = hard_dofs;
507                return Ok(report_from_outcome(
508                    outcome,
509                    SolverKind::Pcg,
510                    PreconditionerKind::Jacobi,
511                    self.backend,
512                    b,
513                    metrics,
514                ));
515            }
516            Err(err) => return Err(err),
517        };
518        let local_factor_seconds = factor_start.elapsed().as_secs_f64();
519
520        let mut stage_options = self.options;
521        stage_options.max_iterations = remaining;
522        let restart_start = Instant::now();
523        let stage = pcg_with_workspace(
524            operator_for_backend(matrix, self.abtm.as_ref(), self.backend),
525            &hybrid,
526            b,
527            x,
528            stage_options,
529            &mut self.workspace,
530        )?;
531        let restart_seconds = restart_start.elapsed().as_secs_f64();
532        let cacheable_hybrid = stage.status != SolveStatus::Breakdown;
533        let outcome = combine_outcomes(probe, stage);
534
535        let metrics = ReportMetrics {
536            analysis_seconds: base_metrics.analysis_seconds,
537            prepare_seconds: base_metrics.prepare_seconds,
538            probe_seconds,
539            diagnostics_seconds,
540            local_factor_seconds,
541            restart_seconds,
542            escalations: 1,
543            probe_iterations,
544            probe_final_residual,
545            hard_dofs,
546            local_direct_regions: hybrid.region_count(),
547            largest_local_region: hybrid.largest_region(),
548            local_factor_dofs: hybrid.local_dofs(),
549            unique_local_factor_dofs: hybrid.unique_local_dofs(),
550            local_factor_bytes: hybrid.factor_bytes(),
551            overlap_layers: self.hybrid_options.overlap_layers,
552            preconditioner_reused: false,
553            solve_sequence: sequence,
554            krylov_workspace_bytes: self.workspace.bytes(),
555        };
556
557        // Cache only a successfully constructed hybrid preconditioner. It is
558        // matrix-value dependent, and validate_matrix() forbids accidental reuse
559        // after coefficients change.
560        if cacheable_hybrid {
561            self.hybrid = Some(hybrid);
562        }
563
564        Ok(report_from_outcome(
565            outcome,
566            SolverKind::Hybrid,
567            PreconditionerKind::Hybrid,
568            self.backend,
569            b,
570            metrics,
571        ))
572    }
573}
574
575#[derive(Debug)]
576pub struct HybitPreparedStructuralSystem {
577    options: SolverOptions,
578    backend: MatrixBackend,
579    structure_signature: u64,
580    value_signature: u64,
581    analysis_seconds: f64,
582    prepare_seconds: f64,
583    aggregate_nodes: usize,
584    preconditioner: RigidBodyTwoLevelBlockJacobiPreconditioner,
585    abtm: Option<AbtmMatrix>,
586    effective_spmv_policy: StructuralSpmvPolicy,
587    effective_preconditioner_policy: StructuralPreconditionerPolicy,
588    effective_pcg_vector_policy: StructuralPcgVectorPolicy,
589    workspace: PcgWorkspace,
590    solve_sequence: usize,
591}
592
593impl HybitPreparedStructuralSystem {
594    pub fn backend(&self) -> MatrixBackend {
595        self.backend
596    }
597    pub fn analysis_seconds(&self) -> f64 {
598        self.analysis_seconds
599    }
600    pub fn prepare_seconds(&self) -> f64 {
601        self.prepare_seconds
602    }
603    pub fn solve_count(&self) -> usize {
604        self.solve_sequence
605    }
606    pub fn krylov_workspace_bytes(&self) -> usize {
607        self.workspace.bytes()
608    }
609    pub fn aggregate_nodes(&self) -> usize {
610        self.aggregate_nodes
611    }
612    pub fn aggregate_count(&self) -> usize {
613        self.preconditioner.aggregate_count()
614    }
615    pub fn min_aggregate_nodes(&self) -> usize {
616        self.preconditioner.min_aggregate_nodes()
617    }
618    pub fn max_aggregate_nodes(&self) -> usize {
619        self.preconditioner.max_aggregate_nodes()
620    }
621    pub fn aggregation(&self) -> RigidBodyAggregation {
622        self.preconditioner.aggregation()
623    }
624    pub fn coarse_dimension(&self) -> usize {
625        self.preconditioner.coarse_dimension()
626    }
627    pub fn preconditioner_bytes(&self) -> usize {
628        self.preconditioner.factor_bytes()
629    }
630    pub fn base_factor_bytes(&self) -> usize {
631        self.preconditioner.base_factor_bytes()
632    }
633    pub fn coarse_factor_bytes(&self) -> usize {
634        self.preconditioner.coarse_factor_bytes()
635    }
636    pub fn geometry_bytes(&self) -> usize {
637        self.preconditioner.geometry_bytes()
638    }
639    /// Effective execution policy after resolving `StructuralSpmvPolicy::Auto`.
640    pub fn spmv_policy(&self) -> StructuralSpmvPolicy {
641        self.effective_spmv_policy
642    }
643    pub fn parallel_spmv_enabled(&self) -> bool {
644        self.effective_spmv_policy == StructuralSpmvPolicy::Parallel
645    }
646    /// Effective execution policy after resolving `StructuralPreconditionerPolicy::Auto`.
647    pub fn structural_preconditioner_policy(&self) -> StructuralPreconditionerPolicy {
648        self.effective_preconditioner_policy
649    }
650    pub fn parallel_preconditioner_enabled(&self) -> bool {
651        self.effective_preconditioner_policy == StructuralPreconditionerPolicy::Parallel
652    }
653    pub fn parallel_preconditioner_index_bytes(&self) -> usize {
654        self.preconditioner.parallel_index_bytes()
655    }
656    /// Effective dense-vector policy after resolving `StructuralPcgVectorPolicy::Auto`.
657    pub fn pcg_vector_policy(&self) -> StructuralPcgVectorPolicy {
658        self.effective_pcg_vector_policy
659    }
660    pub fn parallel_pcg_vectors_enabled(&self) -> bool {
661        self.effective_pcg_vector_policy == StructuralPcgVectorPolicy::Parallel
662    }
663
664    fn validate_matrix(&self, matrix: &Csr32Matrix) -> Result<(), HybitError> {
665        let (structure, values) = matrix_signatures(matrix);
666        if structure != self.structure_signature {
667            return Err(HybitError::InvalidArgument(
668                "prepared structural context matrix structure changed; analyze and prepare again",
669            ));
670        }
671        if values != self.value_signature {
672            return Err(HybitError::InvalidArgument(
673                "prepared structural context matrix values changed; prepare again before reusing coarse factors",
674            ));
675        }
676        Ok(())
677    }
678
679    pub fn solve(
680        &mut self,
681        matrix: &Csr32Matrix,
682        b: &[f64],
683        x: &mut [f64],
684    ) -> Result<SolveReport, HybitError> {
685        self.validate_matrix(matrix)?;
686        if b.len() != matrix.nrows() {
687            return Err(HybitError::DimensionMismatch {
688                expected: matrix.nrows(),
689                actual: b.len(),
690            });
691        }
692        if x.len() != matrix.ncols() {
693            return Err(HybitError::DimensionMismatch {
694                expected: matrix.ncols(),
695                actual: x.len(),
696            });
697        }
698
699        self.solve_sequence += 1;
700        let sequence = self.solve_sequence;
701        let charge_context_setup = sequence == 1;
702        let start = Instant::now();
703        let outcome = match (
704            self.effective_spmv_policy,
705            self.effective_preconditioner_policy,
706        ) {
707            (StructuralSpmvPolicy::Parallel, StructuralPreconditionerPolicy::Parallel) => {
708                let operator = ParallelCsr32Operator::new(matrix);
709                let preconditioner =
710                    ParallelRigidBodyTwoLevelPreconditioner::new(&self.preconditioner)?;
711                run_structural_pcg(
712                    self.effective_pcg_vector_policy,
713                    &operator,
714                    &preconditioner,
715                    b,
716                    x,
717                    self.options,
718                    &mut self.workspace,
719                )?
720            }
721            (StructuralSpmvPolicy::Parallel, StructuralPreconditionerPolicy::Serial) => {
722                let operator = ParallelCsr32Operator::new(matrix);
723                run_structural_pcg(
724                    self.effective_pcg_vector_policy,
725                    &operator,
726                    &self.preconditioner,
727                    b,
728                    x,
729                    self.options,
730                    &mut self.workspace,
731                )?
732            }
733            (StructuralSpmvPolicy::Serial, StructuralPreconditionerPolicy::Parallel) => {
734                let preconditioner =
735                    ParallelRigidBodyTwoLevelPreconditioner::new(&self.preconditioner)?;
736                run_structural_pcg(
737                    self.effective_pcg_vector_policy,
738                    operator_for_backend(matrix, self.abtm.as_ref(), self.backend),
739                    &preconditioner,
740                    b,
741                    x,
742                    self.options,
743                    &mut self.workspace,
744                )?
745            }
746            (StructuralSpmvPolicy::Serial, StructuralPreconditionerPolicy::Serial) => {
747                run_structural_pcg(
748                    self.effective_pcg_vector_policy,
749                    operator_for_backend(matrix, self.abtm.as_ref(), self.backend),
750                    &self.preconditioner,
751                    b,
752                    x,
753                    self.options,
754                    &mut self.workspace,
755                )?
756            }
757            _ => unreachable!("structural execution policies are resolved during prepare"),
758        };
759        let elapsed = start.elapsed().as_secs_f64();
760        let metrics = ReportMetrics {
761            analysis_seconds: if charge_context_setup {
762                self.analysis_seconds
763            } else {
764                0.0
765            },
766            prepare_seconds: if charge_context_setup {
767                self.prepare_seconds
768            } else {
769                0.0
770            },
771            restart_seconds: elapsed,
772            preconditioner_reused: sequence > 1,
773            solve_sequence: sequence,
774            krylov_workspace_bytes: self.workspace.bytes(),
775            ..ReportMetrics::default()
776        };
777        Ok(report_from_outcome(
778            outcome,
779            SolverKind::Pcg,
780            PreconditionerKind::RigidBodyTwoLevel,
781            self.backend,
782            b,
783            metrics,
784        ))
785    }
786}
787
788#[derive(Clone, Copy, Debug, Default)]
789struct ReportMetrics {
790    analysis_seconds: f64,
791    prepare_seconds: f64,
792    probe_seconds: f64,
793    diagnostics_seconds: f64,
794    local_factor_seconds: f64,
795    restart_seconds: f64,
796    escalations: usize,
797    probe_iterations: usize,
798    probe_final_residual: f64,
799    hard_dofs: usize,
800    local_direct_regions: usize,
801    largest_local_region: usize,
802    local_factor_dofs: usize,
803    unique_local_factor_dofs: usize,
804    local_factor_bytes: usize,
805    overlap_layers: usize,
806    preconditioner_reused: bool,
807    solve_sequence: usize,
808    krylov_workspace_bytes: usize,
809}
810
811fn structural_graph_auto_can_fallback(error: &HybitError) -> bool {
812    match error {
813        HybitError::NumericalBreakdown(_) => true,
814        HybitError::InvalidArgument(message) => matches!(
815            *message,
816            "a structural graph component contains fewer than three nodes"
817        ),
818        _ => false,
819    }
820}
821
822impl HybitSolver {
823    pub fn new() -> Self {
824        Self::default()
825    }
826    pub fn options(&self) -> SolverOptions {
827        self.options
828    }
829    pub fn hybrid_options(&self) -> HybridOptions {
830        self.hybrid_options
831    }
832    pub fn structural_options(&self) -> StructuralOptions {
833        self.structural_options
834    }
835
836    pub fn set_options(&mut self, options: SolverOptions) -> Result<(), HybitError> {
837        options.validate()?;
838        self.options = options;
839        Ok(())
840    }
841
842    pub fn set_hybrid_options(&mut self, options: HybridOptions) -> Result<(), HybitError> {
843        options.validate()?;
844        self.hybrid_options = options;
845        Ok(())
846    }
847
848    pub fn set_structural_options(&mut self, options: StructuralOptions) -> Result<(), HybitError> {
849        options.validate()?;
850        self.structural_options = options;
851        Ok(())
852    }
853
854    pub fn set_backend_policy(&mut self, policy: BackendPolicy) {
855        self.backend_policy = policy;
856    }
857
858    pub fn analyze_csr32(&self, matrix: &Csr32Matrix) -> Result<HybitAnalysis, HybitError> {
859        self.options.validate()?;
860        self.hybrid_options.validate()?;
861        let start = Instant::now();
862        let profile = analyze_csr32(matrix)?;
863        if !profile.square {
864            return Err(HybitError::InvalidMatrix(
865                "AutoSolver currently supports square SPD systems",
866            ));
867        }
868        if !profile.full_diagonal || !profile.positive_diagonal {
869            return Err(HybitError::InvalidMatrix(
870                "PCG path requires a complete positive diagonal",
871            ));
872        }
873        let backend = match self.backend_policy {
874            BackendPolicy::Auto | BackendPolicy::Csr32 => MatrixBackend::Csr32,
875            BackendPolicy::Abtm => MatrixBackend::Abtm,
876        };
877        let (structure_signature, value_signature) = matrix_signatures(matrix);
878        Ok(HybitAnalysis {
879            profile,
880            backend,
881            structure_signature,
882            value_signature,
883            analysis_seconds: start.elapsed().as_secs_f64(),
884        })
885    }
886
887    /// Compatibility alias retained from 0.3.
888    pub fn analyze(&self, matrix: &Csr32Matrix) -> Result<MatrixProfile, HybitError> {
889        Ok(self.analyze_csr32(matrix)?.profile)
890    }
891
892    pub fn prepare_csr32(
893        &self,
894        matrix: &Csr32Matrix,
895        analysis: &HybitAnalysis,
896    ) -> Result<HybitPreparedSystem, HybitError> {
897        self.options.validate()?;
898        self.hybrid_options.validate()?;
899        let (structure_signature, value_signature) = matrix_signatures(matrix);
900        if structure_signature != analysis.structure_signature
901            || value_signature != analysis.value_signature
902        {
903            return Err(HybitError::InvalidArgument(
904                "matrix changed between analyze and prepare",
905            ));
906        }
907        let start = Instant::now();
908        let jacobi = JacobiPreconditioner::from_csr32(matrix)?;
909        // Do not eagerly pay the ABTM conversion cost for an easy CSR32
910        // problem. Forced-ABTM backends build it here; the Auto/CSR32 hybrid
911        // path builds topology lazily only if escalation is actually needed.
912        let abtm = if analysis.backend == MatrixBackend::Abtm {
913            Some(AbtmMatrix::from_csr32(matrix, AbtmConfig::default())?)
914        } else {
915            None
916        };
917        let workspace = PcgWorkspace::new(matrix.nrows());
918        let prepare_seconds = start.elapsed().as_secs_f64();
919        Ok(HybitPreparedSystem {
920            options: self.options,
921            hybrid_options: self.hybrid_options,
922            backend: analysis.backend,
923            structure_signature,
924            value_signature,
925            analysis_seconds: analysis.analysis_seconds,
926            prepare_seconds,
927            jacobi,
928            abtm,
929            hybrid: None,
930            workspace,
931            solve_sequence: 0,
932        })
933    }
934
935    pub fn prepare(&self, matrix: &Csr32Matrix) -> Result<HybitPreparedSystem, HybitError> {
936        let analysis = self.analyze_csr32(matrix)?;
937        self.prepare_csr32(matrix, &analysis)
938    }
939
940    pub fn prepare_structural_csr32(
941        &self,
942        matrix: &Csr32Matrix,
943        analysis: &HybitAnalysis,
944        coordinates: &[[f64; 3]],
945    ) -> Result<HybitPreparedStructuralSystem, HybitError> {
946        self.options.validate()?;
947        self.structural_options.validate()?;
948        let (structure_signature, value_signature) = matrix_signatures(matrix);
949        if structure_signature != analysis.structure_signature
950            || value_signature != analysis.value_signature
951        {
952            return Err(HybitError::InvalidArgument(
953                "matrix changed between analyze and structural prepare",
954            ));
955        }
956        let expected = coordinates
957            .len()
958            .checked_mul(3)
959            .ok_or(HybitError::SizeOverflow)?;
960        if matrix.nrows() != expected || matrix.ncols() != expected {
961            return Err(HybitError::DimensionMismatch {
962                expected: matrix.nrows(),
963                actual: expected,
964            });
965        }
966
967        let start = Instant::now();
968        let aggregate_nodes = recommend_rigid_body_aggregate_nodes(
969            coordinates.len(),
970            self.structural_options.target_coarse_dimension,
971        )?;
972        let preconditioner = match self.structural_options.aggregation {
973            RigidBodyAggregation::Auto => {
974                match RigidBodyTwoLevelBlockJacobiPreconditioner::from_csr32_graph(
975                    matrix,
976                    coordinates,
977                    aggregate_nodes,
978                ) {
979                    Ok(preconditioner) => preconditioner,
980                    Err(err) if structural_graph_auto_can_fallback(&err) => {
981                        // Graph aggregation is an optimization policy, not a
982                        // correctness requirement. Some valid SPD systems have
983                        // disconnected one/two-node graph components, and some
984                        // geometries can make a six-mode graph coarse basis rank
985                        // deficient. Auto falls back to the deterministic
986                        // contiguous baseline in those cases. Explicit Graph
987                        // remains strict and still surfaces the original error.
988                        RigidBodyTwoLevelBlockJacobiPreconditioner::from_csr32(
989                            matrix,
990                            coordinates,
991                            aggregate_nodes,
992                        )?
993                    }
994                    Err(err) => return Err(err),
995                }
996            }
997            RigidBodyAggregation::Contiguous => {
998                RigidBodyTwoLevelBlockJacobiPreconditioner::from_csr32(
999                    matrix,
1000                    coordinates,
1001                    aggregate_nodes,
1002                )?
1003            }
1004            RigidBodyAggregation::Graph => {
1005                RigidBodyTwoLevelBlockJacobiPreconditioner::from_csr32_graph(
1006                    matrix,
1007                    coordinates,
1008                    aggregate_nodes,
1009                )?
1010            }
1011        };
1012        let abtm = if analysis.backend == MatrixBackend::Abtm {
1013            Some(AbtmMatrix::from_csr32(matrix, AbtmConfig::default())?)
1014        } else {
1015            None
1016        };
1017        let effective_spmv_policy = match self.structural_options.spmv_policy {
1018            StructuralSpmvPolicy::Auto => {
1019                if analysis.backend == MatrixBackend::Csr32
1020                    && matrix.nnz() >= STRUCTURAL_PARALLEL_SPMV_MIN_NNZ
1021                {
1022                    StructuralSpmvPolicy::Parallel
1023                } else {
1024                    StructuralSpmvPolicy::Serial
1025                }
1026            }
1027            StructuralSpmvPolicy::Serial => StructuralSpmvPolicy::Serial,
1028            StructuralSpmvPolicy::Parallel => {
1029                if analysis.backend != MatrixBackend::Csr32 {
1030                    return Err(HybitError::InvalidArgument(
1031                        "parallel structural SpMV requires the CSR32 backend",
1032                    ));
1033                }
1034                StructuralSpmvPolicy::Parallel
1035            }
1036        };
1037        let effective_preconditioner_policy = match self.structural_options.preconditioner_policy {
1038            StructuralPreconditionerPolicy::Auto => {
1039                if matrix.nnz() >= STRUCTURAL_PARALLEL_PRECONDITIONER_MIN_NNZ {
1040                    StructuralPreconditionerPolicy::Parallel
1041                } else {
1042                    StructuralPreconditionerPolicy::Serial
1043                }
1044            }
1045            StructuralPreconditionerPolicy::Serial => StructuralPreconditionerPolicy::Serial,
1046            StructuralPreconditionerPolicy::Parallel => StructuralPreconditionerPolicy::Parallel,
1047        };
1048        if effective_preconditioner_policy == StructuralPreconditionerPolicy::Parallel {
1049            // Build the aggregate->node index during prepare. The view created
1050            // during each solve then reuses this cached index without allocation.
1051            let _ = ParallelRigidBodyTwoLevelPreconditioner::new(&preconditioner)?;
1052        }
1053        let effective_pcg_vector_policy = match self.structural_options.pcg_vector_policy {
1054            StructuralPcgVectorPolicy::Auto => {
1055                if matrix.nrows() >= STRUCTURAL_PARALLEL_PCG_VECTOR_MIN_N
1056                    && parallel_vector_worker_count() >= STRUCTURAL_PARALLEL_PCG_VECTOR_MIN_THREADS
1057                {
1058                    StructuralPcgVectorPolicy::Parallel
1059                } else {
1060                    StructuralPcgVectorPolicy::Serial
1061                }
1062            }
1063            StructuralPcgVectorPolicy::Serial => StructuralPcgVectorPolicy::Serial,
1064            StructuralPcgVectorPolicy::Parallel => StructuralPcgVectorPolicy::Parallel,
1065        };
1066        let workspace = PcgWorkspace::new(matrix.nrows());
1067        let prepare_seconds = start.elapsed().as_secs_f64();
1068
1069        Ok(HybitPreparedStructuralSystem {
1070            options: self.options,
1071            backend: analysis.backend,
1072            structure_signature,
1073            value_signature,
1074            analysis_seconds: analysis.analysis_seconds,
1075            prepare_seconds,
1076            aggregate_nodes,
1077            preconditioner,
1078            abtm,
1079            effective_spmv_policy,
1080            effective_preconditioner_policy,
1081            effective_pcg_vector_policy,
1082            workspace,
1083            solve_sequence: 0,
1084        })
1085    }
1086
1087    pub fn prepare_structural(
1088        &self,
1089        matrix: &Csr32Matrix,
1090        coordinates: &[[f64; 3]],
1091    ) -> Result<HybitPreparedStructuralSystem, HybitError> {
1092        let analysis = self.analyze_csr32(matrix)?;
1093        self.prepare_structural_csr32(matrix, &analysis, coordinates)
1094    }
1095
1096    pub fn solve_structural_csr32(
1097        &self,
1098        matrix: &Csr32Matrix,
1099        coordinates: &[[f64; 3]],
1100        b: &[f64],
1101        x: &mut [f64],
1102    ) -> Result<SolveReport, HybitError> {
1103        let analysis = self.analyze_csr32(matrix)?;
1104        let mut prepared = self.prepare_structural_csr32(matrix, &analysis, coordinates)?;
1105        prepared.solve(matrix, b, x)
1106    }
1107
1108    pub fn solve_csr32(
1109        &self,
1110        matrix: &Csr32Matrix,
1111        b: &[f64],
1112        x: &mut [f64],
1113    ) -> Result<SolveReport, HybitError> {
1114        let analysis = self.analyze_csr32(matrix)?;
1115        let mut prepared = self.prepare_csr32(matrix, &analysis)?;
1116        prepared.solve(matrix, b, x)
1117    }
1118}
1119
1120fn run_structural_pcg(
1121    vector_policy: StructuralPcgVectorPolicy,
1122    operator: &dyn LinearOperator,
1123    preconditioner: &dyn Preconditioner,
1124    b: &[f64],
1125    x: &mut [f64],
1126    options: SolverOptions,
1127    workspace: &mut PcgWorkspace,
1128) -> Result<KrylovOutcome, HybitError> {
1129    match vector_policy {
1130        StructuralPcgVectorPolicy::Parallel => {
1131            pcg_with_workspace_parallel_vectors(operator, preconditioner, b, x, options, workspace)
1132        }
1133        StructuralPcgVectorPolicy::Serial => {
1134            pcg_with_workspace(operator, preconditioner, b, x, options, workspace)
1135        }
1136        StructuralPcgVectorPolicy::Auto => {
1137            unreachable!("structural PCG vector policy is resolved during prepare")
1138        }
1139    }
1140}
1141
1142fn operator_for_backend<'a>(
1143    matrix: &'a Csr32Matrix,
1144    abtm: Option<&'a AbtmMatrix>,
1145    backend: MatrixBackend,
1146) -> &'a dyn LinearOperator {
1147    match backend {
1148        MatrixBackend::Csr32 => matrix,
1149        MatrixBackend::Abtm => abtm.expect("ABTM storage initialized"),
1150        MatrixBackend::MatrixFree => unreachable!(),
1151    }
1152}
1153
1154#[allow(clippy::too_many_arguments)]
1155fn run_continuation(
1156    matrix: &Csr32Matrix,
1157    abtm: Option<&AbtmMatrix>,
1158    backend: MatrixBackend,
1159    jacobi: &JacobiPreconditioner,
1160    b: &[f64],
1161    x: &mut [f64],
1162    base_options: SolverOptions,
1163    remaining: usize,
1164    workspace: &mut PcgWorkspace,
1165) -> Result<(KrylovOutcome, f64), HybitError> {
1166    if remaining == 0 {
1167        let r = residual(matrix, b, x)?;
1168        let norm = l2_norm(&r);
1169        return Ok((
1170            KrylovOutcome {
1171                status: SolveStatus::MaxIterations,
1172                iterations: 0,
1173                initial_residual: norm,
1174                final_residual: norm,
1175            },
1176            0.0,
1177        ));
1178    }
1179    let mut options = base_options;
1180    options.max_iterations = remaining;
1181    let start = Instant::now();
1182    let outcome = pcg_with_workspace(
1183        operator_for_backend(matrix, abtm, backend),
1184        jacobi,
1185        b,
1186        x,
1187        options,
1188        workspace,
1189    )?;
1190    Ok((outcome, start.elapsed().as_secs_f64()))
1191}
1192
1193fn combine_outcomes(first: KrylovOutcome, second: KrylovOutcome) -> KrylovOutcome {
1194    KrylovOutcome {
1195        status: second.status,
1196        iterations: first.iterations + second.iterations,
1197        initial_residual: first.initial_residual,
1198        final_residual: second.final_residual,
1199    }
1200}
1201
1202fn report_from_outcome(
1203    outcome: KrylovOutcome,
1204    solver: SolverKind,
1205    preconditioner: PreconditionerKind,
1206    backend: MatrixBackend,
1207    b: &[f64],
1208    metrics: ReportMetrics,
1209) -> SolveReport {
1210    let b_norm = l2_norm(b);
1211    let relative_residual = if b_norm == 0.0 {
1212        outcome.final_residual
1213    } else {
1214        outcome.final_residual / b_norm
1215    };
1216    let setup_seconds = metrics.analysis_seconds
1217        + metrics.prepare_seconds
1218        + metrics.diagnostics_seconds
1219        + metrics.local_factor_seconds;
1220    let solve_seconds = metrics.probe_seconds + metrics.restart_seconds;
1221    SolveReport {
1222        status: outcome.status,
1223        solver,
1224        preconditioner,
1225        backend,
1226        iterations: outcome.iterations,
1227        initial_residual: outcome.initial_residual,
1228        final_residual: outcome.final_residual,
1229        relative_residual,
1230        setup_seconds,
1231        solve_seconds,
1232        analysis_seconds: metrics.analysis_seconds,
1233        prepare_seconds: metrics.prepare_seconds,
1234        probe_seconds: metrics.probe_seconds,
1235        diagnostics_seconds: metrics.diagnostics_seconds,
1236        local_factor_seconds: metrics.local_factor_seconds,
1237        restart_seconds: metrics.restart_seconds,
1238        escalations: metrics.escalations,
1239        probe_iterations: metrics.probe_iterations,
1240        probe_final_residual: metrics.probe_final_residual,
1241        hard_dofs: metrics.hard_dofs,
1242        local_direct_regions: metrics.local_direct_regions,
1243        largest_local_region: metrics.largest_local_region,
1244        local_factor_dofs: metrics.local_factor_dofs,
1245        unique_local_factor_dofs: metrics.unique_local_factor_dofs,
1246        local_factor_bytes: metrics.local_factor_bytes,
1247        overlap_layers: metrics.overlap_layers,
1248        preconditioner_reused: metrics.preconditioner_reused,
1249        solve_sequence: metrics.solve_sequence,
1250        krylov_workspace_bytes: metrics.krylov_workspace_bytes,
1251    }
1252}
1253
1254fn fnv_mix(mut h: u64, value: u64) -> u64 {
1255    const PRIME: u64 = 0x100000001b3;
1256    for b in value.to_le_bytes() {
1257        h ^= b as u64;
1258        h = h.wrapping_mul(PRIME);
1259    }
1260    h
1261}
1262
1263fn matrix_signatures(matrix: &Csr32Matrix) -> (u64, u64) {
1264    let mut structure = 0xcbf29ce484222325u64;
1265    structure = fnv_mix(structure, matrix.nrows() as u64);
1266    structure = fnv_mix(structure, matrix.ncols() as u64);
1267    structure = fnv_mix(structure, matrix.nnz() as u64);
1268    for &v in matrix.row_ptr() {
1269        structure = fnv_mix(structure, v as u64);
1270    }
1271    for &v in matrix.col_idx() {
1272        structure = fnv_mix(structure, v as u64);
1273    }
1274
1275    let mut values = 0xcbf29ce484222325u64;
1276    values = fnv_mix(values, structure);
1277    for &v in matrix.values() {
1278        values = fnv_mix(values, v.to_bits());
1279    }
1280    (structure, values)
1281}
1282fn residual(matrix: &Csr32Matrix, b: &[f64], x: &[f64]) -> Result<Vec<f64>, HybitError> {
1283    let mut ax = vec![0.0; matrix.nrows()];
1284    matrix.apply(x, &mut ax)?;
1285    Ok(b.iter().zip(ax).map(|(&bi, ai)| bi - ai).collect())
1286}
1287
1288fn numerical_risk_mask(
1289    matrix: &Csr32Matrix,
1290    options: HybridOptions,
1291) -> Result<DofMask, HybitError> {
1292    let diagonal = matrix.diagonal()?;
1293    let n = matrix.nrows();
1294    let mut risk = DofMask::new(n);
1295    for row in 0..n {
1296        let diag = diagonal[row].abs();
1297        if diag == 0.0 {
1298            continue;
1299        }
1300        let start = matrix.row_ptr()[row] as usize;
1301        let end = matrix.row_ptr()[row + 1] as usize;
1302        let mut offdiag_sum = 0.0;
1303        let mut max_scale_jump = 1.0f64;
1304        for p in start..end {
1305            let col = matrix.col_idx()[p] as usize;
1306            if col == row {
1307                continue;
1308            }
1309            offdiag_sum += matrix.values()[p].abs();
1310            let neighbor_diag = diagonal[col].abs();
1311            if neighbor_diag > 0.0 {
1312                max_scale_jump =
1313                    max_scale_jump.max((diag / neighbor_diag).max(neighbor_diag / diag));
1314            }
1315        }
1316        let coupling = offdiag_sum / diag;
1317        if coupling >= options.coupling_risk_threshold
1318            || max_scale_jump >= options.scale_jump_threshold
1319        {
1320            risk.set(row, true)?;
1321        }
1322    }
1323    Ok(risk)
1324}
1325
1326fn residual_seed_mask(residual: &[f64], fraction: f64) -> Result<DofMask, HybitError> {
1327    let max_abs = residual.iter().fold(0.0f64, |m, &v| m.max(v.abs()));
1328    let mut seeds = DofMask::new(residual.len());
1329    if max_abs == 0.0 {
1330        return Ok(seeds);
1331    }
1332    let threshold = fraction * max_abs;
1333    for (i, &value) in residual.iter().enumerate() {
1334        if value.abs() >= threshold {
1335            seeds.set(i, true)?;
1336        }
1337    }
1338    Ok(seeds)
1339}
1340
1341fn select_risk_components(
1342    matrix: &Csr32Matrix,
1343    risk: &DofMask,
1344    seeds: &DofMask,
1345    residual: &[f64],
1346    options: HybridOptions,
1347) -> Result<DofMask, HybitError> {
1348    let n = matrix.nrows();
1349    let mut selected = DofMask::new(n);
1350    let mut visited = vec![false; n];
1351    let cap = options.max_local_region_size.max(1);
1352
1353    for start in risk.indices() {
1354        if visited[start] {
1355            continue;
1356        }
1357        let mut queue = VecDeque::new();
1358        let mut component = Vec::new();
1359        queue.push_back(start);
1360        visited[start] = true;
1361        let mut touches_seed = false;
1362        while let Some(row) = queue.pop_front() {
1363            component.push(row);
1364            touches_seed |= seeds.contains(row);
1365            let rs = matrix.row_ptr()[row] as usize;
1366            let re = matrix.row_ptr()[row + 1] as usize;
1367            for p in rs..re {
1368                let col = matrix.col_idx()[p] as usize;
1369                if col < n && risk.contains(col) && !visited[col] {
1370                    visited[col] = true;
1371                    queue.push_back(col);
1372                }
1373            }
1374        }
1375        if !touches_seed {
1376            continue;
1377        }
1378
1379        if component.len() <= cap {
1380            for dof in component {
1381                selected.set(dof, true)?;
1382            }
1383        } else {
1384            let root = *component
1385                .iter()
1386                .max_by(|&&a, &&b| residual[a].abs().total_cmp(&residual[b].abs()))
1387                .expect("component is non-empty");
1388            let mut local_seen = vec![false; n];
1389            let mut local_queue = VecDeque::new();
1390            local_queue.push_back(root);
1391            local_seen[root] = true;
1392            let mut count = 0usize;
1393            while let Some(row) = local_queue.pop_front() {
1394                if count >= cap {
1395                    break;
1396                }
1397                selected.set(row, true)?;
1398                count += 1;
1399                let rs = matrix.row_ptr()[row] as usize;
1400                let re = matrix.row_ptr()[row + 1] as usize;
1401                for p in rs..re {
1402                    let col = matrix.col_idx()[p] as usize;
1403                    if col < n && risk.contains(col) && !local_seen[col] {
1404                        local_seen[col] = true;
1405                        local_queue.push_back(col);
1406                    }
1407                }
1408            }
1409        }
1410    }
1411
1412    if selected.is_empty() {
1413        selected.union_assign(seeds)?;
1414    }
1415    Ok(selected)
1416}
1417
1418fn extract_core_regions(
1419    matrix: &Csr32Matrix,
1420    mask: &DofMask,
1421    residual: &[f64],
1422    options: HybridOptions,
1423) -> Result<Vec<Vec<usize>>, HybitError> {
1424    let n = matrix.nrows();
1425    let mut visited = vec![false; n];
1426    let mut regions: Vec<Vec<usize>> = Vec::new();
1427
1428    for start in mask.indices() {
1429        if visited[start] {
1430            continue;
1431        }
1432        let mut queue = VecDeque::new();
1433        let mut component = Vec::new();
1434        queue.push_back(start);
1435        visited[start] = true;
1436        while let Some(row) = queue.pop_front() {
1437            component.push(row);
1438            let rs = matrix.row_ptr()[row] as usize;
1439            let re = matrix.row_ptr()[row + 1] as usize;
1440            for p in rs..re {
1441                let col = matrix.col_idx()[p] as usize;
1442                if col < n && mask.contains(col) && !visited[col] {
1443                    visited[col] = true;
1444                    queue.push_back(col);
1445                }
1446            }
1447        }
1448        for chunk in component.chunks(options.max_local_region_size) {
1449            if !chunk.is_empty() {
1450                regions.push(chunk.to_vec());
1451            }
1452        }
1453    }
1454
1455    regions.sort_by(|a, b| {
1456        let sa = a.iter().fold(0.0f64, |m, &i| m.max(residual[i].abs()));
1457        let sb = b.iter().fold(0.0f64, |m, &i| m.max(residual[i].abs()));
1458        sb.total_cmp(&sa)
1459    });
1460    regions.truncate(options.max_local_regions);
1461    Ok(regions)
1462}
1463
1464fn expand_regions_with_overlap(
1465    abtm: &AbtmMatrix,
1466    core_regions: &[Vec<usize>],
1467    residual: &[f64],
1468    options: HybridOptions,
1469) -> Result<Vec<Vec<usize>>, HybitError> {
1470    let mut result = Vec::with_capacity(core_regions.len());
1471    for core in core_regions {
1472        let mut mask = DofMask::from_indices(abtm.rows(), core)?;
1473        for _ in 0..options.overlap_layers {
1474            mask = abtm.expand_mask_one_hop(&mask)?;
1475        }
1476        let mut expanded = mask.indices();
1477        if expanded.len() > options.max_local_region_size {
1478            // Core DOFs are never discarded. Fill remaining slots with the
1479            // strongest residual halo candidates. This keeps dense factors bounded.
1480            let mut keep = core.clone();
1481            keep.sort_unstable();
1482            keep.dedup();
1483            if keep.len() > options.max_local_region_size {
1484                keep.truncate(options.max_local_region_size);
1485            } else {
1486                expanded.retain(|dof| keep.binary_search(dof).is_err());
1487                expanded.sort_by(|&a, &b| residual[b].abs().total_cmp(&residual[a].abs()));
1488                let room = options.max_local_region_size - keep.len();
1489                keep.extend(expanded.into_iter().take(room));
1490            }
1491            expanded = keep;
1492        }
1493        expanded.sort_unstable();
1494        expanded.dedup();
1495        if !expanded.is_empty() {
1496            result.push(expanded);
1497        }
1498    }
1499    Ok(result)
1500}
1501
1502#[cfg(test)]
1503mod tests {
1504    use super::*;
1505    use hybit_core::Preconditioner;
1506    use hybit_krylov::pcg;
1507
1508    fn poisson_1d(n: usize) -> Csr32Matrix {
1509        let mut row_ptr = Vec::with_capacity(n + 1);
1510        let mut col_idx = Vec::new();
1511        let mut values = Vec::new();
1512        row_ptr.push(0);
1513        for i in 0..n {
1514            if i > 0 {
1515                col_idx.push((i - 1) as u32);
1516                values.push(-1.0);
1517            }
1518            col_idx.push(i as u32);
1519            values.push(2.0);
1520            if i + 1 < n {
1521                col_idx.push((i + 1) as u32);
1522                values.push(-1.0);
1523            }
1524            row_ptr.push(col_idx.len() as u32);
1525        }
1526        Csr32Matrix::new(n, n, row_ptr, col_idx, values).unwrap()
1527    }
1528
1529    fn block_diagonal(
1530        easy_before: usize,
1531        hard_sizes: &[usize],
1532        easy_between: usize,
1533    ) -> Csr32Matrix {
1534        let n = easy_before
1535            + hard_sizes.iter().sum::<usize>()
1536            + easy_between * hard_sizes.len().saturating_sub(1);
1537        let mut row_ptr = Vec::with_capacity(n + 1);
1538        let mut col_idx = Vec::new();
1539        let mut values = Vec::new();
1540        row_ptr.push(0);
1541        let mut row = 0usize;
1542        for _ in 0..easy_before {
1543            col_idx.push(row as u32);
1544            values.push(1.0);
1545            row += 1;
1546            row_ptr.push(col_idx.len() as u32);
1547        }
1548        for (bi, &hard) in hard_sizes.iter().enumerate() {
1549            let base = row;
1550            for local in 0..hard {
1551                let i = base + local;
1552                if local > 0 {
1553                    col_idx.push((i - 1) as u32);
1554                    values.push(-1.0);
1555                }
1556                col_idx.push(i as u32);
1557                values.push(2.0);
1558                if local + 1 < hard {
1559                    col_idx.push((i + 1) as u32);
1560                    values.push(-1.0);
1561                }
1562                row += 1;
1563                row_ptr.push(col_idx.len() as u32);
1564            }
1565            if bi + 1 < hard_sizes.len() {
1566                for _ in 0..easy_between {
1567                    col_idx.push(row as u32);
1568                    values.push(1.0);
1569                    row += 1;
1570                    row_ptr.push(col_idx.len() as u32);
1571                }
1572            }
1573        }
1574        Csr32Matrix::new(n, n, row_ptr, col_idx, values).unwrap()
1575    }
1576
1577    #[test]
1578    fn auto_solver_converges_on_spd_system() {
1579        let a = poisson_1d(64);
1580        let b = vec![1.0; 64];
1581        let mut x = vec![0.0; 64];
1582        let solver = HybitSolver::new();
1583        let report = solver.solve_csr32(&a, &b, &mut x).unwrap();
1584        assert!(report.converged());
1585        assert!(report.krylov_workspace_bytes > 0);
1586    }
1587
1588    #[test]
1589    fn forced_abtm_converges() {
1590        let a = poisson_1d(64);
1591        let b = vec![1.0; 64];
1592        let mut x = vec![0.0; 64];
1593        let mut solver = HybitSolver::new();
1594        solver.set_backend_policy(BackendPolicy::Abtm);
1595        let report = solver.solve_csr32(&a, &b, &mut x).unwrap();
1596        assert!(report.converged());
1597        assert_eq!(report.backend, MatrixBackend::Abtm);
1598    }
1599
1600    #[test]
1601    fn selective_direct_escalation_beats_plain_jacobi_pcg() {
1602        let a = block_diagonal(32, &[64], 0);
1603        let b = vec![1.0; a.nrows()];
1604        let options = SolverOptions {
1605            relative_tolerance: 1.0e-10,
1606            absolute_tolerance: 0.0,
1607            max_iterations: 100,
1608        };
1609        let jacobi = JacobiPreconditioner::from_csr32(&a).unwrap();
1610        let mut x_plain = vec![0.0; a.nrows()];
1611        let plain = pcg(&a, &jacobi, &b, &mut x_plain, options).unwrap();
1612
1613        let mut solver = HybitSolver::new();
1614        solver.set_options(options).unwrap();
1615        let mut x = vec![0.0; a.nrows()];
1616        let report = solver.solve_csr32(&a, &b, &mut x).unwrap();
1617        assert!(report.converged());
1618        assert_eq!(report.preconditioner, PreconditionerKind::Hybrid);
1619        assert!(report.iterations < plain.iterations);
1620    }
1621
1622    #[test]
1623    fn multi_region_overlap_detects_two_hard_blocks() {
1624        let a = block_diagonal(16, &[48, 48], 8);
1625        let b = vec![1.0; a.nrows()];
1626        let options = SolverOptions {
1627            relative_tolerance: 1.0e-10,
1628            absolute_tolerance: 0.0,
1629            max_iterations: 100,
1630        };
1631        let mut solver = HybitSolver::new();
1632        solver.set_options(options).unwrap();
1633        let mut x = vec![0.0; a.nrows()];
1634        let report = solver.solve_csr32(&a, &b, &mut x).unwrap();
1635        assert!(report.converged());
1636        assert!(report.local_direct_regions >= 2);
1637    }
1638
1639    #[test]
1640    fn hybrid_preconditioner_is_positive_on_overlapping_regions() {
1641        let a = poisson_1d(12);
1642        let hybrid =
1643            HybridPreconditioner::from_csr32(&a, vec![(0..8).collect(), (4..12).collect()])
1644                .unwrap();
1645        let r = vec![1.0; 12];
1646        let mut z = vec![0.0; 12];
1647        hybrid.apply(&r, &mut z).unwrap();
1648        let rz: f64 = r.iter().zip(&z).map(|(a, b)| a * b).sum();
1649        assert!(rz > 0.0);
1650    }
1651
1652    #[test]
1653    fn prepared_context_reuses_hybrid_factor_and_workspace() {
1654        let a = block_diagonal(32, &[64], 0);
1655        let mut solver = HybitSolver::new();
1656        solver
1657            .set_options(SolverOptions {
1658                relative_tolerance: 1.0e-10,
1659                absolute_tolerance: 0.0,
1660                max_iterations: 100,
1661            })
1662            .unwrap();
1663        let analysis = solver.analyze_csr32(&a).unwrap();
1664        let mut prepared = solver.prepare_csr32(&a, &analysis).unwrap();
1665        let workspace_bytes = prepared.krylov_workspace_bytes();
1666
1667        let b1 = vec![1.0; a.nrows()];
1668        let mut x1 = vec![0.0; a.nrows()];
1669        let first = prepared.solve(&a, &b1, &mut x1).unwrap();
1670        assert!(first.converged());
1671        assert!(prepared.has_cached_hybrid());
1672        assert!(!first.preconditioner_reused);
1673        assert!(first.local_factor_seconds >= 0.0);
1674
1675        let mut b2 = vec![1.0; a.nrows()];
1676        b2[0] = 2.0;
1677        let mut x2 = vec![0.0; a.nrows()];
1678        let second = prepared.solve(&a, &b2, &mut x2).unwrap();
1679        assert!(second.converged());
1680        assert!(second.preconditioner_reused);
1681        assert_eq!(second.probe_iterations, 0);
1682        assert_eq!(second.local_factor_seconds, 0.0);
1683        assert_eq!(second.analysis_seconds, 0.0);
1684        assert_eq!(second.prepare_seconds, 0.0);
1685        assert_eq!(second.solve_sequence, 2);
1686        assert_eq!(second.krylov_workspace_bytes, workspace_bytes);
1687    }
1688}