//! Integration tests for Story 1.2: Core Crate & Typed Convergence Taxonomy. //! //! Covers: //! - AC #1: non-converged terminations are reported as `ConvergenceReason` //! outcomes (via `SolverError::convergence_reason` and `Solver::solve_outcome`), //! while hard errors (invalid system, validation) remain `Err`. //! - AC #3: existing `SolverError` behavior (incl. `WithDiagnostics`) is //! unchanged — the taxonomy is purely additive. use entropyk_components::{Component, ComponentError, JacobianBuilder, ResidualVector, StateSlice}; use entropyk_solver::solver::{NewtonConfig, Solver, SolverError}; use entropyk_solver::system::{System, DEFAULT_MASS_FLOW_SEED_KG_S, MIN_SOLVER_PRESSURE_PA}; use entropyk_solver::{ConvergenceDiagnostics, ConvergenceReason, SolveOutcome}; // ───────────────────────────────────────────────────────────────────────────── // Mock components (same fixture pattern as fallback_solver.rs) // ───────────────────────────────────────────────────────────────────────────── /// A well-conditioned linear system r = A·x − b: converges in one Newton step. struct LinearSystem { a: Vec>, b: Vec, n: usize, } impl LinearSystem { fn well_conditioned() -> Self { let (p, h) = (MIN_SOLVER_PRESSURE_PA, MIN_SOLVER_PRESSURE_PA); Self { a: vec![vec![2.0, 1.0], vec![1.0, 2.0]], b: vec![2.0 * p + h, p + 2.0 * h], n: 2, } } } impl Component for LinearSystem { fn compute_residuals( &self, state: &StateSlice, residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { for (i, residual) in residuals.iter_mut().enumerate().take(self.n) { let mut ax_i = 0.0; for j in 0..self.n { ax_i += self.a[i][j] * state[1 + j]; } *residual = ax_i - self.b[i]; } residuals[self.n] = state[0] - DEFAULT_MASS_FLOW_SEED_KG_S; Ok(()) } fn jacobian_entries( &self, _state: &StateSlice, jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { for i in 0..self.n { for j in 0..self.n { jacobian.add_entry(i, 1 + j, self.a[i][j]); } } jacobian.add_entry(self.n, 0, 1.0); Ok(()) } fn n_equations(&self) -> usize { self.n + 1 } fn get_ports(&self) -> &[entropyk_components::ConnectedPort] { &[] } } /// A mildly non-linear system ((x − p₀)² = 1) parked at the pressure floor, /// needing several Newton steps from an offset guess — deterministic /// `MaxIters` generator when the iteration budget is 1. struct QuadraticSystem; impl Component for QuadraticSystem { fn compute_residuals( &self, state: &StateSlice, residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { // r0 = (x − p₀)² − 1 (root at p₀ + 1, on the clip floor like the // established LinearSystem fixture; residual stays O(1) so neither // the divergence threshold nor pressure clipping interferes). let dx = state[1] - MIN_SOLVER_PRESSURE_PA; residuals[0] = dx * dx - 1.0; // r1 pins the second unknown. residuals[1] = state[2] - MIN_SOLVER_PRESSURE_PA; // Mass-flow row pins ṁ at the seed value. residuals[2] = state[0] - DEFAULT_MASS_FLOW_SEED_KG_S; Ok(()) } fn jacobian_entries( &self, state: &StateSlice, jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { jacobian.add_entry(0, 1, 2.0 * (state[1] - MIN_SOLVER_PRESSURE_PA)); 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] { &[] } } fn create_test_system(component: Box) -> System { let mut system = System::new(); let n0 = system.add_component(component); system.add_edge(n0, n0).unwrap(); system.finalize().unwrap(); system } // ───────────────────────────────────────────────────────────────────────────── // AC #1: SolverError classification into ConvergenceReason // ───────────────────────────────────────────────────────────────────────────── #[test] fn non_convergence_maps_to_max_iters() { let err = SolverError::NonConvergence { iterations: 42, final_residual: 1.0e-3, }; assert_eq!(err.convergence_reason(), Some(ConvergenceReason::MaxIters)); } #[test] fn timeout_maps_to_timed_out() { let err = SolverError::Timeout { timeout_ms: 500 }; assert_eq!(err.convergence_reason(), Some(ConvergenceReason::TimedOut)); } #[test] fn divergence_maps_to_stalled() { let err = SolverError::Divergence { reason: "residual growing".to_string(), }; assert_eq!(err.convergence_reason(), Some(ConvergenceReason::Stalled)); } #[test] fn with_diagnostics_delegates_to_inner_error() { let base = SolverError::NonConvergence { iterations: 10, final_residual: 1.0e-4, }; let wrapped = SolverError::WithDiagnostics { error: Box::new(base), diagnostics: Box::new(ConvergenceDiagnostics::new()), }; assert_eq!( wrapped.convergence_reason(), Some(ConvergenceReason::MaxIters) ); } #[test] fn hard_errors_map_to_none() { let invalid = SolverError::InvalidSystem { message: "empty system".to_string(), }; assert_eq!(invalid.convergence_reason(), None); let validation = SolverError::Validation { mass_error: 1.0, energy_error: 2.0, }; assert_eq!(validation.convergence_reason(), None); } // ───────────────────────────────────────────────────────────────────────────── // AC #1: solve_outcome reports outcomes as data // ───────────────────────────────────────────────────────────────────────────── #[test] fn solve_outcome_reports_converged_as_data() { let mut system = create_test_system(Box::new(LinearSystem::well_conditioned())); let mut solver = NewtonConfig::default(); let outcome: SolveOutcome = solver .solve_outcome(&mut system) .expect("converged solve must not be a hard error"); assert_eq!(outcome.reason, ConvergenceReason::Converged); assert!(outcome.is_converged()); assert!(outcome.final_residual < 1e-6); assert!(outcome.state.is_some()); } #[test] fn solve_outcome_reports_max_iters_as_data_not_err() { let mut system = create_test_system(Box::new(QuadraticSystem)); let mut solver = NewtonConfig { max_iterations: 1, tolerance: 1e-9, initial_state: Some(vec![ DEFAULT_MASS_FLOW_SEED_KG_S, MIN_SOLVER_PRESSURE_PA + 10.0, MIN_SOLVER_PRESSURE_PA, ]), ..NewtonConfig::default() }; let outcome = solver .solve_outcome(&mut system) .expect("MaxIters termination must be an outcome, not a hard Err"); assert_eq!(outcome.reason, ConvergenceReason::MaxIters); assert!(!outcome.is_converged()); assert!(outcome.iterations >= 1); assert!(outcome.final_residual.is_finite()); assert!(outcome.state.is_none()); } #[test] fn solve_outcome_keeps_hard_errors_as_err() { // Empty system: InvalidSystem is a hard error and must remain `Err`. let mut system = System::new(); system.finalize().unwrap(); let mut solver = NewtonConfig::default(); let result = solver.solve_outcome(&mut system); match result { Err(SolverError::InvalidSystem { .. }) => {} other => panic!("expected Err(InvalidSystem), got {:?}", other), } } // ───────────────────────────────────────────────────────────────────────────── // AC #3: legacy behavior untouched // ───────────────────────────────────────────────────────────────────────────── #[test] fn legacy_solve_still_returns_err_on_non_convergence() { // The legacy `solve` contract is unchanged: NonConvergence stays an `Err`. // Only the additive `solve_outcome` reports outcomes as data. let mut system = create_test_system(Box::new(QuadraticSystem)); let mut solver = NewtonConfig { max_iterations: 1, tolerance: 1e-9, initial_state: Some(vec![ DEFAULT_MASS_FLOW_SEED_KG_S, MIN_SOLVER_PRESSURE_PA + 10.0, MIN_SOLVER_PRESSURE_PA, ]), ..NewtonConfig::default() }; let result = solver.solve(&mut system); assert!(matches!(result, Err(SolverError::NonConvergence { .. }))); }