Skip to main content

pounce_feral/
lib.rs

1//! FERAL backend — pure-Rust sparse symmetric LDL^T factor.
2//!
3//! Implements [`SparseSymLinearSolverInterface`] over [`feral::Solver`].
4//! The lifecycle mirrors `pounce_hsl::Ma57SolverInterface`:
5//!
6//! * `matrix_format()` returns [`EMatrixFormat::TripletFormat`] (1-based,
7//!   lower-triangle COO) so the IPM `TSymLinearSolver` wrapper requires
8//!   no changes versus the MA57 path.
9//! * `initialize_structure` caches the 0-based row/col arrays needed by
10//!   FERAL's [`CscMatrix::from_triplets`] and allocates the values
11//!   buffer.
12//! * `multi_solve` rebuilds `CscMatrix` from the cached pattern + caller-
13//!   filled values and dispatches to [`feral::Solver::factor`] /
14//!   [`feral::Solver::solve_many`]. FERAL's pattern-fingerprint cache
15//!   reuses the symbolic factorization across iterates with identical
16//!   structure (the IPM common case).
17//! * `increase_quality` delegates to [`feral::Solver::increase_quality`]
18//!   and uses MA57's `pivtol_changed` / `CallAgain` protocol so the
19//!   upper-layer reload-and-retry semantics line up.
20
21use std::sync::{Arc, Mutex};
22
23use feral::symbolic::SupernodeParams;
24use feral::{CscMatrix, FactorStats, FactorStatus, NumericParams, Solver};
25
26/// Re-export so option-aware callers can construct a
27/// [`FeralConfig`] without taking a direct dependency on `feral`.
28pub use feral::scaling::ScalingStrategy;
29pub use feral::symbolic::OrderingMethod;
30use pounce_common::types::{Index, Number};
31use pounce_linsol::summary::LinearSolverSummary;
32use pounce_linsol::{
33    EMatrixFormat, ESymSolverStatus, FactorPattern, SparseSymLinearSolverInterface,
34};
35
36/// FERAL solver implementing the IPM-side sparse symmetric backend
37/// contract.
38pub struct FeralSolverInterface {
39    solver: Solver,
40
41    initialized: bool,
42    pivtol_changed: bool,
43    refactorize: bool,
44    refine: bool,
45
46    dim: Index,
47    nonzeros: Index,
48
49    /// 0-based row indices, fixed by `initialize_structure`.
50    rows_0: Vec<usize>,
51    /// 0-based column indices, fixed by `initialize_structure`.
52    cols_0: Vec<usize>,
53    /// Caller-filled numerical values, in the same order as
54    /// `(rows_0, cols_0)`.
55    values: Vec<Number>,
56
57    /// Last factored matrix, retained so `backsolve` can run iterative
58    /// refinement against it (feral's `solve_*_refined` requires `A`).
59    matrix: Option<CscMatrix>,
60
61    negevals: Index,
62
63    /// Fill-reducing ordering configured at construction; surfaced on
64    /// the `linear_solve` tracing span after each factorization
65    /// (pounce#71).
66    ordering: OrderingMethod,
67
68    /// Absolute near-singularity floor; see
69    /// [`FeralConfig::singular_pivot_floor`].
70    singular_pivot_floor: f64,
71
72    /// Running aggregate updated after every successful `factor()`.
73    /// Exposed via [`Self::summary`] and (mirrored to) the optional
74    /// `sink` so an out-of-band consumer can read it post-solve
75    /// without plumbing through the algorithm's wrapper layers.
76    summary: LinearSolverSummary,
77
78    /// Optional shared sink updated alongside `summary`. Decouples
79    /// the algorithm-internal solver lifecycle from CLI / report
80    /// consumers — pounce-cli installs an `Arc<Mutex<...>>` via
81    /// [`Self::with_summary_sink`] and reads it after
82    /// `optimize_tnlp` returns.
83    sink: Option<Arc<Mutex<LinearSolverSummary>>>,
84}
85
86/// Construction-time configuration for [`FeralSolverInterface`].
87///
88/// Mirrors the pounce-extension options registered in
89/// `pounce-algorithm`'s `upstream_options::register_all_options`
90/// (`feral_cascade_break`, `feral_fma`, `feral_refine`,
91/// `feral_singular_pivot_floor`). The IPM
92/// caller reads those off its `OptionsList`, builds a `FeralConfig`,
93/// and passes it to [`FeralSolverInterface::with_config`]. For
94/// non-option callers (tests, standalone use, the env-only legacy
95/// path), [`FeralSolverInterface::new`] keeps reading the
96/// `POUNCE_FERAL_*` env vars to preserve the historic defaults.
97#[derive(Debug, Clone)]
98pub struct FeralConfig {
99    /// Tri-state. `None` (the pounce default) inherits whatever FERAL's
100    /// `NumericParams::default()` ships with — as of FERAL Phase B
101    /// (issue #55, commit 7554a78) that is CB armed with
102    /// `ratio = 0.5, eps = 1e-10` and a symbolic-time delay budget.
103    /// `Some(true)` explicitly arms with the same constants (matches
104    /// the FERAL default; useful when a caller wants the intent
105    /// recorded). `Some(false)` explicitly disarms by setting both
106    /// `cascade_break_ratio` and `cascade_break_eps` to `None`; this
107    /// is what enables FERAL's `DelayBudgetExceeded` path for non-root
108    /// cascade victims and should only be used to reproduce the
109    /// pre-Phase-B behaviour.
110    pub cascade_break: Option<bool>,
111    pub fma: bool,
112    pub refine: bool,
113    /// Near-singularity trigger: if the smallest accepted D-block pivot
114    /// magnitude `min|λ(D)|` (scaled space) falls below this absolute
115    /// floor, `factor()` returns [`ESymSolverStatus::Singular`] even
116    /// though feral force-accepted the pivot and reported `Success`.
117    /// This is pounce's analog of MA57's `CNTL(2)` small-pivot
118    /// threshold — an absolute magnitude on the *scaled* pivot, not a
119    /// ratio: a genuinely rank-deficient pivot sits at the working-
120    /// precision floor regardless of the rest of the spectrum, whereas
121    /// `min/max` ≈ 1/κ(D) collapses on any healthy interior-point KKT
122    /// as `μ→0`. Routes into the IPM's `PerturbForSingularity` branch
123    /// so `δ_w` is bumped. `0` disables the trigger. See
124    /// `dev/research/near-singularity-signal.md` (feral) §4.
125    pub singular_pivot_floor: f64,
126    /// Relative Bunch-Kaufman partial-pivoting threshold `u`: a
127    /// candidate diagonal pivot is rejected when `|d| < u * col_max`.
128    /// Direct analog of Ipopt's `ma27_pivtol` / `ma57_pivtol`. Smaller
129    /// `u` preserves the AMD ordering and keeps `L` sparse; larger
130    /// `u` rejects more candidates, delaying pivots / forcing 2x2
131    /// blocks for accuracy. LAPACK's textbook maximum-stability value
132    /// is `0.5`. Default `1e-8` matches feral's `NumericParams`.
133    pub pivtol: f64,
134    /// Fill-reducing ordering method passed to
135    /// [`feral::Solver::with_ordering`]. Default
136    /// [`OrderingMethod::Auto`]: the adaptive dispatcher picks a
137    /// concrete method per matrix from cheap pattern features (very-
138    /// large-and-sparse → AMD; `n ≤ 10 000` → AMF; otherwise →
139    /// MetisND). Override via the `feral_ordering` OptionsList option
140    /// or the `POUNCE_FERAL_ORDERING` env var when a specific
141    /// concrete method (`amd`, `amf`, `metis`, `scotch`, `kahip`) or
142    /// the symbolic-time race (`auto_race`) is wanted. See
143    /// `feral/src/symbolic/mod.rs::OrderingMethod` for the
144    /// per-variant rationale.
145    pub ordering: OrderingMethod,
146    /// Diagonal scaling strategy passed to
147    /// [`feral::Solver::with_scaling`]. Default
148    /// [`ScalingStrategy::Auto`]: FERAL's adaptive shape-based router
149    /// picks `Mc64Symmetric` on arrow-KKT signatures and `InfNorm`
150    /// otherwise. Override via the `feral_scaling` OptionsList option
151    /// or the `POUNCE_FERAL_SCALING` env var. The opt-in choices are
152    /// `infnorm` (Knight-Ruiz ∞-norm equilibration), `mc64`
153    /// (MC64-style symmetric matching — MUMPS SYM=2 / SSIDS default,
154    /// recovers exact inertia on some ill-conditioned KKTs where
155    /// `Auto` mis-pivots; see feral#65), and `identity` (no-op, for
156    /// regression testing). See `feral/src/scaling/mod.rs::
157    /// ScalingStrategy` for the per-variant rationale.
158    pub scaling: ScalingStrategy,
159    /// Per-backend internal-parallelism toggle (tri-state). `None` (the
160    /// default) leaves feral's `Solver` at its own default and lets the
161    /// legacy `FERAL_PARALLEL` env var still force serial; `Some(false)`
162    /// builds an explicitly **serial** factor; `Some(true)` forces feral's
163    /// internal rayon parallelism on. This is the first-class lever for
164    /// outer-parallel / inner-serial batched solving — each rayon worker
165    /// builds its own `Some(false)` backend, with no global state (pounce
166    /// issue #79). feral reads `Solver::use_parallel` fresh on every
167    /// `factor()`, so two backends with different settings never interfere.
168    pub parallel: Option<bool>,
169}
170
171impl Default for FeralConfig {
172    fn default() -> Self {
173        Self {
174            cascade_break: None,
175            fma: false,
176            refine: true,
177            // MA57 `CNTL(2)` default — an absolute small-pivot
178            // magnitude on the scaled matrix. Only pivots essentially
179            // at the working-precision floor are flagged singular.
180            singular_pivot_floor: 1e-20,
181            pivtol: 1e-8,
182            ordering: OrderingMethod::Auto,
183            scaling: ScalingStrategy::Auto,
184            parallel: None,
185        }
186    }
187}
188
189/// Resolve the feral pivot-threshold env override from its two accepted
190/// variable names. The documented `POUNCE_FERAL_PIVTOL` — the
191/// `POUNCE_FERAL_*` convention shared by every other knob in
192/// [`FeralConfig::from_env`] — takes precedence; the bare `FERAL_PIVTOL` is
193/// retained as a deprecated legacy alias for back-compatibility. An unset or
194/// unparseable value falls through to the next source, and with neither set
195/// to the `1e-8` default (matching [`FeralConfig::default`]).
196fn resolve_pivtol_env(pounce: Option<&str>, legacy: Option<&str>) -> f64 {
197    pounce
198        .and_then(|s| s.parse::<f64>().ok())
199        .or_else(|| legacy.and_then(|s| s.parse::<f64>().ok()))
200        .unwrap_or(1e-8)
201}
202
203impl FeralConfig {
204    /// Read the knobs from `POUNCE_FERAL_CASCADE_BREAK`,
205    /// `POUNCE_FERAL_FMA`, `POUNCE_FERAL_REFINE`,
206    /// `POUNCE_FERAL_SINGULAR_PIVOT_FLOOR`, `POUNCE_FERAL_PIVTOL`,
207    /// `POUNCE_FERAL_ORDERING`, `POUNCE_FERAL_SCALING` environment
208    /// variables. Used as a fallback when the IPM has no `OptionsList` to
209    /// consult (tests, legacy callers). The pivot threshold also accepts the
210    /// deprecated bare `FERAL_PIVTOL` as a legacy alias (see
211    /// [`resolve_pivtol_env`]).
212    pub fn from_env() -> Self {
213        Self {
214            cascade_break: match std::env::var("POUNCE_FERAL_CASCADE_BREAK").as_deref() {
215                Ok("1") | Ok("on") | Ok("true") | Ok("yes") => Some(true),
216                Ok("0") | Ok("off") | Ok("false") | Ok("no") => Some(false),
217                _ => None,
218            },
219            fma: matches!(
220                std::env::var("POUNCE_FERAL_FMA").as_deref(),
221                Ok("1") | Ok("on") | Ok("true") | Ok("yes"),
222            ),
223            refine: !matches!(
224                std::env::var("POUNCE_FERAL_REFINE").as_deref(),
225                Ok("0") | Ok("false") | Ok("off") | Ok("no"),
226            ),
227            singular_pivot_floor: std::env::var("POUNCE_FERAL_SINGULAR_PIVOT_FLOOR")
228                .ok()
229                .and_then(|s| s.parse::<f64>().ok())
230                .unwrap_or(1e-20),
231            pivtol: resolve_pivtol_env(
232                std::env::var("POUNCE_FERAL_PIVTOL").ok().as_deref(),
233                std::env::var("FERAL_PIVTOL").ok().as_deref(),
234            ),
235            ordering: std::env::var("POUNCE_FERAL_ORDERING")
236                .ok()
237                .as_deref()
238                .and_then(parse_ordering_method)
239                .unwrap_or(OrderingMethod::Auto),
240            scaling: std::env::var("POUNCE_FERAL_SCALING")
241                .ok()
242                .as_deref()
243                .and_then(parse_scaling_strategy)
244                .unwrap_or(ScalingStrategy::Auto),
245            // Left `None` so the legacy `FERAL_PARALLEL` env var still acts
246            // as the fallback serial switch in `with_config`; callers that
247            // want an explicit per-backend setting use `FeralConfig.parallel`
248            // directly (e.g. `FeralSolverInterface::serial`).
249            parallel: None,
250        }
251    }
252}
253
254/// Parse a case-insensitive ordering tag (the values accepted by the
255/// `feral_ordering` OptionsList option and the `POUNCE_FERAL_ORDERING`
256/// env var) into the corresponding [`OrderingMethod`]. Returns `None`
257/// for unrecognized tags so the caller can fall back to the default.
258pub fn parse_ordering_method(s: &str) -> Option<OrderingMethod> {
259    match s.trim().to_ascii_lowercase().as_str() {
260        "auto" => Some(OrderingMethod::Auto),
261        "auto_race" | "autorace" | "race" => Some(OrderingMethod::AutoRace),
262        "amd" => Some(OrderingMethod::Amd),
263        "amf" => Some(OrderingMethod::Amf),
264        "metis" | "metis_nd" | "metisnd" => Some(OrderingMethod::MetisND),
265        "scotch" | "scotch_nd" | "scotchnd" => Some(OrderingMethod::ScotchND),
266        "kahip" | "kahip_nd" | "kahipnd" => Some(OrderingMethod::KahipND),
267        _ => None,
268    }
269}
270
271/// Parse a case-insensitive scaling tag (the values accepted by the
272/// `feral_scaling` OptionsList option and the `POUNCE_FERAL_SCALING`
273/// env var) into the corresponding [`ScalingStrategy`]. Returns `None`
274/// for unrecognized tags (and for `external`, which carries a vector
275/// that cannot be supplied via a string option) so the caller can fall
276/// back to the default.
277pub fn parse_scaling_strategy(s: &str) -> Option<ScalingStrategy> {
278    match s.trim().to_ascii_lowercase().as_str() {
279        "auto" => Some(ScalingStrategy::Auto),
280        "infnorm" | "inf_norm" | "inf" => Some(ScalingStrategy::InfNorm),
281        "mc64" | "mc64symmetric" | "mc64_symmetric" => Some(ScalingStrategy::Mc64Symmetric),
282        "identity" | "none" => Some(ScalingStrategy::Identity),
283        _ => None,
284    }
285}
286
287impl FeralSolverInterface {
288    /// Construct with config read from environment variables. Retained
289    /// for legacy callers (tests, anything without an IPM options
290    /// list). Prefer [`Self::with_config`] from option-aware sites so
291    /// the `.opt`-file knobs take effect.
292    pub fn new() -> Self {
293        Self::with_config(FeralConfig::from_env())
294    }
295
296    /// Construct a backend with feral's internal parallelism **disabled**
297    /// (inheriting all other env-driven config). Each rayon worker in an
298    /// outer-parallel / inner-serial batch builds one of these directly, so
299    /// the only parallelism is across instances — no global `FERAL_PARALLEL`
300    /// mutation (pounce issue #79).
301    pub fn serial() -> Self {
302        Self::with_config(FeralConfig {
303            parallel: Some(false),
304            ..FeralConfig::from_env()
305        })
306    }
307
308    /// Construct with explicit configuration. Cascade-break
309    /// (`ratio=0.5, eps=1e-10`) was off by default in pounce for a
310    /// period after the issue-17/issue-18 inertia investigations,
311    /// when the FERAL Bunch-Kaufman heuristic could not bound the
312    /// per-supernode delayed-pivot catchment and spurious
313    /// `WrongInertia` returns on borderline iterates (robot_1600
314    /// iter-3, NARX_CFy iters 1+, ~250 spurious records — feral
315    /// journal 2026-05-16 21:30) cost more than CB's per-factor
316    /// speedup (pinene_3200_0009: 33 ms cb-on vs 94 s cb-off).
317    /// FERAL issue #55 Phase B (commit 7554a78) bounds the catchment
318    /// at symbolic-analysis time and arms CB out of the box, so
319    /// pounce now inherits the FERAL default (CB on) when the
320    /// `feral_cascade_break` option is left unset. See
321    /// [`FeralConfig::cascade_break`] for the tri-state semantics.
322    pub fn with_config(cfg: FeralConfig) -> Self {
323        // `POUNCE_FERAL_MIN_PAR_FLOPS=<u64>` overrides feral's parallel-
324        // dispatch flop gate (feral#19, default 10^8). `0` fires the
325        // gate on every multi-child tree ≥ N_PAR_MIN supernodes (pre-
326        // feral#19 behavior). `u64::MAX` rejects all parallel dispatch
327        // at the tree level. Useful when the per-hardware break-even
328        // is far from feral's default.
329        let mut np = NumericParams::default();
330        if let Ok(s) = std::env::var("POUNCE_FERAL_MIN_PAR_FLOPS") {
331            if let Ok(v) = s.parse::<u64>() {
332                np.min_parallel_flops = Some(v);
333            }
334        }
335        // Relative Bunch-Kaufman partial-pivoting threshold — the
336        // analog of Ipopt's `ma27_pivtol` / `ma57_pivtol`. Surfaced as
337        // the `feral_pivtol` OptionsList option (registered in
338        // pounce-algorithm's `upstream_options::register_all_options`),
339        // with the `POUNCE_FERAL_PIVTOL` env var (or its deprecated legacy
340        // alias `FERAL_PIVTOL`) as a fallback read in
341        // `FeralConfig::from_env`.
342        np.bk.pivot_threshold = cfg.pivtol;
343        // Cascade-break (FERAL issue #55 Phase B, commit 7554a78):
344        // `NumericParams::default()` now arms CB with `ratio = 0.5,
345        // eps = 1e-10` and bounds delayed-pivot catchment via a
346        // symbolic-time delay budget. Pounce no longer disables CB by
347        // default — the tri-state `cfg.cascade_break` only intervenes
348        // when the caller explicitly set the option:
349        //   None        — leave `np` alone (inherit FERAL default = on)
350        //   Some(true)  — explicit re-arm (matches the default; no-op
351        //                 in behaviour, but records caller intent)
352        //   Some(false) — explicit disarm (re-enables FERAL's
353        //                 `DelayBudgetExceeded` path on non-root
354        //                 cascade victims; only meaningful for
355        //                 reproducing pre-Phase-B behaviour)
356        match cfg.cascade_break {
357            None => {}
358            Some(true) => {
359                np.cascade_break_ratio = Some(0.5);
360                np.cascade_break_eps = Some(1e-10);
361            }
362            Some(false) => {
363                np.cascade_break_ratio = None;
364                np.cascade_break_eps = None;
365            }
366        }
367        let mut solver = Solver::with_params(np, SupernodeParams::default());
368        // Internal-parallelism toggle. An explicit `cfg.parallel` is the
369        // primary, per-backend lever (no global state); when unset, fall
370        // back to the legacy process-wide `FERAL_PARALLEL` env var for
371        // backward compatibility.
372        match cfg.parallel {
373            Some(p) => solver = solver.with_parallel(p),
374            None => {
375                if matches!(
376                    std::env::var("FERAL_PARALLEL").as_deref(),
377                    Ok("0") | Ok("false") | Ok("off")
378                ) {
379                    solver = solver.with_parallel(false);
380                }
381            }
382        }
383        if cfg.fma {
384            solver = solver.with_fma(true);
385        }
386        // Fill-reducing ordering. `OrderingMethod::Auto` is pounce's
387        // default — it picks a concrete method per-matrix from cheap
388        // pattern features. Override via the `feral_ordering`
389        // OptionsList option or `POUNCE_FERAL_ORDERING` env var.
390        solver = solver.with_ordering(cfg.ordering);
391        // Diagonal scaling. `ScalingStrategy::Auto` is pounce's default
392        // — FERAL's adaptive shape-based router. Override via the
393        // `feral_scaling` OptionsList option or `POUNCE_FERAL_SCALING`
394        // env var (e.g. `mc64` recovers exact inertia on some
395        // ill-conditioned KKTs where `Auto` mis-pivots — feral#65).
396        solver = solver.with_scaling(cfg.scaling.clone());
397        Self {
398            solver,
399            initialized: false,
400            pivtol_changed: false,
401            refactorize: false,
402            refine: cfg.refine,
403            dim: 0,
404            nonzeros: 0,
405            rows_0: Vec::new(),
406            cols_0: Vec::new(),
407            values: Vec::new(),
408            matrix: None,
409            negevals: 0,
410            ordering: cfg.ordering,
411            singular_pivot_floor: cfg.singular_pivot_floor,
412            summary: LinearSolverSummary {
413                solver_name: "feral".to_string(),
414                ..Default::default()
415            },
416            sink: None,
417        }
418    }
419
420    /// Install a shared summary sink. The interface updates the sink
421    /// (and the internal `summary`) after every successful
422    /// `factor()`. Default is no sink — calls then go only to the
423    /// internal `summary`.
424    pub fn with_summary_sink(mut self, sink: Arc<Mutex<LinearSolverSummary>>) -> Self {
425        self.sink = Some(sink);
426        self
427    }
428
429    /// Snapshot of the post-solve aggregate. Always populated (no
430    /// opt-in needed for the always-on Phase A stats from feral 0.7).
431    pub fn summary(&self) -> LinearSolverSummary {
432        self.summary.clone()
433    }
434
435    /// Fold a single feral `FactorStats` into the running summary,
436    /// then mirror the snapshot into the sink if one is installed.
437    fn record_factor_stats(&mut self, stats: FactorStats) {
438        let s = &mut self.summary;
439        s.n_factors += 1;
440        if stats.pattern_reused {
441            s.n_pattern_reuse += 1;
442        } else {
443            s.n_pattern_changes += 1;
444        }
445        s.max_fill_ratio = Some(match s.max_fill_ratio {
446            Some(prev) => prev.max(stats.fill_ratio),
447            None => stats.fill_ratio,
448        });
449        s.min_abs_pivot = Some(match s.min_abs_pivot {
450            Some(prev) => prev.min(stats.min_abs_pivot),
451            None => stats.min_abs_pivot,
452        });
453        s.max_abs_pivot = Some(match s.max_abs_pivot {
454            Some(prev) => prev.max(stats.max_abs_pivot),
455            None => stats.max_abs_pivot,
456        });
457        s.last_inertia = Some((
458            stats.inertia.positive,
459            stats.inertia.negative,
460            stats.inertia.zero,
461        ));
462        s.last_nnz_a = Some(stats.nnz_a);
463        s.last_nnz_l = Some(stats.nnz_l);
464
465        if let Some(sink) = self.sink.as_ref() {
466            if let Ok(mut guard) = sink.lock() {
467                *guard = s.clone();
468            }
469        }
470
471        // Surface the linear-solve characteristics on the enclosing
472        // `linear_solve` tracing span (pounce#71). A no-op unless that
473        // span is active and declared these fields, so non-IPM callers
474        // and the no-subscriber case pay nothing. Re-factorizations
475        // (regularization retries) overwrite with last-wins, so the
476        // span reflects the accepted factorization.
477        let span = tracing::Span::current();
478        span.record("n", self.dim);
479        span.record("matrix_nnz", stats.nnz_a);
480        span.record("factor_nnz", stats.nnz_l);
481        span.record("inertia_neg", stats.inertia.negative);
482        span.record("fill_ratio", stats.fill_ratio);
483        span.record("ordering", tracing::field::debug(self.ordering));
484    }
485
486    /// Build the lower-triangle CSC view, factor it, and stash the
487    /// strict-negative-eigenvalue count (IPOPT / MA57 `INFO(24)`
488    /// convention). Rank deficiency (zero pivots) is reported as
489    /// `Singular` so the outer loop routes to `perturb_for_singular`.
490    fn factor(&mut self, check_neg_evals: bool, number_of_neg_evals: Index) -> ESymSolverStatus {
491        let n = self.dim as usize;
492        // Hand the KKT to feral with its structure intact. Where a
493        // constraint multiplier is zero (e.g. the initial point) the
494        // (2,2) diagonal lands as structurally-present exact `0.0`
495        // values; feral handles those explicit zeros correctly and
496        // without a delayed-pivot penalty, so pounce must NOT strip
497        // them — dropping them leaves the constraint columns with no
498        // diagonal, which is the structurally-absent-(2,2) cascade
499        // (feral#46) the strip was meant to avoid.
500        let matrix = match CscMatrix::from_triplets(n, &self.rows_0, &self.cols_0, &self.values) {
501            Ok(m) => m,
502            Err(_) => return ESymSolverStatus::FatalError,
503        };
504
505        let status = self.solver.factor(&matrix, None);
506        // Keep the matrix for refinement in backsolve, regardless of
507        // factor outcome — the caller may still issue solves against
508        // a stale factor in some restart paths.
509        self.matrix = Some(matrix);
510        match status {
511            FactorStatus::Success => {
512                if let Some(stats) = self.solver.last_factor_stats() {
513                    self.record_factor_stats(stats);
514                }
515                // IPOPT / MA57 convention: `number_of_neg_evals` is the
516                // count of strict negative pivots (MA57's INFO(24)). Zero
517                // pivots are reported separately by signalling `Singular`,
518                // which routes the outer loop to `perturb_for_singular`
519                // (bumping δ_c on rank-deficient constraint rows) instead
520                // of `perturb_for_wrong_inertia` (bumping δ_x). Folding
521                // zero into negevals — the SSIDS bookkeeping convention —
522                // is correct for spectral accounting but breaks IPOPT's
523                // singularity branch on LP-shaped KKTs whose (3,3) block
524                // is structurally zero. See pounce gh#52 / feral gh#54.
525                let (neg, zero) = match self.solver.inertia() {
526                    Some(i) => (i.negative, i.zero),
527                    None => (self.solver.num_negative_eigenvalues(), 0),
528                };
529                self.negevals = neg as Index;
530                if zero > 0 {
531                    tracing::debug!(
532                        target: "pounce::linsol",
533                        neg, zero, expected = number_of_neg_evals, dim = self.dim,
534                        "inertia singular"
535                    );
536                    return ESymSolverStatus::Singular;
537                }
538                if check_neg_evals && self.negevals != number_of_neg_evals {
539                    tracing::debug!(
540                        target: "pounce::linsol",
541                        got_neg = self.negevals, expected = number_of_neg_evals, dim = self.dim,
542                        "inertia mismatch"
543                    );
544                    return ESymSolverStatus::WrongInertia;
545                }
546                // Near-singularity (MA57 CNTL(2) analog). feral's default
547                // `ZeroPivotAction::ForceAccept` completes the factorization
548                // and reports `Success` even on a pivot at the working-
549                // precision floor. We flag `Singular` only when the smallest
550                // accepted D-block pivot magnitude drops below an absolute
551                // floor — the literal `CNTL(2)` quantity. A ratio test
552                // `min/max` ≈ 1/κ(D) is wrong here: an interior-point KKT
553                // is *designed* to become ill-conditioned as `μ→0`, so the
554                // ratio collapses on healthy full-rank systems near the
555                // solution. The absolute floor moves with neither `μ` nor
556                // the spectral spread. See
557                // `dev/research/near-singularity-signal.md` (feral) §4.
558                if self.singular_pivot_floor > 0.0 {
559                    if let Some(min_piv) = self.solver.min_pivot_magnitude() {
560                        if min_piv < self.singular_pivot_floor {
561                            return ESymSolverStatus::Singular;
562                        }
563                    }
564                }
565                ESymSolverStatus::Success
566            }
567            FactorStatus::Singular => ESymSolverStatus::Singular,
568            FactorStatus::WrongInertia { .. } => {
569                // Should not occur — we passed `None` for check_inertia.
570                ESymSolverStatus::FatalError
571            }
572            FactorStatus::FatalError(_) => ESymSolverStatus::FatalError,
573        }
574    }
575
576    fn backsolve(&self, nrhs: Index, rhs_vals: &mut [Number]) -> ESymSolverStatus {
577        let n = self.dim as usize;
578        let nrhs = nrhs as usize;
579        debug_assert_eq!(rhs_vals.len(), n * nrhs);
580
581        let solved = match (self.refine, self.matrix.as_ref(), nrhs == 1) {
582            (true, Some(m), true) => self.solver.solve_refined(m, rhs_vals),
583            (true, Some(m), false) => self.solver.solve_many_refined(m, rhs_vals, nrhs),
584            (_, _, true) => self.solver.solve(rhs_vals),
585            (_, _, false) => self.solver.solve_many(rhs_vals, nrhs),
586        };
587        match solved {
588            Ok(x) => {
589                rhs_vals.copy_from_slice(&x);
590                ESymSolverStatus::Success
591            }
592            Err(_) => ESymSolverStatus::FatalError,
593        }
594    }
595}
596
597impl Default for FeralSolverInterface {
598    fn default() -> Self {
599        Self::new()
600    }
601}
602
603impl std::fmt::Debug for FeralSolverInterface {
604    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
605        f.debug_struct("FeralSolverInterface")
606            .field("dim", &self.dim)
607            .field("nonzeros", &self.nonzeros)
608            .field("initialized", &self.initialized)
609            .field("negevals", &self.negevals)
610            .finish_non_exhaustive()
611    }
612}
613
614impl SparseSymLinearSolverInterface for FeralSolverInterface {
615    fn initialize_structure(
616        &mut self,
617        dim: Index,
618        nonzeros: Index,
619        ia: &[Index],
620        ja: &[Index],
621    ) -> ESymSolverStatus {
622        assert_eq!(ia.len(), nonzeros as usize);
623        assert_eq!(ja.len(), nonzeros as usize);
624
625        self.dim = dim;
626        self.nonzeros = nonzeros;
627        self.values = vec![0.0; nonzeros as usize];
628
629        // Convert 1-based MA57-style indices to 0-based for FERAL, and
630        // canonicalize each entry to the lower triangle. MA57 accepts
631        // either triangle of a symmetric COO; pounce's KKT assembly
632        // takes advantage of that and emits a mix of lower- and
633        // upper-triangle entries. FERAL's `CscMatrix::from_triplets`
634        // documents "Entries must be lower-triangle (row >= col)" but
635        // does NOT check it — upper-triangle entries get stored in the
636        // CSC structure where the LDL^T factorization ignores them,
637        // silently dropping them from the factored matrix.
638        self.rows_0 = Vec::with_capacity(nonzeros as usize);
639        self.cols_0 = Vec::with_capacity(nonzeros as usize);
640        for k in 0..nonzeros as usize {
641            let i = (ia[k] - 1) as usize;
642            let j = (ja[k] - 1) as usize;
643            if i >= j {
644                self.rows_0.push(i);
645                self.cols_0.push(j);
646            } else {
647                self.rows_0.push(j);
648                self.cols_0.push(i);
649            }
650        }
651
652        self.initialized = true;
653        ESymSolverStatus::Success
654    }
655
656    fn values_array_mut(&mut self) -> &mut [Number] {
657        debug_assert!(self.initialized);
658        &mut self.values
659    }
660
661    fn multi_solve(
662        &mut self,
663        new_matrix: bool,
664        _ia: &[Index],
665        _ja: &[Index],
666        nrhs: Index,
667        rhs_vals: &mut [Number],
668        check_neg_evals: bool,
669        number_of_neg_evals: Index,
670    ) -> ESymSolverStatus {
671        // Quality was bumped since the last factor → caller must refill
672        // values and we'll re-factor. Mirrors MA57's protocol.
673        if self.pivtol_changed {
674            self.pivtol_changed = false;
675            if !new_matrix {
676                self.refactorize = true;
677                return ESymSolverStatus::CallAgain;
678            }
679        }
680
681        if new_matrix || self.refactorize {
682            let status = self.factor(check_neg_evals, number_of_neg_evals);
683            if status != ESymSolverStatus::Success {
684                return status;
685            }
686            self.refactorize = false;
687        }
688
689        self.backsolve(nrhs, rhs_vals)
690    }
691
692    fn number_of_neg_evals(&self) -> Index {
693        debug_assert!(self.initialized);
694        self.negevals
695    }
696
697    fn increase_quality(&mut self) -> bool {
698        // Mirror ipopt-feral (IpFeralSolverInterface.cpp:134): no pivtol
699        // escalation here. Returning false hands recovery to
700        // PDPerturbationHandler so matrix-side regularization (`lg(rg)`)
701        // is the single escalator, matching ipopt-feral's trajectory.
702        false
703    }
704
705    fn provides_inertia(&self) -> bool {
706        true
707    }
708
709    fn matrix_format(&self) -> EMatrixFormat {
710        EMatrixFormat::TripletFormat
711    }
712
713    fn provides_degeneracy_detection(&self) -> bool {
714        true
715    }
716
717    /// Find the dependent rows of a constraint Jacobian `J` (an
718    /// `n_rows × n_cols` matrix given as a 1-based triplet) by
719    /// rank-revealing the scaled augmented system
720    ///
721    /// ```text
722    ///     M = [ s·I_n   Jᵀ ]    s = max(1, max|J|),  d = n_cols + n_rows
723    ///         [   J     0  ]
724    /// ```
725    ///
726    /// `M` is symmetric with `rank(M) = n_cols + rank(J)`, so it has
727    /// exactly `n_rows − rank(J)` singular pivots — one per dependent
728    /// row. Scaling the identity block by `s ≥ max|J|` makes every
729    /// `x`-column's diagonal the column maximum, so feral's threshold
730    /// partial pivoting pins each of the first `n_cols` rows on its own
731    /// column; the singular pivots therefore fall exclusively in the
732    /// `J`-row block `[n_cols, d)`, and `perm[k] − n_cols` is the
733    /// dependent row for each singular pivot position `k`. Working off
734    /// the well-conditioned augmented system (rather than `JJᵀ`, whose
735    /// squared conditioning blurs near-dependent rows) is the standard
736    /// Ipopt `DetermineDependentRows` recipe.
737    fn determine_dependent_rows(
738        &mut self,
739        n_rows: Index,
740        n_cols: Index,
741        irn: &[Index],
742        jcn: &[Index],
743        vals: &[Number],
744        c_deps: &mut Vec<Index>,
745    ) -> ESymSolverStatus {
746        use feral::{
747            LuParams, LuScaling, LuSingularAction, SparseColMatrix, SparseLu, SparseLuSymbolic,
748        };
749
750        c_deps.clear();
751        let n_rows = n_rows as usize;
752        let n_cols = n_cols as usize;
753        if n_rows == 0 {
754            return ESymSolverStatus::Success;
755        }
756        let nnz = vals.len();
757        if irn.len() != nnz || jcn.len() != nnz {
758            return ESymSolverStatus::FatalError;
759        }
760
761        // Identity-block scale: dominate every J entry so the x-columns
762        // pivot on their own diagonal (see method doc).
763        let a_max = vals.iter().fold(0.0_f64, |m, &v| m.max(v.abs()));
764        let s = a_max.max(1.0);
765
766        // Build M column-by-column: cols[col] = list of (row, value).
767        let d = n_cols + n_rows;
768        let mut cols: Vec<Vec<(usize, f64)>> = vec![Vec::new(); d];
769        for c in 0..n_cols {
770            cols[c].push((c, s)); // s·I_n on the leading x-columns
771        }
772        for t in 0..nnz {
773            let i = (irn[t] - 1) as usize; // 0-based J row
774            let c = (jcn[t] - 1) as usize; // 0-based J col
775            if i >= n_rows || c >= n_cols {
776                return ESymSolverStatus::FatalError; // malformed triplet
777            }
778            let v = vals[t];
779            cols[c].push((n_cols + i, v)); // J in the bottom-left block
780            cols[n_cols + i].push((c, v)); // Jᵀ in the top-right block
781        }
782
783        let m = match SparseColMatrix::from_sparse_columns(d, &cols) {
784            Ok(m) => m,
785            Err(_) => return ESymSolverStatus::FatalError,
786        };
787        // Fill-reducing (AMD) order — `natural` blows up on large augmented
788        // systems (gen: d≈6000). The dependent-row mapping below is unaffected:
789        // it reads the *row pivot* permutation `perm()`, and the s-scaling pins
790        // each x-column to its own x-row diagonal regardless of column order.
791        let symbolic = match SparseLuSymbolic::analyze(&m) {
792            Ok(sym) => sym,
793            Err(_) => return ESymSolverStatus::FatalError,
794        };
795
796        // feral computes its singularity floor as ztol = zero_pivot_tol·max|M|
797        // (= zero_pivot_tol·s, since the identity scale dominates J) and
798        // perturbs singular pivots up to `abs_floor`; setting abs_floor = ztol
799        // keeps a perturbed pivot at the floor so it is detectable below.
800        let zero_pivot_tol = 1e-13;
801        let ztol = zero_pivot_tol * s;
802        let params = LuParams {
803            on_singular: LuSingularAction::PerturbToEps { abs_floor: ztol },
804            scaling: LuScaling::None,
805            zero_pivot_tol,
806            ..LuParams::default()
807        };
808        let lu = match SparseLu::factor(&m, &symbolic, params) {
809            Ok(lu) => lu,
810            Err(_) => return ESymSolverStatus::FatalError,
811        };
812
813        // A pivot position is singular when its U diagonal sits at/below the
814        // detection floor; independent pivots are O(s) ≫ this floor. The
815        // s-scaling normally maps singular pivots to J-rows (perm[k] ≥
816        // n_cols), but under heavy degeneracy (e.g. the elastic phase-1
817        // vertices of NETLIB afiro/gen) AMD fill-in plus the Jᵀ coupling can
818        // push a near-zero pivot onto an x-row. That is not an invariant
819        // violation — we only collect J-row dependencies, so an x-row
820        // singular pivot is simply skipped. Under-reporting here is safe:
821        // the caller re-prunes / inertia-shifts on the next iteration.
822        let dep_tol = 1e-9 * s;
823        let perm = lu.perm();
824        for k in 0..d {
825            if lu.u_dense(k, k).abs() <= dep_tol {
826                let r = perm[k];
827                if r >= n_cols {
828                    c_deps.push((r - n_cols) as Index);
829                }
830            }
831        }
832        c_deps.sort_unstable();
833        ESymSolverStatus::Success
834    }
835
836    /// Walk feral's per-supernode `NodeFactors` to assemble the LDLᵀ
837    /// factor's strict-lower nonzero pattern in *permuted*
838    /// coordinates. `perm` is feral's global fill-reducing
839    /// permutation (new-to-old). When `want_values` is true the
840    /// per-supernode `l` block is also gathered into `l_vals` —
841    /// indexed by the post-BK pivot perm so the order matches the
842    /// (irn, jcn) arrays.
843    ///
844    /// Returns `None` before the first successful factor (`factors()`
845    /// returns `None`).
846    fn factor_pattern(&self, want_values: bool) -> Option<FactorPattern> {
847        let factors = self.solver.factors()?;
848
849        // Conservative upper bound on nnz_L (strict-lower): per-supernode
850        //   nelim*(nelim-1)/2 + (nrow - nelim) * nelim
851        // (the diagonal is excluded). Doubles as a single allocation
852        // for both irn and jcn, plus l_vals when requested.
853        let mut nnz_upper: usize = 0;
854        for nf in &factors.node_factors {
855            let ff = &nf.frontal_factors;
856            let nelim = ff.nelim;
857            let nrow = ff.nrow;
858            let trailing = nrow.saturating_sub(nelim) * nelim;
859            nnz_upper += nelim * nelim.saturating_sub(1) / 2 + trailing;
860        }
861
862        let mut l_irn: Vec<Index> = Vec::with_capacity(nnz_upper);
863        let mut l_jcn: Vec<Index> = Vec::with_capacity(nnz_upper);
864        let mut l_vals: Option<Vec<Number>> = if want_values {
865            Some(Vec::with_capacity(nnz_upper))
866        } else {
867            None
868        };
869
870        for nf in &factors.node_factors {
871            let ff = &nf.frontal_factors;
872            let nelim = ff.nelim;
873            let nrow = ff.nrow;
874            // perm[i] = pre-BK supernode row that landed at post-BK
875            // pivot position i. Indices [nelim, nrow) are identity.
876            let perm = &ff.perm;
877            let l = &ff.l;
878            for j in 0..nelim {
879                // Column j of L: global col index in permuted coords.
880                let col_local = perm[j];
881                let col_global = nf.row_indices[col_local];
882                let col1 = (col_global as Index) + 1;
883                // Strict-lower entries: rows i in (j, nrow).
884                for i in (j + 1)..nrow {
885                    let row_local = if i < nelim { perm[i] } else { i };
886                    let row_global = nf.row_indices[row_local];
887                    l_irn.push((row_global as Index) + 1);
888                    l_jcn.push(col1);
889                    if let Some(vals) = l_vals.as_mut() {
890                        vals.push(l[j * nrow + i]);
891                    }
892                }
893            }
894        }
895
896        Some(FactorPattern {
897            n: factors.n,
898            perm: factors.perm.clone(),
899            l_irn,
900            l_jcn,
901            l_vals,
902        })
903    }
904}
905
906#[cfg(test)]
907mod tests {
908    use super::*;
909
910    /// L12: the pivot-threshold env override honors the documented
911    /// `POUNCE_FERAL_*` convention (`POUNCE_FERAL_PIVTOL`) and keeps the bare
912    /// `FERAL_PIVTOL` only as a deprecated legacy alias. Tested on the pure
913    /// `resolve_pivtol_env` helper so it never mutates the process
914    /// environment (which would be a data race under the rayon-parallel
915    /// solves — the same hazard fixed in L9).
916    #[test]
917    fn resolve_pivtol_env_honors_pounce_convention() {
918        // The documented POUNCE_FERAL_* name is read (this is the bug: the
919        // old code only looked at the bare FERAL_PIVTOL, so this was ignored).
920        assert_eq!(resolve_pivtol_env(Some("0.3"), None), 0.3);
921        // Legacy FERAL_PIVTOL is still honored when the convention var is unset.
922        assert_eq!(resolve_pivtol_env(None, Some("0.4")), 0.4);
923        // Both set: the convention name takes precedence over the legacy alias.
924        assert_eq!(resolve_pivtol_env(Some("0.3"), Some("0.4")), 0.3);
925        // Neither set: the 1e-8 default (matches FeralConfig::default).
926        assert_eq!(resolve_pivtol_env(None, None), 1e-8);
927        // Unparseable convention value falls through to the legacy alias...
928        assert_eq!(resolve_pivtol_env(Some("garbage"), Some("0.4")), 0.4);
929        // ...and, with no legacy value, to the default.
930        assert_eq!(resolve_pivtol_env(Some("garbage"), None), 1e-8);
931    }
932
933    /// 2x2 SPD matrix `[[2,1],[1,3]]`. Lower-triangle 1-based triplets.
934    /// Solving against (3, 4) gives x = (1, 1).
935    #[test]
936    fn factor_and_solve_spd_2x2() {
937        let mut s = FeralSolverInterface::new();
938        let irn: [Index; 3] = [1, 2, 2];
939        let jcn: [Index; 3] = [1, 1, 2];
940
941        assert_eq!(
942            s.initialize_structure(2, 3, &irn, &jcn),
943            ESymSolverStatus::Success
944        );
945        s.values_array_mut().copy_from_slice(&[2.0, 1.0, 3.0]);
946
947        let mut rhs = [3.0, 4.0];
948        assert_eq!(
949            s.multi_solve(true, &irn, &jcn, 1, &mut rhs, false, 0),
950            ESymSolverStatus::Success
951        );
952        assert!((rhs[0] - 1.0).abs() < 1e-12, "x0 = {}", rhs[0]);
953        assert!((rhs[1] - 1.0).abs() < 1e-12, "x1 = {}", rhs[1]);
954        assert_eq!(s.number_of_neg_evals(), 0);
955        assert!(s.provides_inertia());
956        assert_eq!(s.matrix_format(), EMatrixFormat::TripletFormat);
957    }
958
959    /// 2x2 indefinite `[[1,2],[2,1]]` — eigenvalues 3, -1.
960    #[test]
961    fn detects_one_negative_eigenvalue() {
962        let mut s = FeralSolverInterface::new();
963        let irn: [Index; 3] = [1, 2, 2];
964        let jcn: [Index; 3] = [1, 1, 2];
965
966        assert_eq!(
967            s.initialize_structure(2, 3, &irn, &jcn),
968            ESymSolverStatus::Success
969        );
970        s.values_array_mut().copy_from_slice(&[1.0, 2.0, 1.0]);
971
972        let mut rhs = [3.0, 3.0];
973        assert_eq!(
974            s.multi_solve(true, &irn, &jcn, 1, &mut rhs, true, 1),
975            ESymSolverStatus::Success
976        );
977        assert_eq!(s.number_of_neg_evals(), 1);
978        assert!((rhs[0] - 1.0).abs() < 1e-12);
979        assert!((rhs[1] - 1.0).abs() < 1e-12);
980    }
981
982    /// Wrong expected inertia → `WrongInertia` (and no solve).
983    #[test]
984    fn check_neg_evals_mismatch_returns_wrong_inertia() {
985        let mut s = FeralSolverInterface::new();
986        let irn: [Index; 3] = [1, 2, 2];
987        let jcn: [Index; 3] = [1, 1, 2];
988        assert_eq!(
989            s.initialize_structure(2, 3, &irn, &jcn),
990            ESymSolverStatus::Success
991        );
992        s.values_array_mut().copy_from_slice(&[2.0, 1.0, 3.0]); // SPD
993        let mut rhs = [3.0, 4.0];
994        assert_eq!(
995            s.multi_solve(true, &irn, &jcn, 1, &mut rhs, true, 1),
996            ESymSolverStatus::WrongInertia
997        );
998    }
999
1000    /// `increase_quality` then resolve with `new_matrix=false`
1001    /// returns `CallAgain`; refilling values and retrying succeeds.
1002    #[test]
1003    fn increase_quality_then_resolve_triggers_call_again() {
1004        let mut s = FeralSolverInterface::new();
1005        let irn: [Index; 3] = [1, 2, 2];
1006        let jcn: [Index; 3] = [1, 1, 2];
1007        assert_eq!(
1008            s.initialize_structure(2, 3, &irn, &jcn),
1009            ESymSolverStatus::Success
1010        );
1011        s.values_array_mut().copy_from_slice(&[2.0, 1.0, 3.0]);
1012        let mut rhs = [3.0, 4.0];
1013        assert_eq!(
1014            s.multi_solve(true, &irn, &jcn, 1, &mut rhs, false, 0),
1015            ESymSolverStatus::Success
1016        );
1017
1018        if s.increase_quality() {
1019            // Quality bumped; new_matrix=false → CallAgain.
1020            let mut rhs = [3.0, 4.0];
1021            assert_eq!(
1022                s.multi_solve(false, &irn, &jcn, 1, &mut rhs, false, 0),
1023                ESymSolverStatus::CallAgain
1024            );
1025            // Refill values and retry.
1026            s.values_array_mut().copy_from_slice(&[2.0, 1.0, 3.0]);
1027            let mut rhs = [3.0, 4.0];
1028            assert_eq!(
1029                s.multi_solve(false, &irn, &jcn, 1, &mut rhs, false, 0),
1030                ESymSolverStatus::Success
1031            );
1032            assert!((rhs[0] - 1.0).abs() < 1e-12);
1033            assert!((rhs[1] - 1.0).abs() < 1e-12);
1034        }
1035    }
1036
1037    /// Issue #79: the first-class per-backend `parallel` toggle builds a
1038    /// serial factor without touching any global state, and its result is
1039    /// bit-identical to the parallel driver (feral guarantees parity).
1040    #[test]
1041    fn per_backend_parallel_toggle_serial_matches_parallel() {
1042        let irn: [Index; 3] = [1, 2, 2];
1043        let jcn: [Index; 3] = [1, 1, 2];
1044        let solve = |mut s: FeralSolverInterface| -> [f64; 2] {
1045            assert_eq!(
1046                s.initialize_structure(2, 3, &irn, &jcn),
1047                ESymSolverStatus::Success
1048            );
1049            s.values_array_mut().copy_from_slice(&[2.0, 1.0, 3.0]);
1050            let mut rhs = [3.0, 4.0];
1051            assert_eq!(
1052                s.multi_solve(true, &irn, &jcn, 1, &mut rhs, false, 0),
1053                ESymSolverStatus::Success
1054            );
1055            rhs
1056        };
1057        let par = solve(FeralSolverInterface::with_config(FeralConfig {
1058            parallel: Some(true),
1059            ..FeralConfig::default()
1060        }));
1061        let ser = solve(FeralSolverInterface::serial());
1062        // [[2,1],[1,3]] x = [3,4] ⇒ x = [1, 1], same both ways.
1063        assert!((par[0] - 1.0).abs() < 1e-12 && (par[1] - 1.0).abs() < 1e-12);
1064        assert_eq!(
1065            par, ser,
1066            "serial and parallel factors must agree bit-for-bit"
1067        );
1068    }
1069
1070    /// Pounce emits some symmetric entries as upper-triangle
1071    /// `(i, j)` with `i < j` because MA57 accepts either half. The
1072    /// FERAL wrapper must canonicalize to lower triangle (row >= col)
1073    /// before handing entries to `CscMatrix::from_triplets`, which
1074    /// silently drops upper-triangle entries during LDL^T. A regression
1075    /// in this canonicalization would corrupt residuals and inertia
1076    /// (see jkitchin/feral#6).
1077    #[test]
1078    fn upper_triangle_entries_are_canonicalized() {
1079        let mut s = FeralSolverInterface::new();
1080        // Same matrix as `factor_and_solve_spd_2x2`, but the (2,1)
1081        // off-diagonal is given as upper-triangle (1,2).
1082        let irn: [Index; 3] = [1, 1, 2];
1083        let jcn: [Index; 3] = [1, 2, 2];
1084        s.initialize_structure(2, 3, &irn, &jcn);
1085        s.values_array_mut().copy_from_slice(&[2.0, 1.0, 3.0]);
1086
1087        let mut rhs = [3.0, 4.0];
1088        assert_eq!(
1089            s.multi_solve(true, &irn, &jcn, 1, &mut rhs, false, 0),
1090            ESymSolverStatus::Success
1091        );
1092        assert!((rhs[0] - 1.0).abs() < 1e-12, "x0 = {}", rhs[0]);
1093        assert!((rhs[1] - 1.0).abs() < 1e-12, "x1 = {}", rhs[1]);
1094    }
1095
1096    /// `factor_pattern` returns the L sparsity (strict-lower) after a
1097    /// successful factor. For the SPD 2x2 above, L has exactly one
1098    /// strict-lower entry (the single off-diagonal), and `perm` is a
1099    /// permutation of `0..n`.
1100    #[test]
1101    fn factor_pattern_returns_l_after_factor() {
1102        let mut s = FeralSolverInterface::new();
1103        let irn: [Index; 3] = [1, 2, 2];
1104        let jcn: [Index; 3] = [1, 1, 2];
1105        s.initialize_structure(2, 3, &irn, &jcn);
1106        s.values_array_mut().copy_from_slice(&[2.0, 1.0, 3.0]);
1107        let mut rhs = [3.0, 4.0];
1108        s.multi_solve(true, &irn, &jcn, 1, &mut rhs, false, 0);
1109
1110        // Pattern-only.
1111        let pat = s.factor_pattern(false).expect("factors present");
1112        assert_eq!(pat.n, 2);
1113        assert_eq!(pat.perm.len(), 2);
1114        assert!(pat.perm.contains(&0) && pat.perm.contains(&1));
1115        assert_eq!(pat.l_irn.len(), 1, "L strict-lower nnz = 1 for SPD 2x2");
1116        assert_eq!(pat.l_jcn.len(), 1);
1117        assert!(pat.l_vals.is_none(), "values not requested");
1118
1119        // With values.
1120        let pat = s.factor_pattern(true).expect("factors present");
1121        let vals = pat.l_vals.as_ref().expect("values requested");
1122        assert_eq!(vals.len(), pat.l_irn.len());
1123        // The single strict-lower L entry should be finite.
1124        assert!(vals[0].is_finite());
1125    }
1126
1127    /// Before any factor, `factor_pattern` returns `None`.
1128    #[test]
1129    fn factor_pattern_none_before_factor() {
1130        let s = FeralSolverInterface::new();
1131        assert!(s.factor_pattern(false).is_none());
1132        assert!(s.factor_pattern(true).is_none());
1133    }
1134
1135    /// Two-RHS solve via `solve_many`.
1136    #[test]
1137    fn multi_rhs_solve() {
1138        let mut s = FeralSolverInterface::new();
1139        let irn: [Index; 3] = [1, 2, 2];
1140        let jcn: [Index; 3] = [1, 1, 2];
1141        assert_eq!(
1142            s.initialize_structure(2, 3, &irn, &jcn),
1143            ESymSolverStatus::Success
1144        );
1145        s.values_array_mut().copy_from_slice(&[2.0, 1.0, 3.0]);
1146
1147        // Column 1: A x = (3, 4) → x = (1, 1)
1148        // Column 2: A x = (4, 5) → x = (7/5, 6/5)
1149        let mut rhs = [3.0, 4.0, 4.0, 5.0];
1150        assert_eq!(
1151            s.multi_solve(true, &irn, &jcn, 2, &mut rhs, false, 0),
1152            ESymSolverStatus::Success
1153        );
1154        assert!((rhs[0] - 1.0).abs() < 1e-10);
1155        assert!((rhs[1] - 1.0).abs() < 1e-10);
1156        assert!((rhs[2] - 7.0 / 5.0).abs() < 1e-10);
1157        assert!((rhs[3] - 6.0 / 5.0).abs() < 1e-10);
1158    }
1159
1160    /// `parse_scaling_strategy` accepts every documented tag (in either
1161    /// case / with aliases) and rejects unknown ones. `external` is not
1162    /// reachable from a string tag (it carries a vector).
1163    #[test]
1164    fn parse_scaling_strategy_accepts_documented_tags() {
1165        use ScalingStrategy::*;
1166        let cases: &[(&str, ScalingStrategy)] = &[
1167            ("auto", Auto),
1168            ("AUTO", Auto),
1169            ("infnorm", InfNorm),
1170            ("inf_norm", InfNorm),
1171            ("inf", InfNorm),
1172            ("mc64", Mc64Symmetric),
1173            ("MC64", Mc64Symmetric),
1174            ("mc64symmetric", Mc64Symmetric),
1175            ("mc64_symmetric", Mc64Symmetric),
1176            ("identity", Identity),
1177            ("none", Identity),
1178        ];
1179        for (tag, expected) in cases {
1180            assert_eq!(
1181                parse_scaling_strategy(tag),
1182                Some(expected.clone()),
1183                "tag {tag:?} should parse"
1184            );
1185        }
1186        assert_eq!(parse_scaling_strategy("external"), None);
1187        assert_eq!(parse_scaling_strategy("not_a_strategy"), None);
1188        assert_eq!(parse_scaling_strategy(""), None);
1189    }
1190
1191    /// The pounce default is FERAL's default — `ScalingStrategy::Auto` —
1192    /// so behaviour is unchanged when the option is left unset.
1193    #[test]
1194    fn default_scaling_is_auto() {
1195        assert_eq!(FeralConfig::default().scaling, ScalingStrategy::Auto);
1196    }
1197
1198    /// `with_config` actually propagates the configured scaling strategy
1199    /// into the underlying FERAL solver (and each variant still
1200    /// constructs + factors a tiny SPD system).
1201    #[test]
1202    fn every_scaling_propagates_and_factors() {
1203        use ScalingStrategy::*;
1204        for strategy in [Auto, InfNorm, Mc64Symmetric, Identity] {
1205            let cfg = FeralConfig {
1206                scaling: strategy.clone(),
1207                ..FeralConfig::default()
1208            };
1209            let mut s = FeralSolverInterface::with_config(cfg);
1210            assert_eq!(
1211                s.solver.scaling_strategy(),
1212                &strategy,
1213                "configured strategy should reach the solver for {strategy:?}"
1214            );
1215            let irn: [Index; 3] = [1, 2, 2];
1216            let jcn: [Index; 3] = [1, 1, 2];
1217            assert_eq!(
1218                s.initialize_structure(2, 3, &irn, &jcn),
1219                ESymSolverStatus::Success,
1220                "structure init for {strategy:?}"
1221            );
1222            s.values_array_mut().copy_from_slice(&[2.0, 1.0, 3.0]);
1223            let mut rhs = [3.0, 4.0];
1224            assert_eq!(
1225                s.multi_solve(true, &irn, &jcn, 1, &mut rhs, false, 0),
1226                ESymSolverStatus::Success,
1227                "solve for {strategy:?}"
1228            );
1229            assert!((rhs[0] - 1.0).abs() < 1e-10, "x0 for {strategy:?}");
1230            assert!((rhs[1] - 1.0).abs() < 1e-10, "x1 for {strategy:?}");
1231        }
1232    }
1233
1234    /// `parse_ordering_method` accepts every documented tag (in either
1235    /// case) and rejects unknown ones.
1236    #[test]
1237    fn parse_ordering_method_accepts_documented_tags() {
1238        use OrderingMethod::*;
1239        let cases: &[(&str, OrderingMethod)] = &[
1240            ("auto", Auto),
1241            ("AUTO", Auto),
1242            ("auto_race", AutoRace),
1243            ("autorace", AutoRace),
1244            ("race", AutoRace),
1245            ("amd", Amd),
1246            ("AMD", Amd),
1247            ("amf", Amf),
1248            ("metis", MetisND),
1249            ("metis_nd", MetisND),
1250            ("MetisND", MetisND),
1251            ("scotch", ScotchND),
1252            ("kahip", KahipND),
1253        ];
1254        for (tag, expected) in cases {
1255            assert_eq!(
1256                parse_ordering_method(tag),
1257                Some(*expected),
1258                "tag {tag:?} should parse"
1259            );
1260        }
1261        assert_eq!(parse_ordering_method("not_a_method"), None);
1262        assert_eq!(parse_ordering_method(""), None);
1263    }
1264
1265    /// Each `OrderingMethod` variant constructs a usable solver and
1266    /// can factor a tiny SPD system.
1267    #[test]
1268    fn every_ordering_constructs_and_factors() {
1269        use OrderingMethod::*;
1270        for method in [Auto, AutoRace, Amd, Amf, MetisND, ScotchND, KahipND] {
1271            let cfg = FeralConfig {
1272                ordering: method,
1273                ..FeralConfig::default()
1274            };
1275            let mut s = FeralSolverInterface::with_config(cfg);
1276            let irn: [Index; 3] = [1, 2, 2];
1277            let jcn: [Index; 3] = [1, 1, 2];
1278            assert_eq!(
1279                s.initialize_structure(2, 3, &irn, &jcn),
1280                ESymSolverStatus::Success,
1281                "structure init for {method:?}"
1282            );
1283            s.values_array_mut().copy_from_slice(&[2.0, 1.0, 3.0]);
1284            let mut rhs = [3.0, 4.0];
1285            assert_eq!(
1286                s.multi_solve(true, &irn, &jcn, 1, &mut rhs, false, 0),
1287                ESymSolverStatus::Success,
1288                "solve for {method:?}"
1289            );
1290            assert!((rhs[0] - 1.0).abs() < 1e-10, "x0 for {method:?}");
1291            assert!((rhs[1] - 1.0).abs() < 1e-10, "x1 for {method:?}");
1292        }
1293    }
1294
1295    /// Rank-deficient J: rows 1,2 over 3 cols with row 2 = 2·row 1.
1296    /// `determine_dependent_rows` must flag exactly the *one* redundant
1297    /// row, and (per the s-scaling argument) it must be a real J-row
1298    /// index in `[0, n_rows)`. Pins R2: the singular-pivot → row map.
1299    #[test]
1300    fn determine_dependent_rows_flags_the_redundant_row() {
1301        let mut s = FeralSolverInterface::new();
1302        assert!(s.provides_degeneracy_detection());
1303        // J = [[1,1,1],[2,2,2]] as a 1-based triplet.
1304        let irn: [Index; 6] = [1, 1, 1, 2, 2, 2];
1305        let jcn: [Index; 6] = [1, 2, 3, 1, 2, 3];
1306        let vals = [1.0, 1.0, 1.0, 2.0, 2.0, 2.0];
1307        let mut c_deps = Vec::new();
1308        let st = s.determine_dependent_rows(2, 3, &irn, &jcn, &vals, &mut c_deps);
1309        assert_eq!(st, ESymSolverStatus::Success);
1310        assert_eq!(c_deps.len(), 1, "exactly one dependent row, got {c_deps:?}");
1311        assert!(
1312            c_deps[0] == 0 || c_deps[0] == 1,
1313            "dep row in range: {c_deps:?}"
1314        );
1315    }
1316
1317    /// Full-rank J (identity rows): no dependent rows.
1318    #[test]
1319    fn determine_dependent_rows_full_rank_reports_none() {
1320        let mut s = FeralSolverInterface::new();
1321        // J = I_3.
1322        let irn: [Index; 3] = [1, 2, 3];
1323        let jcn: [Index; 3] = [1, 2, 3];
1324        let vals = [1.0, 1.0, 1.0];
1325        let mut c_deps = Vec::new();
1326        let st = s.determine_dependent_rows(3, 3, &irn, &jcn, &vals, &mut c_deps);
1327        assert_eq!(st, ESymSolverStatus::Success);
1328        assert!(c_deps.is_empty(), "full-rank J has no deps, got {c_deps:?}");
1329    }
1330
1331    /// Three rows, rank 2: row 3 = row 1 + row 2. Exactly one dependency,
1332    /// and dropping it must leave the other two independent.
1333    #[test]
1334    fn determine_dependent_rows_rank_two_of_three() {
1335        let mut s = FeralSolverInterface::new();
1336        // r1=[1,0,1], r2=[0,1,1], r3=r1+r2=[1,1,2].
1337        let irn: [Index; 7] = [1, 1, 2, 2, 3, 3, 3];
1338        let jcn: [Index; 7] = [1, 3, 2, 3, 1, 2, 3];
1339        let vals = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0];
1340        let mut c_deps = Vec::new();
1341        let st = s.determine_dependent_rows(3, 3, &irn, &jcn, &vals, &mut c_deps);
1342        assert_eq!(st, ESymSolverStatus::Success);
1343        assert_eq!(
1344            c_deps.len(),
1345            1,
1346            "one dependency among 3 rows, got {c_deps:?}"
1347        );
1348        assert!(c_deps[0] <= 2, "row index in range: {c_deps:?}");
1349    }
1350}