Files
Entropyk/crates/solver/tests/recoverable_domain_violation.rs
sepehr 5bd180b5b8
Some checks failed
CI / check (push) Has been cancelled
Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
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>
2026-07-19 16:35:31 +02:00

438 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Integration tests for Story 1.3: Recoverable Domain-Violation Errors.
//!
//! Covers:
//! - AC #2: a Newton trial step that triggers a recoverable domain violation
//! is shrunk and retried by the line search; after exhaustion of backtracks
//! the solve fails with `ConvergenceReason::DomainViolation` and diagnostics.
//! - AC #3: end-to-end mock proof — a component that throws a domain violation
//! on oversized steps converges via step reduction, and the iteration
//! history records the recoverable events.
//! - Fatal vs recoverable: a fatal `ComponentError` is never retried by the
//! line search, and a recoverable violation outside the line search is a
//! typed `SolverError::DomainViolation`, not an `InvalidSystem` flattening.
//!
//! Mock pattern follows `convergence_reason.rs` (`LinearSystem` /
//! `create_test_system` self-loop fixture): state layout `[ṁ, P, h]`, the
//! mass-flow pin row parks ṁ at `DEFAULT_MASS_FLOW_SEED_KG_S`, and the
//! enthalpy slot is parked at `MIN_SOLVER_PRESSURE_PA` (abstract unknown).
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use entropyk_components::{
Component, ComponentError, DomainViolation, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_solver::solver::{NewtonConfig, Solver, SolverError, VerboseConfig};
use entropyk_solver::system::{System, DEFAULT_MASS_FLOW_SEED_KG_S, MIN_SOLVER_PRESSURE_PA};
use entropyk_solver::{ConvergenceReason, DomainViolation as SolverDomainViolation, SolveOutcome};
// ─────────────────────────────────────────────────────────────────────────────
// Mock component: DomainWallComponent
// ─────────────────────────────────────────────────────────────────────────────
/// Pressure-row residual shape.
///
/// A strictly linear residual can never overshoot with an exact Jacobian
/// (Newton solves an affine system exactly in one step), so AC#3's
/// "full step overshoots the wall" requirement forces a nonlinear row.
/// Both shapes below drive `P` monotonically toward the target with an
/// exactly computable Newton step `ΔP = -(P - T)/p`:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WallShape {
/// `r = sign(P T)·√|P T|` (p = 1/2): the full Newton step is
/// `ΔP = -2(P T)`, i.e. it lands at `2T P₀`, symmetrically past the
/// target. With `T P₀ = 256` (dyadic) the halved step lands on the
/// target exactly — zero floating-point noise in the AC#3 assertion.
Sqrt,
/// `r = cbrt(P T)` (p = 1/3): the full step is `ΔP = -3(P T)`
/// (trial at `3T 2P₀`, Armijo-rejected), while the halved step shrinks
/// `|P T|` by exactly 1/2 per iteration — a slow, deterministic
/// approach used to stage the AC#2 exhaustion scenario over several
/// iterations before the wall activates.
Cbrt,
}
/// What the wall returns when a trial pressure exceeds it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WallMode {
/// Recoverable domain violation (KINSOL `> 0`): the line search must
/// shrink the step and retry.
Recoverable,
/// Fatal calculation failure (KINSOL `< 0`): the line search must abort
/// immediately without burning backtracks.
Fatal,
}
/// A mock component whose pressure row drives `P` toward `target_p` and whose
/// "physical domain" ends at `wall_p`: any evaluation at `P > wall_p` fails.
///
/// Residuals (state layout `[ṁ, P, h]`, 3 equations):
/// - `r0 = shape(P target_p)` — pressure row,
/// - `r1 = h parked_h` — enthalpy pin,
/// - `r2 = ṁ DEFAULT_MASS_FLOW_SEED_KG_S` — mass-flow pin (CM1.3).
///
/// The Jacobian is analytic and exact (project rule — no finite differences):
/// `∂r0/∂P = 1/(p·|P T|^(1p))`, `∂r1/∂h = 1`, `∂r2/∂ṁ = 1`.
///
/// `wall_after_evals` delays wall enforcement until the evaluation count
/// exceeds the given value, which stages the AC#2 scenario: the wall only
/// becomes active after a few iterations have been recorded in the
/// diagnostics history, so the exhausting iteration carries diagnostics.
struct DomainWallComponent {
target_p: f64,
wall_p: f64,
parked_h: f64,
shape: WallShape,
mode: WallMode,
wall_after_evals: usize,
eval_count: Arc<AtomicUsize>,
}
impl DomainWallComponent {
fn new(
target_p: f64,
wall_p: f64,
shape: WallShape,
mode: WallMode,
wall_after_evals: usize,
eval_count: Arc<AtomicUsize>,
) -> Self {
Self {
target_p,
wall_p,
parked_h: MIN_SOLVER_PRESSURE_PA,
shape,
mode,
wall_after_evals,
eval_count,
}
}
}
impl Component for DomainWallComponent {
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
let eval = self.eval_count.fetch_add(1, Ordering::SeqCst) + 1;
let p = state[1];
if eval > self.wall_after_evals && p > self.wall_p {
let detail = format!(
"trial pressure {p:.1} Pa exceeds domain wall at {:.1} Pa",
self.wall_p
);
return Err(match self.mode {
WallMode::Recoverable => ComponentError::DomainViolation(DomainViolation {
component: Some("domain-wall".into()),
detail,
}),
WallMode::Fatal => ComponentError::CalculationFailed(detail),
});
}
let dp = p - self.target_p;
residuals[0] = match self.shape {
WallShape::Sqrt => dp.signum() * dp.abs().sqrt(),
WallShape::Cbrt => dp.cbrt(),
};
residuals[1] = state[2] - self.parked_h;
residuals[2] = state[0] - DEFAULT_MASS_FLOW_SEED_KG_S;
Ok(())
}
fn jacobian_entries(
&self,
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
let dp_abs = (state[1] - self.target_p).abs();
let dr_dp = match self.shape {
// d/dP [sign(PT)·√|PT|] = 1/(2·√|PT|)
WallShape::Sqrt => 0.5 / dp_abs.sqrt(),
// d/dP cbrt(PT) = 1/(3·|PT|^(2/3))
WallShape::Cbrt => 1.0 / (3.0 * dp_abs.cbrt().powi(2)),
};
jacobian.add_entry(0, 1, dr_dp);
jacobian.add_entry(1, 2, 1.0);
jacobian.add_entry(2, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
3
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
/// Creates a minimal self-loop system with a single mock component.
fn create_test_system(component: Box<dyn Component>) -> System {
let mut system = System::new();
let n0 = system.add_component(component);
system.add_edge(n0, n0).unwrap();
system.finalize().unwrap();
system
}
/// Newton with line search and verbose diagnostics (iteration history is only
/// collected when verbose mode is active).
fn line_search_solver(initial_p: f64) -> NewtonConfig {
NewtonConfig {
line_search: true,
initial_state: Some(vec![
DEFAULT_MASS_FLOW_SEED_KG_S,
initial_p,
MIN_SOLVER_PRESSURE_PA,
]),
verbose_config: VerboseConfig {
enabled: true,
log_residuals: true,
..VerboseConfig::default()
},
..NewtonConfig::default()
}
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #3: converges via step reduction, events recorded in iteration history
// ─────────────────────────────────────────────────────────────────────────────
//
// Geometry (sqrt shape, dyadic exact): target T = floor + 300, start
// P₀ = T 256, wall = T + 100. The full Newton step ΔP = +512 proposes the
// trial 2T P₀ = T + 256, which crosses the wall (T + 100) → recoverable
// violation. The first backtrack (alpha = 0.5) lands exactly on T, which sits
// below the wall, and is accepted: the solve converges in one iteration with
// `recoverable_events = 1` and `alpha = 0.5` recorded.
#[test]
fn line_search_converges_via_step_reduction_on_domain_violation() {
let target_p = MIN_SOLVER_PRESSURE_PA + 300.0;
let start_p = target_p - 256.0;
let wall_p = target_p + 100.0;
let eval_count = Arc::new(AtomicUsize::new(0));
let mut system = create_test_system(Box::new(DomainWallComponent::new(
target_p,
wall_p,
WallShape::Sqrt,
WallMode::Recoverable,
0,
Arc::clone(&eval_count),
)));
let mut solver = line_search_solver(start_p);
let converged = solver
.solve(&mut system)
.expect("line search must shrink the oversized step and converge");
assert!(
converged.is_converged(),
"solve must report full convergence, got {:?}",
converged.status
);
assert!(
(converged.state[1] - target_p).abs() <= 1e-9 * target_p,
"converged pressure {} must equal the target {}",
converged.state[1],
target_p
);
let diagnostics = converged
.diagnostics
.as_ref()
.expect("verbose solve must attach iteration diagnostics");
let event_iteration = diagnostics
.iteration_history
.iter()
.find(|iteration| iteration.recoverable_events > 0)
.expect("iteration history must record the recoverable domain violation");
match event_iteration.alpha {
Some(alpha) => assert!(
alpha < 1.0,
"the recovering iteration must use a reduced step, got alpha = {alpha}"
),
None => panic!("the recovering iteration must record its line-search alpha"),
}
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #2: backtrack exhaustion surfaces a typed DomainViolation outcome
// ─────────────────────────────────────────────────────────────────────────────
//
// Geometry (cbrt shape): target T = floor + 200, start P₀ = T 100, wall at
// T 50 (below the target: the solution is unreachable without crossing the
// wall). The wall activates only after 10 residual evaluations so three
// iterations are recorded first:
// - initial eval (1); iter 1: alpha = 1 Armijo-rejected (2), alpha = 0.5
// accepted (3), post-step eval (4) → P₁ = T + 50;
// - iter 2: evals 5, 6, post-step 7 → P₂ = T 25;
// - iter 3: evals 8, 9, post-step 10 → P₃ = T + 12.5.
// Iteration 4 starts above the target with a downward Newton step of 37.5 Pa;
// every backtracked trial stays in [T 25, T + 12.5], all above the wall, so
// all 20 trials fail recoverably and the line search exhausts.
#[test]
fn line_search_exhaustion_returns_typed_domain_violation_outcome() {
let target_p = MIN_SOLVER_PRESSURE_PA + 200.0;
let start_p = target_p - 100.0;
let wall_p = target_p - 50.0;
let build_system = |eval_count: Arc<AtomicUsize>| {
create_test_system(Box::new(DomainWallComponent::new(
target_p,
wall_p,
WallShape::Cbrt,
WallMode::Recoverable,
10,
eval_count,
)))
};
// Legacy `solve`: hard Err, but typed as DomainViolation with diagnostics.
let mut system = build_system(Arc::new(AtomicUsize::new(0)));
let mut solver = line_search_solver(start_p);
let err = solver
.solve(&mut system)
.expect_err("an unreachable target must fail the solve");
let violation = match err.base_error() {
SolverError::DomainViolation(violation) => violation,
other => panic!("expected SolverError::DomainViolation, got {other:?}"),
};
// The solver facade re-exports the shared solver-core type (Task 3).
let _: &SolverDomainViolation = violation;
assert!(
violation.component.is_none(),
"the exhaustion error is raised by the line search, not a component: {violation:?}"
);
assert!(
violation.detail.contains("exhausted"),
"exhaustion detail must say the line search exhausted, got: {}",
violation.detail
);
assert_eq!(
err.convergence_reason(),
Some(ConvergenceReason::DomainViolation),
"typed domain violation must classify as ConvergenceReason::DomainViolation"
);
let diagnostics = err
.diagnostics()
.expect("failure after recorded iterations must carry diagnostics");
assert!(
!diagnostics.iteration_history.is_empty(),
"iterations completed before exhaustion must be in the history"
);
// `solve_outcome`: the same termination is outcome data, not a hard Err.
let mut system = build_system(Arc::new(AtomicUsize::new(0)));
let mut solver = line_search_solver(start_p);
let outcome: SolveOutcome = solver
.solve_outcome(&mut system)
.expect("domain-violation exhaustion must be an outcome, not a hard error");
assert_eq!(outcome.reason, ConvergenceReason::DomainViolation);
assert!(!outcome.is_converged());
}
// ─────────────────────────────────────────────────────────────────────────────
// Fatal vs recoverable: fatal errors are never retried by the line search
// ─────────────────────────────────────────────────────────────────────────────
//
// Same geometry as AC#3 (the full step crosses the wall), but the wall returns
// `ComponentError::CalculationFailed` (fatal, KINSOL `< 0`). The line search
// must abort immediately: exactly 2 residual evaluations (initial + the fatal
// trial), no backtracking retries, and an `InvalidSystem`-shaped hard error
// whose `convergence_reason()` is `None`.
#[test]
fn fatal_error_aborts_line_search_without_retries() {
let target_p = MIN_SOLVER_PRESSURE_PA + 300.0;
let start_p = target_p - 256.0;
let wall_p = target_p + 100.0;
let eval_count = Arc::new(AtomicUsize::new(0));
let mut system = create_test_system(Box::new(DomainWallComponent::new(
target_p,
wall_p,
WallShape::Sqrt,
WallMode::Fatal,
0,
Arc::clone(&eval_count),
)));
let mut solver = line_search_solver(start_p);
let err = solver
.solve(&mut system)
.expect_err("a fatal component error must abort the solve");
assert!(
matches!(err.base_error(), SolverError::InvalidSystem { .. }),
"fatal component errors keep the InvalidSystem flattening, got {:?}",
err.base_error()
);
assert_eq!(
err.convergence_reason(),
None,
"fatal errors are hard errors, not convergence outcomes"
);
assert_eq!(
eval_count.load(Ordering::SeqCst),
2,
"fatal errors must not burn line-search backtracks (initial eval + fatal trial)"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Recoverable violation without line search: typed error, no flattening
// ─────────────────────────────────────────────────────────────────────────────
//
// With `line_search: false` the full Newton step is applied unconditionally;
// the post-step residual evaluation then sits beyond the wall and fails
// recoverably. The uniform residual-eval rule maps this to a typed
// `SolverError::DomainViolation` (an outcome), not an `InvalidSystem`
// flattening (a hard error).
#[test]
fn recoverable_violation_without_line_search_is_typed_not_flattened() {
let target_p = MIN_SOLVER_PRESSURE_PA + 300.0;
let start_p = target_p - 256.0;
let wall_p = target_p + 100.0;
let eval_count = Arc::new(AtomicUsize::new(0));
let mut system = create_test_system(Box::new(DomainWallComponent::new(
target_p,
wall_p,
WallShape::Sqrt,
WallMode::Recoverable,
0,
Arc::clone(&eval_count),
)));
let mut solver = NewtonConfig {
line_search: false,
initial_state: Some(vec![
DEFAULT_MASS_FLOW_SEED_KG_S,
start_p,
MIN_SOLVER_PRESSURE_PA,
]),
..NewtonConfig::default()
};
let err = solver
.solve(&mut system)
.expect_err("the post-step recoverable violation must fail the solve");
assert!(
matches!(err.base_error(), SolverError::DomainViolation(_)),
"post-step recoverable violation must be typed DomainViolation, got {:?}",
err.base_error()
);
assert_eq!(
err.convergence_reason(),
Some(ConvergenceReason::DomainViolation),
"typed domain violation must classify as ConvergenceReason::DomainViolation"
);
assert_eq!(
eval_count.load(Ordering::SeqCst),
2,
"initial eval + post-step eval, no line-search trials"
);
}