Files
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

226 lines
10 KiB
C++

#include <array>
#include <cmath>
#include <limits>
#include <memory>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "mfem/linalg/densemat.hpp"
#include "serif/backend/mfem/solver/preconditioner/border.hpp"
#include "serif/backend/mfem/solver/normalization/operators.hpp"
namespace
{
namespace backend = serif::solver::preconditioner::backend::mfem;
using serif::solver::preconditioner::ApplicationContract;
/** @brief Build independent dense examples in the legacy tests' row-major notation. */
[[nodiscard]] mfem::DenseMatrix matrix(int size, std::initializer_list<double> entries)
{
REQUIRE(entries.size() == static_cast<std::size_t>(size * size));
mfem::DenseMatrix result(size);
auto entry = entries.begin();
for (int row = 0; row < size; ++row)
{
for (int column = 0; column < size; ++column)
{
result(row, column) = *entry++;
}
}
return result;
}
/** @brief Keep test vectors explicit without relying on MFEM initializer-list support. */
[[nodiscard]] mfem::Vector vector(std::initializer_list<double> values)
{
mfem::Vector result(static_cast<int>(values.size()));
int index = 0;
for (double value : values)
{
result[index++] = value;
}
return result;
}
/** @brief Compare complete actions by their absolute error norm. */
[[nodiscard]] double difference(const mfem::Vector& left, const mfem::Vector& right)
{
mfem::Vector result(left);
result -= right;
return result.Norml2();
}
/** @brief Count physical work while retaining independently specified dense actions. */
class CountedOperator final : public mfem::Operator
{
public:
/** @brief Freeze the coefficients while allowing failure and work-count instrumentation. */
explicit CountedOperator(mfem::DenseMatrix coefficients) : Operator(coefficients.Height(), coefficients.Width()), coefficients(std::move(coefficients))
{
}
/** @brief Apply the exact dense action and optionally simulate a rejected numerical callback. */
void Mult(const mfem::Vector& input, mfem::Vector& output) const override
{
++applications;
coefficients.Mult(input, output);
if (nonfinite)
{
output[0] = std::numeric_limits<double>::infinity();
}
}
mfem::DenseMatrix coefficients;
mutable int applications{0};
bool nonfinite{false};
};
/** @brief A nonlinear inner solve exposes the difference between cached linear responses and fresh solves. */
class VariableInverse final : public mfem::Operator
{
public:
/** @brief Use one unknown so the independently derived correction is unambiguous. */
VariableInverse() : Operator(1)
{
}
/** @brief Model a right-hand-side-dependent inverse with a known analytic response. */
void Mult(const mfem::Vector& input, mfem::Vector& output) const override
{
++applications;
output[0] = evaluate(input[0]);
}
/** @brief Evaluate the independent scalar nonlinear response. */
[[nodiscard]] static double evaluate(double value)
{
return value / (2.0 + std::abs(value));
}
mutable int applications{0};
};
} // namespace
TEST_CASE("Dense borders preserve exact factorizations and stationary response work counts", "[preconditioner][border][unit]")
{
// MeanField specification_border.cpp:845 retains one, two, and four generated scalars and both preparations.
for (const int count : {1, 2, 4})
{
CAPTURE(count);
for (const int generation : {0, 1})
{
CAPTURE(generation);
auto fields = std::make_shared<CountedOperator>(matrix(3, {1.0 / (2 + 0.25 * generation), 0, 0, 0, 1.0 / (3 + 0.5 * generation), 0, 0, 0, 1.0 / (5 + 0.75 * generation)}));
auto forward = std::make_shared<mfem::DenseMatrix>(3, count);
auto reverse = std::make_shared<mfem::DenseMatrix>(count, 3);
auto diagonal = std::make_shared<mfem::DenseMatrix>(count);
mfem::DenseMatrix complete(3 + count);
complete = 0.0;
complete(0, 0) = 2 + 0.25 * generation;
complete(1, 1) = 3 + 0.5 * generation;
complete(2, 2) = 5 + 0.75 * generation;
for (int row = 0; row < count; ++row)
{
for (int column = 0; column < count; ++column)
{
(*diagonal)(row, column) = row == column ? 2.0 + row + 0.5 * generation : 0.01 * (row + column + 1);
complete(3 + row, 3 + column) = (*diagonal)(row, column);
}
for (int column = 0; column < 3; ++column)
{
(*forward)(column, row) = -0.03 * ((column + 1) * (row + 2));
(*reverse)(row, column) = 0.04 * ((row + 1) * (column + 2));
complete(column, 3 + row) = (*forward)(column, row);
complete(3 + row, column) = (*reverse)(row, column);
}
}
auto prepared = backend::try_bordered_inverse({.action = fields}, {.first_from_second = forward, .second_from_first = reverse}, diagonal);
REQUIRE(prepared);
CHECK(fields->applications == count);
mfem::Vector rhs(3 + count), actual(3 + count), expected(3 + count);
for (int index = 0; index < rhs.Size(); ++index)
{
rhs[index] = 0.25 + 0.17 * (index + 1);
}
mfem::DenseMatrixInverse exact(complete);
exact.Mult(rhs, expected);
prepared->action->Mult(rhs, actual);
CHECK(difference(actual, expected) / std::max(1.0, expected.Norml2()) <= 2.0e-13);
CHECK(fields->applications == count + 1);
prepared->action->Mult(rhs, rhs);
CHECK(difference(rhs, expected) / std::max(1.0, expected.Norml2()) <= 2.0e-13);
CHECK(fields->applications == count + 2);
if (count == 2)
{
auto flexible = backend::try_bordered_inverse({.action = fields, .contract = ApplicationContract::variable}, {.first_from_second = forward, .second_from_first = reverse}, diagonal);
REQUIRE(flexible);
const int before = fields->applications;
flexible->action->Mult(vector({0.42, 0.59, 0.76, 0.93, 1.10}), actual);
CHECK(difference(actual, expected) / std::max(1.0, expected.Norml2()) <= 2.0e-13);
CHECK(fields->applications == before + 2);
}
}
}
}
TEST_CASE("Variable border inverses recompute the corrected field solve", "[preconditioner][border][unit]")
{
const auto fields = std::make_shared<VariableInverse>();
const auto forward = std::make_shared<mfem::DenseMatrix>(matrix(1, {0.75}));
const auto reverse = std::make_shared<mfem::DenseMatrix>(matrix(1, {1.25}));
const auto diagonal = std::make_shared<mfem::DenseMatrix>(matrix(1, {4.0}));
auto prepared = backend::try_bordered_inverse({.action = fields, .contract = ApplicationContract::variable}, {.first_from_second = forward, .second_from_first = reverse}, diagonal);
REQUIRE(prepared);
CHECK(prepared->contract == ApplicationContract::variable);
CHECK(fields->applications == 1);
mfem::Vector rhs = vector({2.5, -0.3});
mfem::Vector actual(2);
const double response = VariableInverse::evaluate(0.75);
const double first = VariableInverse::evaluate(rhs[0]);
const double border = (rhs[1] - 1.25 * first) / (4.0 - 1.25 * response);
const double corrected = VariableInverse::evaluate(rhs[0] - 0.75 * border);
prepared->action->Mult(rhs, actual);
CHECK(fields->applications == 3);
CHECK(actual[0] == Catch::Approx(corrected).margin(2.0e-14));
CHECK(actual[1] == Catch::Approx(border).margin(2.0e-14));
CHECK(std::abs(actual[0] - (first - response * border)) > 1.0e-3);
}
TEST_CASE("An empty border preserves the original field inverse", "[preconditioner][border][unit]")
{
const backend::Inverse fields{.action = std::make_shared<mfem::DenseMatrix>(matrix(1, {0.5}))};
auto prepared = backend::try_bordered_inverse(fields, {.first_from_second = std::make_shared<mfem::DenseMatrix>(1, 0), .second_from_first = std::make_shared<mfem::DenseMatrix>(0, 1)}, std::make_shared<mfem::DenseMatrix>(0));
REQUIRE(prepared);
CHECK(prepared->action == fields.action);
}
TEST_CASE("Unbordered field selection preserves nonunit normalization and typed packing", "[preconditioner][border][coordinates]")
{
namespace normalization = serif::solver::normalization;
namespace scaled = normalization::backend::mfem;
// Original eight-coordinate fixture: three uneven field groups and two excluded scalar borders.
auto packed = std::make_shared<mfem::DenseMatrix>(8);
*packed = 0.0;
for (int index = 0; index < 6; ++index)
{
(*packed)(index, index) = index + 2.0;
}
(*packed)(0, 5) = 0.4;
(*packed)(4, 1) = -0.6;
const std::array output_order{5, 2, 3, 4, 6, 0, 1, 7};
const std::array input_order{4, 5, 1, 2, 3, 6, 0, 7};
auto physical = backend::try_restrict_operator(packed, output_order, input_order);
REQUIRE(physical);
auto factors = normalization::make_diagonal_normalization({1.0 / 23, 1.0 / 29, 1.0 / 31, 1.0 / 37, 1.0 / 41, 1.0 / 43, 1.0 / 47, 1.0 / 53}, {0.5, 1.0 / 3, 0.2, 1.0 / 7, 1.0 / 11, 1.0 / 13, 1.0 / 17, 1.0 / 19});
REQUIRE(factors);
scaled::ScaledInverseOperator inverse(**physical, *factors);
mfem::Vector rhs = vector({1, 2, 3, 4, 5, 6, 7, 8});
const mfem::Vector expected = vector({42.0 / 23, 112.0 / 29, 275.0 / 31, 3.0 / 37, 0, 240.4 / 43, 45.0 / 47, 0});
mfem::Vector actual(8);
inverse.Mult(rhs, actual);
CHECK(difference(actual, expected) < 1.0e-13);
inverse.Mult(rhs, rhs);
CHECK(difference(rhs, expected) < 1.0e-13);
}