Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Some checks failed
CI / check (push) Has been cancelled
Some checks failed
CI / check (push) Has been cancelled
Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
24
crates/solver-core/Cargo.toml
Normal file
24
crates/solver-core/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "entropyk-solver-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Sepehr <sepehr@entropyk.com>"]
|
||||
description = "no_std-ready core types for the Entropyk solver: typed convergence taxonomy and shared error types"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/entropyk/entropyk"
|
||||
|
||||
[dependencies]
|
||||
thiserror = { version = "2.0", default-features = false }
|
||||
serde = { version = "1.0", default-features = false, features = ["derive"] }
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
std = ["alloc", "thiserror/std"]
|
||||
alloc = []
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1.0"
|
||||
|
||||
[lib]
|
||||
name = "entropyk_solver_core"
|
||||
path = "src/lib.rs"
|
||||
202
crates/solver-core/src/error.rs
Normal file
202
crates/solver-core/src/error.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
//! Shared error types for the Entropyk solver crates (FR8 foundation, FR9
|
||||
//! groundwork).
|
||||
//!
|
||||
//! Two families live here:
|
||||
//!
|
||||
//! - **Hard errors** ([`SolverCoreError`]): usage mistakes, invalid system
|
||||
//! definitions, internal invariant violations. These stay `Err`.
|
||||
//! - **Outcome carriers** ([`SolveOutcome`], [`DomainViolation`]): structured
|
||||
//! data describing *how* a solve terminated. A non-converged solve is
|
||||
//! reported through [`ConvergenceReason`], never as a hard `Err`.
|
||||
//!
|
||||
//! This module requires the `alloc` feature (messages and state vectors are
|
||||
//! heap-allocated). The pure-`core` tier provides [`ConvergenceReason`] only.
|
||||
//!
|
||||
//! The recoverable/fatal split follows the KINSOL callback convention: an
|
||||
//! evaluation returning a recoverable signal (`> 0`) lets the strategy shrink
|
||||
//! the step and retry, while a fatal signal (`< 0`) aborts. [`DomainViolation`]
|
||||
//! is the type home for the recoverable signal; the step-reduction wiring
|
||||
//! lives in `entropyk-components` (`ComponentError::is_recoverable`) and the
|
||||
//! `entropyk-solver` globalization strategies (line search et al.).
|
||||
|
||||
use alloc::string::String;
|
||||
use alloc::vec::Vec;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::reason::ConvergenceReason;
|
||||
|
||||
/// Hard solver errors shared across the solver crates.
|
||||
///
|
||||
/// These represent defects in *how the solver was invoked* (bad
|
||||
/// configuration, ill-formed system, broken internal invariant) — conditions
|
||||
/// where returning an outcome would hide a bug. Non-convergence is **not**
|
||||
/// here by design: it is a [`ConvergenceReason`] outcome.
|
||||
///
|
||||
/// `#[non_exhaustive]`: new hard-error kinds may be added as a semver-minor
|
||||
/// change; downstream `match` expressions must include a wildcard arm.
|
||||
#[derive(Error, Debug, Clone, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum SolverCoreError {
|
||||
/// API or configuration misuse (invalid argument, inconsistent options).
|
||||
#[error("invalid usage: {message}")]
|
||||
Usage {
|
||||
/// Human-readable description of the misuse.
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// The system definition is invalid and cannot be solved (empty system,
|
||||
/// inconsistent constraints, structural singularity detected at setup).
|
||||
#[error("invalid system: {message}")]
|
||||
InvalidSystem {
|
||||
/// Human-readable description of the system defect.
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// An internal invariant was violated. Indicates a solver bug; please
|
||||
/// report it with the attached context.
|
||||
#[error("internal solver error: {message}")]
|
||||
Internal {
|
||||
/// Human-readable description of the violated invariant.
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A recoverable component domain violation (KINSOL `> 0` convention).
|
||||
///
|
||||
/// Raised when a component evaluation leaves its physical domain (e.g. a
|
||||
/// fluid-property backend queried outside its range). Globalization
|
||||
/// strategies may convert this into step reduction and retry instead of
|
||||
/// aborting the solve. When retries are exhausted, the solve terminates with
|
||||
/// [`ConvergenceReason::DomainViolation`].
|
||||
///
|
||||
/// The recoverable plumbing is implemented in `entropyk-components`
|
||||
/// (`ComponentError::DomainViolation` / `ComponentError::is_recoverable`)
|
||||
/// and in `entropyk-solver` (the Newton line search and the other
|
||||
/// globalization strategies shrink the step on recoverable errors and map
|
||||
/// exhaustion to `SolverError::DomainViolation` →
|
||||
/// [`ConvergenceReason::DomainViolation`]).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DomainViolation {
|
||||
/// Name of the component that left its domain, when known.
|
||||
pub component: Option<String>,
|
||||
/// Human-readable detail (which quantity, what bound was crossed).
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl core::fmt::Display for DomainViolation {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
match &self.component {
|
||||
Some(component) => write!(f, "domain violation in '{component}': {}", self.detail),
|
||||
None => write!(f, "domain violation: {}", self.detail),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
impl std::error::Error for DomainViolation {}
|
||||
|
||||
/// The outcome of a solve, reported as data (FR8).
|
||||
///
|
||||
/// This is the data-first return shape consumed by strategy composition
|
||||
/// (fallback cascade, Epic 3) and the robustness-sweep histogram (Story 2.6).
|
||||
/// The legacy `Solver::solve` signature is unchanged; `Solver::solve_outcome`
|
||||
/// adapts between the two.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SolveOutcome {
|
||||
/// Why the solve stopped.
|
||||
pub reason: ConvergenceReason,
|
||||
|
||||
/// Number of iterations performed. `0` when the termination carried no
|
||||
/// iteration payload (e.g. timeout before the first iteration).
|
||||
pub iterations: usize,
|
||||
|
||||
/// ℓ₂ norm of the residual vector at termination. `f64::NAN` when the
|
||||
/// termination carried no residual payload.
|
||||
pub final_residual: f64,
|
||||
|
||||
/// Final state vector when available (converged or best-effort state);
|
||||
/// `None` when the solve produced no usable state.
|
||||
pub state: Option<Vec<f64>>,
|
||||
}
|
||||
|
||||
impl SolveOutcome {
|
||||
/// Returns `true` only when the solve fully converged.
|
||||
#[inline]
|
||||
pub fn is_converged(&self) -> bool {
|
||||
self.reason.is_converged()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hard_error_display_messages() {
|
||||
let usage = SolverCoreError::Usage {
|
||||
message: "negative tolerance".to_string(),
|
||||
};
|
||||
assert!(usage.to_string().contains("negative tolerance"));
|
||||
|
||||
let invalid = SolverCoreError::InvalidSystem {
|
||||
message: "empty system".to_string(),
|
||||
};
|
||||
assert!(invalid.to_string().contains("empty system"));
|
||||
|
||||
let internal = SolverCoreError::Internal {
|
||||
message: "jacobian dimension drift".to_string(),
|
||||
};
|
||||
assert!(internal.to_string().contains("jacobian dimension drift"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_violation_carries_context() {
|
||||
let violation = DomainViolation {
|
||||
component: Some("condenser".to_string()),
|
||||
detail: "pressure below backend range".to_string(),
|
||||
};
|
||||
assert_eq!(violation.component.as_deref(), Some("condenser"));
|
||||
assert!(violation.detail.contains("pressure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_violation_display_includes_component_and_detail() {
|
||||
let attributed = DomainViolation {
|
||||
component: Some("condenser".to_string()),
|
||||
detail: "pressure below backend range".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
attributed.to_string(),
|
||||
"domain violation in 'condenser': pressure below backend range"
|
||||
);
|
||||
|
||||
let unattributed = DomainViolation {
|
||||
component: None,
|
||||
detail: "line search exhausted".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
unattributed.to_string(),
|
||||
"domain violation: line search exhausted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solve_outcome_delegates_convergence_flag() {
|
||||
let converged = SolveOutcome {
|
||||
reason: ConvergenceReason::Converged,
|
||||
iterations: 7,
|
||||
final_residual: 1e-9,
|
||||
state: Some(alloc::vec![1.0, 2.0]),
|
||||
};
|
||||
assert!(converged.is_converged());
|
||||
|
||||
let stalled = SolveOutcome {
|
||||
reason: ConvergenceReason::Stalled,
|
||||
iterations: 0,
|
||||
final_residual: f64::NAN,
|
||||
state: None,
|
||||
};
|
||||
assert!(!stalled.is_converged());
|
||||
}
|
||||
}
|
||||
61
crates/solver-core/src/lib.rs
Normal file
61
crates/solver-core/src/lib.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
//! # entropyk-solver-core
|
||||
//!
|
||||
//! no_std-ready core types for the Entropyk solver: the typed
|
||||
//! [`ConvergenceReason`] taxonomy and the shared error types that every solver
|
||||
//! crate (full solver, diagnostics, lite embedded variant) builds upon.
|
||||
//!
|
||||
//! ## Design rule: outcomes are data, not exceptions
|
||||
//!
|
||||
//! Following the PETSc/IPOPT consensus, a solve that terminates without
|
||||
//! reaching tolerance (max iterations, stall, line-search failure, singular
|
||||
//! Jacobian, domain violation, timeout, likely-no-solution) is a **typed
|
||||
//! outcome** carried by [`ConvergenceReason`], not a hard `Err`. "No physical
|
||||
//! solution exists" is a *reason*, not an exception (IPOPT reports
|
||||
//! `Infeasible_Problem_Detected` as a non-negative status). Hard errors —
|
||||
//! API misuse, invalid system definition, internal invariant violations —
|
||||
//! remain `Err` variants ([`SolverCoreError`]).
|
||||
//!
|
||||
//! ## Feature tiers (std / alloc / core)
|
||||
//!
|
||||
//! | Tier | Enabled by | Contents |
|
||||
//! |------|-----------|----------|
|
||||
//! | `std` (default) | `std` feature | everything |
|
||||
//! | `alloc` | `alloc` feature (no `std`) | [`ConvergenceReason`], [`SolverCoreError`], [`DomainViolation`], [`SolveOutcome`], [`LinearSolver`] |
|
||||
//! | pure `core` | `--no-default-features` | [`ConvergenceReason`] only |
|
||||
//!
|
||||
//! `ConvergenceReason` is pure-`core` (copyable, allocation-free) so the lite
|
||||
//! embedded solver (Epic 6) can report outcomes on `thumbv7em-none-eabihf`.
|
||||
//! Message-carrying error types require `alloc`. The `std` tier only adds
|
||||
//! `std::error::Error` integration via thiserror's `std` feature.
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```
|
||||
//! use entropyk_solver_core::ConvergenceReason;
|
||||
//!
|
||||
//! let reason = ConvergenceReason::MaxIters;
|
||||
//! assert!(!reason.is_converged());
|
||||
//! assert_eq!(reason.as_str(), "max_iters");
|
||||
//! assert_eq!(reason.to_string(), "max_iters");
|
||||
//! ```
|
||||
|
||||
#![cfg_attr(not(feature = "std"), no_std)]
|
||||
|
||||
#[cfg(feature = "alloc")]
|
||||
extern crate alloc;
|
||||
|
||||
pub mod reason;
|
||||
|
||||
#[cfg(feature = "alloc")]
|
||||
pub mod error;
|
||||
|
||||
#[cfg(feature = "alloc")]
|
||||
pub mod linear;
|
||||
|
||||
pub use reason::ConvergenceReason;
|
||||
|
||||
#[cfg(feature = "alloc")]
|
||||
pub use error::{DomainViolation, SolveOutcome, SolverCoreError};
|
||||
|
||||
#[cfg(feature = "alloc")]
|
||||
pub use linear::LinearSolver;
|
||||
179
crates/solver-core/src/linear.rs
Normal file
179
crates/solver-core/src/linear.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
//! Pluggable linear-algebra backend contract: the `LinearSolver` trait.
|
||||
//!
|
||||
//! ## Lifecycle (diffsol-style, FR3)
|
||||
//!
|
||||
//! 1. [`LinearSolver::set_problem`] — structure/setup: allocate for a problem
|
||||
//! of `n_equations × n_unknowns`. Called once per system shape.
|
||||
//! 2. [`LinearSolver::set_linearisation`] — assemble the Jacobian at the new
|
||||
//! iterate (triplet format, same as `JacobianBuilder` emits: `(row, col,
|
||||
//! value)`, values accumulated with `+=` over duplicates) and compute the
|
||||
//! numeric factorization. Called per Jacobian refresh.
|
||||
//! 3. [`LinearSolver::solve_in_place`] — solve `J·x = b` with the stored
|
||||
//! factorization, overwriting the caller-owned buffer `rhs` with `x`.
|
||||
//! Zero allocation on the hot path.
|
||||
//!
|
||||
//! The split between (2) and (3) is the typed hook the Jacobian-freezing
|
||||
//! policy needs (Story 1.6 / FR4): while frozen, the solver skips
|
||||
//! `set_linearisation` and reuses the factorization (and cached scaling
|
||||
//! factors) across iterations.
|
||||
//!
|
||||
//! ## Object safety (deliberate deviation from diffsol)
|
||||
//!
|
||||
//! diffsol's `LinearSolver<M: Matrix>` is generic and explicitly NOT
|
||||
//! dyn-compatible; runtime selection lives in the separate `diffsol-c` crate.
|
||||
//! Entropyk instead requires an **object-safe** trait (`Box<dyn LinearSolver>`)
|
||||
//! so the Epic-3 string-keyed strategy registry can hold heterogeneous
|
||||
//! backends without a wrapper crate. Monomorphization is preserved *inside*
|
||||
//! backends; only the selection boundary is dynamic.
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let mut lu = NalgebraLuSolver::new();
|
||||
//! lu.set_problem(2, 2)?;
|
||||
//! lu.set_linearisation(&[(0, 0, 2.0), (1, 1, 1.0)])?;
|
||||
//! let mut rhs = vec![4.0, 3.0];
|
||||
//! lu.solve_in_place(&mut rhs)?;
|
||||
//! assert!((rhs[0] - 2.0).abs() < 1e-12); // x = J⁻¹·b
|
||||
//! ```
|
||||
|
||||
use crate::SolverCoreError;
|
||||
|
||||
/// A pluggable linear-system backend with an explicit lifecycle (FR3).
|
||||
///
|
||||
/// Implementations must be `Send` so strategies can move them across rayon
|
||||
/// jobs later (Epic 4). All methods are object-safe by design.
|
||||
pub trait LinearSolver: Send {
|
||||
/// Allocates/reset for a problem of `n_equations × n_unknowns`.
|
||||
///
|
||||
/// Errors: `Usage` if a dimension is zero.
|
||||
fn set_problem(&mut self, n_equations: usize, n_unknowns: usize)
|
||||
-> Result<(), SolverCoreError>;
|
||||
|
||||
/// Assembles the Jacobian from `(row, col, value)` triplets (accumulating
|
||||
/// duplicates) and computes the numeric factorization at this point.
|
||||
///
|
||||
/// Errors: `Usage` if called before `set_problem` or an index is out of
|
||||
/// bounds.
|
||||
fn set_linearisation(&mut self, entries: &[(usize, usize, f64)])
|
||||
-> Result<(), SolverCoreError>;
|
||||
|
||||
/// Solves `J·x = b` with the stored factorization, overwriting `rhs` with
|
||||
/// the solution. `rhs.len()` must equal the problem size (square systems).
|
||||
///
|
||||
/// Errors: `Usage` on shape mismatch or missing factorization;
|
||||
/// `InvalidSystem` when the factorization is singular or yields a
|
||||
/// non-finite step (the strategy maps this to its singular-Jacobian
|
||||
/// outcome).
|
||||
fn solve_in_place(&mut self, rhs: &mut [f64]) -> Result<(), SolverCoreError>;
|
||||
|
||||
/// Problem size (number of rows) for wiring checks.
|
||||
fn n(&self) -> usize;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use alloc::vec::Vec;
|
||||
|
||||
/// Minimal hand-written 2×2 backend proving object-safety and the
|
||||
/// lifecycle error paths (no nalgebra in this crate).
|
||||
struct TinyLu {
|
||||
a: Option<[[f64; 2]; 2]>,
|
||||
}
|
||||
|
||||
impl LinearSolver for TinyLu {
|
||||
fn set_problem(
|
||||
&mut self,
|
||||
n_equations: usize,
|
||||
n_unknowns: usize,
|
||||
) -> Result<(), SolverCoreError> {
|
||||
if n_equations != 2 || n_unknowns != 2 {
|
||||
return Err(SolverCoreError::Usage {
|
||||
message: alloc::format!("TinyLu is 2x2 only, got {n_equations}x{n_unknowns}"),
|
||||
});
|
||||
}
|
||||
self.a = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_linearisation(
|
||||
&mut self,
|
||||
entries: &[(usize, usize, f64)],
|
||||
) -> Result<(), SolverCoreError> {
|
||||
let mut a = [[0.0; 2]; 2];
|
||||
for &(r, c, v) in entries {
|
||||
if r > 1 || c > 1 {
|
||||
return Err(SolverCoreError::Usage {
|
||||
message: alloc::format!("index out of bounds ({r},{c})"),
|
||||
});
|
||||
}
|
||||
a[r][c] += v;
|
||||
}
|
||||
self.a = Some(a);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn solve_in_place(&mut self, rhs: &mut [f64]) -> Result<(), SolverCoreError> {
|
||||
let a = self.a.ok_or_else(|| SolverCoreError::Usage {
|
||||
message: alloc::string::String::from("set_linearisation not called"),
|
||||
})?;
|
||||
if rhs.len() != 2 {
|
||||
return Err(SolverCoreError::Usage {
|
||||
message: alloc::format!("rhs len {} != 2", rhs.len()),
|
||||
});
|
||||
}
|
||||
let det = a[0][0] * a[1][1] - a[0][1] * a[1][0];
|
||||
if det == 0.0 {
|
||||
return Err(SolverCoreError::InvalidSystem {
|
||||
message: alloc::string::String::from("singular"),
|
||||
});
|
||||
}
|
||||
let (b0, b1) = (rhs[0], rhs[1]);
|
||||
rhs[0] = (b0 * a[1][1] - b1 * a[0][1]) / det;
|
||||
rhs[1] = (a[0][0] * b1 - a[1][0] * b0) / det;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n(&self) -> usize {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_through_dyn_trait_object() {
|
||||
// Object-safety proof: the trait must be usable behind Box<dyn>.
|
||||
let mut solver: alloc::boxed::Box<dyn LinearSolver> =
|
||||
alloc::boxed::Box::new(TinyLu { a: None });
|
||||
solver.set_problem(2, 2).unwrap();
|
||||
// Solve before factorization data exists → Usage error (TinyLu has
|
||||
// none until set_linearisation; here it returns InvalidSystem-free
|
||||
// Usage via missing `a`).
|
||||
assert!(solver.solve_in_place(&mut [1.0, 2.0]).is_err());
|
||||
solver
|
||||
.set_linearisation(&[(0, 0, 2.0), (1, 1, 1.0), (0, 0, 2.0)]) // duplicate accumulates
|
||||
.unwrap();
|
||||
let mut rhs = alloc::vec![8.0, 3.0];
|
||||
solver.solve_in_place(&mut rhs).unwrap();
|
||||
assert!((rhs[0] - 2.0).abs() < 1e-12); // 4x = 8
|
||||
assert!((rhs[1] - 3.0).abs() < 1e-12);
|
||||
assert_eq!(solver.n(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_error_paths() {
|
||||
let mut solver = TinyLu { a: None };
|
||||
// solve before set_problem → error (TinyLu has no factorization state).
|
||||
assert!(solver.solve_in_place(&mut [1.0, 2.0]).is_err());
|
||||
assert!(solver.set_problem(0, 2).is_err());
|
||||
solver.set_problem(2, 2).unwrap();
|
||||
assert!(solver.set_linearisation(&[(2, 0, 1.0)]).is_err()); // out of bounds
|
||||
solver.set_linearisation(&[(0, 0, 1.0)]).unwrap(); // rank-1 → singular
|
||||
assert!(matches!(
|
||||
solver.solve_in_place(&mut [1.0, 2.0]),
|
||||
Err(SolverCoreError::InvalidSystem { .. })
|
||||
));
|
||||
let mut too_long: Vec<f64> = alloc::vec![1.0, 2.0, 3.0];
|
||||
assert!(solver.solve_in_place(&mut too_long).is_err());
|
||||
}
|
||||
}
|
||||
143
crates/solver-core/src/reason.rs
Normal file
143
crates/solver-core/src/reason.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
//! Typed convergence taxonomy (FR8).
|
||||
//!
|
||||
//! [`ConvergenceReason`] reports *how* a solve terminated as first-class data.
|
||||
//! It is pure-`core`: copyable, allocation-free, and available in every
|
||||
//! feature tier (`std` / `alloc` / `core`).
|
||||
//!
|
||||
//! Design sources (PETSc/IPOPT consensus, see the 2026-07-18 solver research
|
||||
//! document): converged-reason is separate from hard errors; "no solution
|
||||
//! exists" is a reason, not an exception. The enum is `#[non_exhaustive]` so
|
||||
//! new reasons can be added as a semver-minor change — downstream `match`
|
||||
//! expressions must include a wildcard arm.
|
||||
|
||||
use core::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Why a solve stopped.
|
||||
///
|
||||
/// Every variant is a normal termination *outcome*, not a failure condition:
|
||||
/// callers are expected to branch on the reason (retry with another strategy,
|
||||
/// tighten a specification, report to the user) rather than catch it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum ConvergenceReason {
|
||||
/// Residual norm reached the configured tolerance within budget.
|
||||
Converged,
|
||||
/// Iteration budget exhausted before reaching tolerance.
|
||||
MaxIters,
|
||||
/// Iterates stopped making progress (residual decrease below the
|
||||
/// stagnation threshold, or divergence detected).
|
||||
Stalled,
|
||||
/// The globalization line search failed to find an acceptable step.
|
||||
LineSearchFailed,
|
||||
/// The Jacobian became numerically singular at the current iterate.
|
||||
SingularJacobian,
|
||||
/// A component evaluation left its physical domain and step reduction
|
||||
/// did not recover (see KINSOL recoverable-violation convention).
|
||||
DomainViolation,
|
||||
/// The configured time budget was exhausted.
|
||||
TimedOut,
|
||||
/// Evidence indicates the system has no physical solution (e.g. a
|
||||
/// structural infeasibility certificate from matching analysis).
|
||||
LikelyNoSolution,
|
||||
}
|
||||
|
||||
impl ConvergenceReason {
|
||||
/// Returns `true` only when the solve fully converged.
|
||||
#[inline]
|
||||
pub const fn is_converged(&self) -> bool {
|
||||
matches!(self, Self::Converged)
|
||||
}
|
||||
|
||||
/// Stable machine-readable key for this reason.
|
||||
///
|
||||
/// Used as histogram buckets by the robustness-sweep runner (Story 2.6)
|
||||
/// and in serialized diagnostics; the strings are part of the public
|
||||
/// contract and must not change.
|
||||
#[inline]
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Converged => "converged",
|
||||
Self::MaxIters => "max_iters",
|
||||
Self::Stalled => "stalled",
|
||||
Self::LineSearchFailed => "line_search_failed",
|
||||
Self::SingularJacobian => "singular_jacobian",
|
||||
Self::DomainViolation => "domain_violation",
|
||||
Self::TimedOut => "timed_out",
|
||||
Self::LikelyNoSolution => "likely_no_solution",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ConvergenceReason {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ALL: [ConvergenceReason; 8] = [
|
||||
ConvergenceReason::Converged,
|
||||
ConvergenceReason::MaxIters,
|
||||
ConvergenceReason::Stalled,
|
||||
ConvergenceReason::LineSearchFailed,
|
||||
ConvergenceReason::SingularJacobian,
|
||||
ConvergenceReason::DomainViolation,
|
||||
ConvergenceReason::TimedOut,
|
||||
ConvergenceReason::LikelyNoSolution,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn only_converged_is_converged() {
|
||||
for reason in ALL {
|
||||
assert_eq!(
|
||||
reason.is_converged(),
|
||||
reason == ConvergenceReason::Converged
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn as_str_keys_are_stable() {
|
||||
assert_eq!(ConvergenceReason::Converged.as_str(), "converged");
|
||||
assert_eq!(ConvergenceReason::MaxIters.as_str(), "max_iters");
|
||||
assert_eq!(ConvergenceReason::Stalled.as_str(), "stalled");
|
||||
assert_eq!(
|
||||
ConvergenceReason::LineSearchFailed.as_str(),
|
||||
"line_search_failed"
|
||||
);
|
||||
assert_eq!(
|
||||
ConvergenceReason::SingularJacobian.as_str(),
|
||||
"singular_jacobian"
|
||||
);
|
||||
assert_eq!(
|
||||
ConvergenceReason::DomainViolation.as_str(),
|
||||
"domain_violation"
|
||||
);
|
||||
assert_eq!(ConvergenceReason::TimedOut.as_str(), "timed_out");
|
||||
assert_eq!(
|
||||
ConvergenceReason::LikelyNoSolution.as_str(),
|
||||
"likely_no_solution"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_matches_as_str() {
|
||||
for reason in ALL {
|
||||
assert_eq!(reason.to_string(), reason.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip_preserves_variant() {
|
||||
for reason in ALL {
|
||||
let json = serde_json::to_string(&reason).expect("serialize");
|
||||
let back: ConvergenceReason = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(reason, back);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user