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.
This commit is contained in:
2026-11-09 13:53:17 -05:00
parent 74e55a6859
commit cb20faeb3d
202 changed files with 35505 additions and 953 deletions

View File

@@ -0,0 +1,316 @@
#include "serif/utils/error/errors.hpp"
#include <initializer_list>
#include <limits>
#include <stdexcept>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include <catch2/matchers/catch_matchers_exception.hpp>
#include <mfem/linalg/densemat.hpp>
#include <mfem/linalg/solvers.hpp>
#include "serif/backend/mfem/solver/normalization/operators.hpp"
namespace
{
namespace normalization = serif::solver::normalization;
namespace backend = normalization::backend::mfem;
/** @brief Form a vector from physically readable test coordinates. */
mfem::Vector vector(std::initializer_list<double> values)
{
mfem::Vector result(static_cast<int>(values.size()));
int index = 0;
for (const double value : values)
{
result(index++) = value;
}
return result;
}
/** @brief Compare coordinate actions at the original MeanField relative tolerances. */
void check_vector(const mfem::Vector &actual, const mfem::Vector &expected, double epsilon = 2.0e-13)
{
REQUIRE(actual.Size() == expected.Size());
for (int index = 0; index < actual.Size(); ++index)
{
CHECK(actual(index) == Catch::Approx(expected(index)).epsilon(epsilon).margin(1.0e-300));
}
}
/** @brief Exact physical inverse which records what dimensional Jacobian MFEM binds. */
class DenseInverseSolver final : public mfem::Solver
{
public:
/** @brief Copy a small reference inverse for independent solver assertions. */
explicit DenseInverseSolver(const mfem::DenseMatrix &inverse) : mfem::Solver(inverse.Height(), inverse.Width()), m_inverse(inverse)
{
}
/** @brief Bind the dimensional Jacobian, permitting a rectangular inverse map. */
void SetOperator(const mfem::Operator &operation) override
{
if (operation.Height() != Width() || operation.Width() != Height())
{
serif::utils::errors::serif_error<std::invalid_argument, serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT>("Dense test inverse received an incompatible Jacobian.");
}
bound_operator = &operation;
++bindings;
}
/** @brief Apply the exact inverse after checking the iterative initial guess. */
void Mult(const mfem::Vector &input, mfem::Vector &output) const override
{
if (iterative_mode)
{
for (int index = 0; index < output.Size(); ++index)
{
CHECK(output(index) == 0.0);
}
}
m_inverse.Mult(input, output);
}
/** @brief Apply the transpose of the exact inverse. */
void MultTranspose(const mfem::Vector &input, mfem::Vector &output) const override
{
m_inverse.MultTranspose(input, output);
}
const mfem::Operator *bound_operator{nullptr};
int bindings{0};
private:
mfem::DenseMatrix m_inverse;
};
/** @brief Recover dimensional matrices from a well-conditioned normalized test problem. */
struct PhysicalSystem final
{
normalization::DiagonalNormalization map = *normalization::make_diagonal_normalization({1.0e-9, 2.0e7}, {5.0e8, 3.0e-6});
mfem::DenseMatrix jacobian{2};
mfem::DenseMatrix inverse{2};
/** @brief Use independently known normalized matrix and inverse entries. */
PhysicalSystem()
{
constexpr double normalized_matrix[2][2]{{4.0, 1.0}, {2.0, 3.0}};
constexpr double normalized_inverse[2][2]{{0.3, -0.1}, {-0.2, 0.4}};
for (int row = 0; row < 2; ++row)
{
for (int column = 0; column < 2; ++column)
{
jacobian(row, column) = normalized_matrix[row][column] * map.state_factors()[column] / map.residual_factors()[row];
inverse(row, column) = normalized_inverse[row][column] * map.residual_factors()[column] / map.state_factors()[row];
}
}
}
};
}
// Ported from MeanField tests/normalization/physical_riesz.cpp, with transpose coverage added.
TEST_CASE("Scaled Jacobian and inverse implement the exact coordinate change", "[normalization][unit][linear-algebra]")
{
const PhysicalSystem physical;
const backend::ScaledJacobianOperator jacobian(physical.jacobian, physical.map);
const backend::ScaledInverseOperator inverse(physical.inverse, physical.map);
const mfem::Vector direction = vector({0.75, -1.25});
mfem::Vector action;
jacobian.Mult(direction, action);
check_vector(action, vector({1.75, -2.25}), 4.0e-15);
mfem::Vector recovered;
inverse.Mult(action, recovered);
check_vector(recovered, direction);
jacobian.MultTranspose(direction, action);
check_vector(action, vector({0.5, -3.0}), 4.0e-15);
inverse.MultTranspose(action, recovered);
check_vector(recovered, direction);
}
TEST_CASE("Scaled physical preconditioning preserves dimensional binding through FGMRES", "[normalization][unit][solver]")
{
const PhysicalSystem physical;
const backend::ScaledJacobianOperator jacobian(physical.jacobian, physical.map);
DenseInverseSolver inverse(physical.inverse);
backend::ScaledPreconditioner preconditioner(inverse, jacobian);
CHECK(inverse.bound_operator == &physical.jacobian);
const mfem::Vector right_hand_side = vector({1.5, -0.75});
const mfem::Vector expected = vector({0.525, -0.6});
mfem::Vector correction(2);
preconditioner.Mult(right_hand_side, correction);
check_vector(correction, expected, 3.0e-13);
mfem::FGMRESSolver krylov;
krylov.SetRelTol(1.0e-13);
krylov.SetAbsTol(1.0e-15);
krylov.SetMaxIter(4);
krylov.SetKDim(2);
krylov.SetPrintLevel(0);
krylov.SetPreconditioner(preconditioner);
krylov.SetOperator(jacobian);
mfem::Vector solution(2);
solution = 0.0;
krylov.Mult(right_hand_side, solution);
CHECK(krylov.GetConverged());
CHECK(krylov.GetNumIterations() <= 1);
check_vector(solution, expected, 3.0e-13);
CHECK(inverse.bound_operator == &physical.jacobian);
CHECK(inverse.bindings >= 2);
CHECK(preconditioner.statistics().operator_bindings >= 2);
CHECK(preconditioner.statistics().applications >= 2);
preconditioner.MultTranspose(right_hand_side, correction);
check_vector(correction, vector({0.6, -0.45}));
}
TEST_CASE("Scaled preconditioners reject unrelated preparations and malformed vectors", "[normalization][unit][validation]")
{
const PhysicalSystem physical;
const backend::ScaledJacobianOperator jacobian(physical.jacobian, physical.map);
DenseInverseSolver inverse(physical.inverse);
backend::ScaledPreconditioner preconditioner(inverse, jacobian);
const backend::ScaledJacobianOperator unrelated(physical.jacobian, physical.map);
const mfem::IdentityOperator wrong_size(3);
CHECK_THROWS_MATCHES(preconditioner.SetOperator(unrelated), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_ERROR")));
CHECK_THROWS_MATCHES(preconditioner.SetOperator(wrong_size), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_ERROR")));
CHECK(inverse.bindings == 1);
const mfem::Vector right_hand_side = vector({1.0, 2.0});
mfem::Vector wrong_output(1);
CHECK_THROWS_MATCHES(preconditioner.Mult(right_hand_side, wrong_output), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_VECTOR_SIZE_MISMATCH")));
CHECK_THROWS_MATCHES(preconditioner.MultTranspose(right_hand_side, wrong_output), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_VECTOR_SIZE_MISMATCH")));
mfem::Vector output(2);
output = 7.0;
CHECK_THROWS_MATCHES(preconditioner.Mult(wrong_output, output), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_VECTOR_SIZE_MISMATCH")));
check_vector(output, vector({7.0, 7.0}));
CHECK(preconditioner.statistics().applications == 0);
CHECK_THROWS_MATCHES((backend::ScaledJacobianOperator{wrong_size, physical.map}), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR")));
CHECK_THROWS_MATCHES((backend::ScaledInverseOperator{wrong_size, physical.map}), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR")));
}
TEST_CASE("Physical Riesz scaling collapses a forty-eight-decade diagonal imbalance", "[normalization][unit][numerics]")
{
const auto map = normalization::make_diagonal_normalization({1.0e-12, 1.0, 1.0e12}, {1.0e12, 1.0, 1.0e-12});
REQUIRE(map);
mfem::DenseMatrix physical(3);
physical = 0.0;
for (int index = 0; index < 3; ++index)
{
physical(index, index) = map->state_factors()[index] / map->residual_factors()[index];
}
CHECK(physical(2, 2) / physical(0, 0) == Catch::Approx(1.0e48));
const backend::ScaledJacobianOperator jacobian(physical, *map);
const mfem::Vector direction = vector({-2.0, 3.5, 0.125});
mfem::Vector action;
jacobian.Mult(direction, action);
check_vector(action, direction, 4.0e-15);
}
TEST_CASE("Rectangular normalized operators and their transposes preserve independent coordinate sizes", "[normalization][unit][linear-algebra]")
{
const auto map = normalization::make_diagonal_normalization({2.0, 3.0}, {5.0, 7.0, 11.0});
REQUIRE(map);
mfem::DenseMatrix physical(3, 2);
mfem::DenseMatrix physical_inverse(2, 3);
for (int row = 0; row < 3; ++row)
{
for (int column = 0; column < 2; ++column)
{
const double entry = 1.0 + 2.0 * row + column;
physical(row, column) = entry * map->state_factors()[column] / map->residual_factors()[row];
physical_inverse(column, row) = entry * map->residual_factors()[row] / map->state_factors()[column];
}
}
const backend::ScaledJacobianOperator jacobian(physical, *map);
const backend::ScaledInverseOperator inverse(physical_inverse, *map);
mfem::Vector action;
jacobian.Mult(vector({2.0, -0.5}), action);
check_vector(action, vector({1.0, 4.0, 7.0}));
jacobian.MultTranspose(vector({1.0, 2.0, 3.0}), action);
check_vector(action, vector({22.0, 28.0}));
inverse.Mult(vector({1.0, 2.0, 3.0}), action);
check_vector(action, vector({22.0, 28.0}));
inverse.MultTranspose(vector({2.0, -0.5}), action);
check_vector(action, vector({1.0, 4.0, 7.0}));
DenseInverseSolver physical_solver(physical_inverse);
backend::ScaledPreconditioner preconditioner(physical_solver, jacobian);
CHECK(physical_solver.bound_operator == &physical);
action.SetSize(2);
preconditioner.Mult(vector({1.0, 2.0, 3.0}), action);
check_vector(action, vector({22.0, 28.0}));
action.SetSize(3);
preconditioner.MultTranspose(vector({2.0, -0.5}), action);
check_vector(action, vector({1.0, 4.0, 7.0}));
}
TEST_CASE("Normalized adapters own frozen factors and support in-place applications", "[normalization][unit][lifetime]")
{
PhysicalSystem physical;
const backend::ScaledJacobianOperator jacobian(physical.jacobian, physical.map);
physical.map = *normalization::make_diagonal_normalization({1.0, 1.0}, {1.0, 1.0});
CHECK(jacobian.normalization().state_factors()[0] == 1.0e-9);
CHECK(&jacobian.physical_operator() == &physical.jacobian);
mfem::Vector values = vector({0.75, -1.25});
jacobian.Mult(values, values);
check_vector(values, vector({1.75, -2.25}));
values = vector({0.75, -1.25});
jacobian.MultTranspose(values, values);
check_vector(values, vector({0.5, -3.0}));
}
TEST_CASE("Physical iterative inverses receive fresh zero correction guesses", "[normalization][unit][solver]")
{
const PhysicalSystem physical;
const backend::ScaledJacobianOperator jacobian(physical.jacobian, physical.map);
DenseInverseSolver inverse(physical.inverse);
inverse.iterative_mode = true;
backend::ScaledPreconditioner preconditioner(inverse, jacobian);
mfem::Vector correction(2);
correction = 41.0;
preconditioner.Mult(vector({1.5, -0.75}), correction);
check_vector(correction, vector({0.525, -0.6}));
preconditioner.Mult(vector({1.5, -0.75}), correction);
check_vector(correction, vector({0.525, -0.6}));
}
TEST_CASE("Scaled preconditioners bind an initially unconfigured physical solver before checking its dimensions", "[normalization][unit][solver]")
{
mfem::DenseMatrix physical(2);
physical(0, 0) = 4.0;
physical(0, 1) = 1.0;
physical(1, 0) = 1.0;
physical(1, 1) = 3.0;
const auto map = normalization::make_diagonal_normalization({2.0, 5.0}, {7.0, 11.0});
REQUIRE(map);
const backend::ScaledJacobianOperator jacobian(physical, *map);
mfem::CGSolver inverse;
inverse.SetRelTol(1.0e-14);
inverse.SetAbsTol(1.0e-15);
inverse.SetMaxIter(4);
inverse.SetPrintLevel(0);
CHECK(inverse.Width() == 0);
CHECK(inverse.Height() == 0);
backend::ScaledPreconditioner preconditioner(inverse, jacobian);
CHECK(inverse.Width() == 2);
CHECK(inverse.Height() == 2);
CHECK(preconditioner.statistics().operator_bindings == 1);
mfem::Vector correction(2);
preconditioner.Mult(vector({2.45, -3.575}), correction);
CHECK(inverse.GetConverged());
check_vector(correction, vector({0.25, -0.75}));
preconditioner.SetOperator(jacobian);
CHECK(preconditioner.statistics().operator_bindings == 2);
}
TEST_CASE("Normalization reports MFEM index overflow before narrowing", "[normalization][unit][validation]")
{
const std::size_t largest_index = static_cast<std::size_t>(std::numeric_limits<int>::max());
CHECK(backend::detail::mfem_size(0) == 0);
CHECK(backend::detail::mfem_size(largest_index) == std::numeric_limits<int>::max());
CHECK_THROWS_MATCHES(backend::detail::mfem_size(largest_index + 1), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("INVALID_DOF_COUNT")));
}