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,225 @@
#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);
}