Files
MeanField_SERiF/tests/solver/linear.cpp
Emily Boudreaux cb20faeb3d test(tests): added test suite
The test suite is extensive. Note that LLMs were used to aid in the development of the test suite. All LLM generated code was manually reviewed.
2026-11-09 13:53:17 -05:00

221 lines
11 KiB
C++

#include <cmath>
#include <limits>
#include <stdexcept>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "mfem/linalg/densemat.hpp"
#include "serif/backend/mfem/solver/linear/solve.hpp"
#include "serif/utils/error/errors.hpp"
#include "serif/backend/mfem/operators/actions.hpp"
namespace {
namespace linear = serif::solver::linear;
constexpr auto fixed = serif::solver::preconditioner::ApplicationContract::fixed_linear;
/** @brief Keep an exact identity inverse while checking the original operator address is preserved. */
class IdentityInverse final : public ::mfem::Solver {
public:
/** @brief Bind one test linearization. */
explicit IdentityInverse(const ::mfem::Operator& operation) : ::mfem::Solver(operation.Width()), m_operation(operation) {}
/** @brief Reject a counting wrapper or unrelated operator replacing the accepted linearization. */
void SetOperator(const ::mfem::Operator& operation) override { REQUIRE(&operation == &m_operation); }
/** @brief Copy the correction without changing its coordinates. */
void Mult(const ::mfem::Vector& input, ::mfem::Vector& output) const override { output = input; }
private:
const ::mfem::Operator& m_operation;
};
/** @brief Use MeanField's nonsymmetric two-coordinate problem, with solution (0.1, 0.6). */
[[nodiscard]] ::mfem::DenseMatrix example_matrix() {
::mfem::DenseMatrix matrix(2);
matrix(0, 0) = 4.0;
matrix(0, 1) = 1.0;
matrix(1, 0) = 2.0;
matrix(1, 1) = 3.0;
return matrix;
}
}
TEST_CASE("Checked FGMRES preserves restart counts and RHS-relative warm-start accuracy", "[solver][linear]") {
const ::mfem::DenseMatrix matrix = example_matrix();
IdentityInverse inverse(matrix);
::mfem::Vector rhs(2), correction(2);
rhs[0] = 1.0;
rhs[1] = 2.0;
correction = 0.0;
const linear::FGMRES options{.restart_length = 1};
const linear::Control control{.relative_tolerance = 1.0e-12, .absolute_tolerance = 1.0e-14, .maximum_iterations = 200};
const auto cold = linear::backend::mfem::solve(options, matrix, inverse, fixed, rhs, correction, control);
REQUIRE(cold.converged());
CHECK(correction[0] == Catch::Approx(0.1).margin(1.0e-11));
CHECK(correction[1] == Catch::Approx(0.6).margin(1.0e-11));
CHECK(cold.right_hand_side_norm == Catch::Approx(std::sqrt(5.0)));
CHECK(cold.true_residual_norm <= control.convergence_threshold(cold.right_hand_side_norm));
CHECK(cold.iterations == static_cast<int>(cold.preconditioner_applications));
CHECK(cold.iterations > 1);
CHECK(cold.iterations <= control.maximum_iterations);
CHECK(cold.restarts == cold.iterations - 1);
CHECK(cold.operator_applications > cold.preconditioner_applications);
CHECK(cold.solve_seconds >= 0.0);
CHECK(cold.operator_seconds >= 0.0);
CHECK(cold.preconditioner_seconds >= 0.0);
correction[0] += 1.0e-5;
correction[1] -= 1.0e-5;
const auto warm = linear::backend::mfem::solve(options, matrix, inverse, fixed, rhs, correction, control);
REQUIRE(warm.converged());
CHECK(warm.initial_residual_norm < cold.initial_residual_norm);
CHECK(warm.true_residual_norm <= control.convergence_threshold(warm.right_hand_side_norm));
}
TEST_CASE("Checked FGMRES discards an adverse or overflowing finite guess", "[solver][linear]") {
const ::mfem::DenseMatrix matrix = example_matrix();
IdentityInverse inverse(matrix);
::mfem::Vector rhs(2), correction(2);
rhs[0] = 1.0;
rhs[1] = 2.0;
correction = 0.0;
const linear::FGMRES options{.restart_length = 1};
const linear::Control control{.relative_tolerance = 1.0e-12, .absolute_tolerance = 1.0e-14, .maximum_iterations = 200};
const auto cold = linear::backend::mfem::solve(options, matrix, inverse, fixed, rhs, correction, control);
REQUIRE(cold.converged());
SECTION("Finite adverse coefficients") {
correction[0] = 100.0;
correction[1] = -100.0;
}
SECTION("Finite coefficients with overflowing action") {
correction = std::numeric_limits<double>::max() / 2.0;
}
const auto guarded = linear::backend::mfem::solve(options, matrix, inverse, fixed, rhs, correction, control);
REQUIRE(guarded.converged());
CHECK(guarded.initial_residual_norm == Catch::Approx(cold.right_hand_side_norm));
CHECK(guarded.iterations == cold.iterations);
CHECK(guarded.operator_applications == cold.operator_applications + 1);
}
TEST_CASE("Checked FGMRES verifies exact guesses and zero right-hand sides without Krylov iterations", "[solver][linear]") {
const ::mfem::DenseMatrix matrix = example_matrix();
IdentityInverse inverse(matrix);
::mfem::Vector rhs(2), correction(2);
correction[0] = 1.0;
correction[1] = 2.0;
matrix.Mult(correction, rhs);
SECTION("Exact nonzero initial guess") {
const auto report = linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction);
REQUIRE(report.converged());
CHECK(report.iterations == 0);
CHECK(report.restarts == 0);
CHECK(report.operator_applications == 2);
CHECK(report.true_residual_norm == 0.0);
}
SECTION("Zero equation discards a nonzero guess") {
rhs = 0.0;
const auto report = linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction);
REQUIRE(report.converged());
CHECK(report.iterations == 0);
CHECK(report.relative_true_residual_norm == 0.0);
CHECK(correction.Norml2() == 0.0);
}
SECTION("Variable preconditioner contract is supported") {
const auto report = linear::backend::mfem::solve({}, matrix, inverse,
serif::solver::preconditioner::ApplicationContract::variable, rhs, correction);
CHECK(report.converged());
}
}
TEST_CASE("Checked FGMRES reports exhausted budgets and nonfinite numerical results", "[solver][linear]") {
::mfem::DenseMatrix matrix = example_matrix();
IdentityInverse inverse(matrix);
::mfem::Vector rhs(2), correction(2);
rhs[0] = 1.0;
rhs[1] = 2.0;
correction = 0.0;
SECTION("A single Krylov iteration is insufficient") {
const auto report = linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction,
{.relative_tolerance = 1.0e-14, .maximum_iterations = 1});
CHECK(report.status == linear::Status::maximum_iterations);
CHECK(report.iterations == 1);
CHECK(report.true_residual_norm > report.control.convergence_threshold(report.right_hand_side_norm));
}
SECTION("Nonfinite RHS is numerical failure") {
rhs[0] = std::numeric_limits<double>::infinity();
const auto report = linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction);
CHECK(report.status == linear::Status::non_finite);
CHECK(report.operator_applications == 0);
}
SECTION("Nonfinite initial correction is numerical failure") {
correction[0] = std::numeric_limits<double>::quiet_NaN();
CHECK(linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction).status == linear::Status::non_finite);
}
SECTION("SERiF action overflow is a numerical report, without catching unrelated callback errors") {
const auto action = serif::operators::backend::mfem::linearized_action(2, 2,
[](const ::mfem::Vector&, ::mfem::Vector& output) { output = std::numeric_limits<double>::infinity(); },
[](const ::mfem::Vector&, ::mfem::Vector& output) { output = std::numeric_limits<double>::infinity(); });
IdentityInverse action_inverse(*action.action());
CHECK(linear::backend::mfem::solve({}, *action.action(), action_inverse, fixed, rhs, correction).status == linear::Status::non_finite);
}
SECTION("Nonfinite operator action cannot converge") {
matrix(0, 0) = std::numeric_limits<double>::quiet_NaN();
CHECK(linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction).status == linear::Status::non_finite);
}
}
TEST_CASE("Checked FGMRES independently rejects misleading backend convergence", "[solver][linear]") {
/** @brief Mimic a stale callback whose final action disagrees with the Krylov recurrence. */
class ChangingOperator final : public ::mfem::Operator {
public:
/** @brief Use one coordinate so the recurrence converges in one iteration. */
ChangingOperator() : ::mfem::Operator(1) {}
/** @brief Change only after MFEM's initial and Arnoldi operator applications. */
void Mult(const ::mfem::Vector& input, ::mfem::Vector& output) const override {
++applications;
output[0] = input[0] * (applications > 2 ? 2.0 : 1.0);
}
mutable int applications{0};
} operation;
IdentityInverse inverse(operation);
::mfem::Vector rhs(1), correction(1);
rhs = 1.0;
correction = 0.0;
const auto report = linear::backend::mfem::solve({}, operation, inverse, fixed, rhs, correction);
CHECK(report.status == linear::Status::backend_failure);
CHECK(report.reported_residual_norm == 0.0);
CHECK(report.true_residual_norm == 1.0);
}
TEST_CASE("Checked FGMRES validates configuration and preserves callback errors", "[solver][linear]") {
CHECK_THROWS_AS(linear::FGMRES{.restart_length = 0}.validate(), std::invalid_argument);
CHECK_THROWS_AS(linear::FGMRES{.print_level = 4}.validate(), std::invalid_argument);
for (const double invalid : {-1.0, std::numeric_limits<double>::quiet_NaN(), std::numeric_limits<double>::infinity()}) {
CHECK_THROWS_AS(linear::Control{.relative_tolerance = invalid}.validate(), std::invalid_argument);
CHECK_THROWS_AS(linear::Control{.absolute_tolerance = invalid}.validate(), std::invalid_argument);
CHECK_THROWS_AS(linear::Control{}.convergence_threshold(invalid), std::invalid_argument);
}
CHECK_THROWS_AS(linear::Control{.maximum_iterations = 0}.validate(), std::invalid_argument);
CHECK_THROWS_AS(linear::Control{.relative_tolerance = std::numeric_limits<double>::max()}.convergence_threshold(2.0), std::invalid_argument);
const linear::Control control{.relative_tolerance = 0.25, .absolute_tolerance = 0.5};
CHECK(control.convergence_threshold(4.0) == 1.0);
CHECK(control.convergence_threshold(0.0) == 0.5);
/** @brief Model a genuine programming error rather than a numerical rejection. */
class BrokenOperator final : public ::mfem::Operator {
public:
/** @brief Match one valid coefficient. */
BrokenOperator() : ::mfem::Operator(1) {}
/** @brief Exercise the error boundary without introducing a broad catch in the solver. */
void Mult(const ::mfem::Vector&, ::mfem::Vector&) const override {
serif::utils::errors::serif_error<std::logic_error, serif::utils::errors::SERiFErrorCode::SOLVER_ERROR>("Broken callback contract.");
}
} operation;
IdentityInverse inverse(operation);
::mfem::Vector rhs(1), correction(1);
rhs = 1.0;
correction = 0.0;
CHECK_THROWS_AS(linear::backend::mfem::solve({}, operation, inverse, fixed, rhs, correction), std::logic_error);
correction.SetSize(2);
CHECK_THROWS_AS(linear::backend::mfem::solve({}, operation, inverse, fixed, rhs, correction), std::invalid_argument);
}