Files
MeanField_SERiF/tests/normalization/operators.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

317 lines
14 KiB
C++

#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")));
}