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}