Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
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:
2026-07-19 16:35:31 +02:00
parent 88620790d6
commit 5bd180b5b8
1363 changed files with 101041 additions and 58547 deletions

View 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"

View 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());
}
}

View 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;

View 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());
}
}

View 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);
}
}
}