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

View File

@@ -0,0 +1,150 @@
#include <array>
#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/preconditioner/inverse/dense.hpp"
namespace
{
namespace backend = serif::solver::preconditioner::backend::mfem;
using serif::solver::preconditioner::ApplicationContract;
using serif::solver::preconditioner::strategies::coupling::CouplingOrder;
/** @brief Construct a small row-major matrix independently of the implementation's block algebra. */
[[nodiscard]] std::shared_ptr<mfem::DenseMatrix> matrix(int height, int width, std::initializer_list<double> entries)
{
REQUIRE(entries.size() == static_cast<std::size_t>(height * width));
auto result = std::make_shared<mfem::DenseMatrix>(height, width);
auto entry = entries.begin();
for (int row = 0; row < height; ++row)
{
for (int column = 0; column < width; ++column)
{
(*result)(row, column) = *entry++;
}
}
return result;
}
/** @brief Compare complete numerical actions without ignoring small absolute discrepancies. */
[[nodiscard]] double difference(const mfem::Vector& left, const mfem::Vector& right)
{
mfem::Vector result(left);
result -= right;
return result.Norml2();
}
} // namespace
TEST_CASE("Two-group coupling policies reproduce their complete nonsymmetric factorizations", "[preconditioner][coupling][unit]")
{
const backend::Inverse first{.action = matrix(2, 2, {0.5, 0.0, 0.0, 0.25})};
const backend::Inverse second{.action = matrix(2, 2, {0.2, 0.0, 0.0, 0.1})};
const backend::TwoBlockCouplings couplings{.first_from_second = matrix(2, 2, {1, 2, -1, 3}), .second_from_first = matrix(2, 2, {3, -2, 4, 1})};
const std::array orders{CouplingOrder::independent, CouplingOrder::second_then_first, CouplingOrder::lower_diagonal_upper};
const std::array systems{
matrix(4, 4, {2, 0, 0, 0, 0, 4, 0, 0, 0, 0, 5, 0, 0, 0, 0, 10}),
matrix(4, 4, {2, 0, 1, 2, 0, 4, -1, 3, 0, 0, 5, 0, 0, 0, 0, 10}),
matrix(4, 4, {2, 0, 1, 2, 0, 4, -1, 3, 3, -2, 7, 1.5, 4, 1, 1.75, 14.75})};
mfem::Vector rhs(4), actual(4), expected(4);
rhs[0] = 1.0;
rhs[1] = -2.0;
rhs[2] = 3.0;
rhs[3] = 4.0;
for (std::size_t policy = 0; policy < orders.size(); ++policy)
{
CAPTURE(policy);
auto inverse = backend::try_coupled_inverse(first, second, couplings, orders[policy]);
REQUIRE(inverse);
CHECK(inverse->contract == ApplicationContract::fixed_linear);
mfem::DenseMatrixInverse exact(*systems[policy]);
exact.Mult(rhs, expected);
double* const storage = actual.GetData();
inverse->action->Mult(rhs, actual);
CHECK(actual.GetData() == storage);
CHECK(difference(actual, expected) < 2.0e-13);
mfem::Vector aliased(rhs);
inverse->action->Mult(aliased, aliased);
CHECK(difference(aliased, expected) < 2.0e-13);
inverse->action->Mult(rhs, actual);
CHECK(difference(actual, expected) < 2.0e-13);
}
}
TEST_CASE("A matrix-free Schur action shares the eliminated-field orientation", "[preconditioner][coupling][unit]")
{
const backend::Inverse eliminated{.action = matrix(2, 2, {0.5, 0.0, 0.0, 0.25})};
const backend::TwoBlockCouplings couplings{.first_from_second = matrix(2, 2, {1, 2, -1, 3}), .second_from_first = matrix(2, 2, {3, -2, 4, 1})};
auto schur = backend::try_schur_operator(matrix(2, 2, {7, 1.5, 1.75, 14.75}), eliminated, couplings);
REQUIRE(schur);
mfem::Vector values(2);
values[0] = 3.0;
values[1] = -2.0;
(*schur)->Mult(values, values);
CHECK(values[0] == Catch::Approx(15.0).margin(2.0e-14));
CHECK(values[1] == Catch::Approx(-20.0).margin(2.0e-14));
backend::Inverse variable = eliminated;
variable.contract = ApplicationContract::variable;
CHECK_FALSE(backend::try_schur_operator(matrix(2, 2, {7, 1.5, 1.75, 14.75}), variable, couplings));
}
TEST_CASE("Coupled inverse validation rejects unsupported policies and preserves outputs", "[preconditioner][coupling][validation]")
{
using serif::utils::errors::SERiFErrorCode;
const backend::Inverse identity{.action = matrix(1, 1, {1.0})};
const backend::TwoBlockCouplings full{.first_from_second = matrix(1, 1, {2.0}), .second_from_first = matrix(1, 1, {3.0})};
CHECK(backend::try_coupled_inverse(identity, identity, {}, CouplingOrder::independent));
CHECK(backend::try_coupled_inverse(identity, identity, {.first_from_second = full.first_from_second}, CouplingOrder::second_then_first));
auto missing = backend::try_coupled_inverse({}, identity, full, CouplingOrder::independent);
REQUIRE_FALSE(missing);
CHECK(missing.error().code == SERiFErrorCode::PRECONDITIONER_INVALID_OPERATOR);
auto incompatible = backend::try_coupled_inverse(identity, identity, {}, CouplingOrder::lower_diagonal_upper);
REQUIRE_FALSE(incompatible);
CHECK(incompatible.error().code == SERiFErrorCode::PRECONDITIONER_SIZE_MISMATCH);
auto unknown = backend::try_coupled_inverse(identity, identity, full, static_cast<CouplingOrder>(99));
REQUIRE_FALSE(unknown);
CHECK(unknown.error().code == SERiFErrorCode::PRECONDITIONER_INVALID_OPTIONS);
backend::Inverse variable = identity;
variable.contract = ApplicationContract::variable;
auto composed = backend::try_coupled_inverse(identity, variable, {}, CouplingOrder::independent);
REQUIRE(composed);
CHECK(composed->contract == ApplicationContract::variable);
mfem::Vector rhs(2), output(2), short_rhs(1);
rhs = 1.0;
output = 7.0;
CHECK_THROWS(composed->action->Mult(short_rhs, output));
CHECK(output[0] == 7.0);
CHECK(output[1] == 7.0);
rhs[1] = std::numeric_limits<double>::quiet_NaN();
CHECK_THROWS(composed->action->Mult(rhs, output));
CHECK(output[0] == 7.0);
CHECK(output[1] == 7.0);
}
TEST_CASE("Selected operators preserve distinct equation and unknown orders and their transpose", "[preconditioner][coupling][coordinates]")
{
const auto source = matrix(3, 4, {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12});
const std::array rows{2, 0};
const std::array columns{3, 1};
auto selected = backend::try_restrict_operator(source, rows, columns);
REQUIRE(selected);
mfem::Vector values(2);
values[0] = 2.0;
values[1] = -1.0;
(*selected)->Mult(values, values);
CHECK(values[0] == 14.0);
CHECK(values[1] == 6.0);
values[0] = 2.0;
values[1] = -1.0;
(*selected)->MultTranspose(values, values);
CHECK(values[0] == 20.0);
CHECK(values[1] == 18.0);
CHECK_FALSE(backend::try_restrict_operator(source, std::array{2, 2}, columns));
CHECK_FALSE(backend::try_restrict_operator(source, std::array{3}, columns));
CHECK_FALSE(backend::try_restrict_operator(source, rows, std::array{-1}));
}

View File

@@ -0,0 +1,197 @@
#include <array>
#include <memory>
#include <span>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "serif/backend/mfem/operators/actions.hpp"
#include "serif/solver/preconditioner/preconditioner.hpp"
#include "serif/backend/mfem/solver/normalization/operators.hpp"
#include "serif/utils/dependencies/computation_cache.hpp"
namespace
{
namespace pc = serif::solver::preconditioner;
namespace dependencies = serif::utils::dependencies;
namespace operators = serif::operators::backend::mfem;
namespace inverse = pc::strategies::inverse;
namespace coupling = pc::strategies::coupling;
using Inverse = pc::backend::mfem::Inverse;
using Code = serif::utils::errors::SERiFErrorCode;
struct Temperature
{
};
struct HeatBalance
{
};
struct Density
{
};
struct MassConstraint
{
};
struct MassParameter
{
};
struct Potential
{
};
struct Gradient
{
};
/** @brief Construct owned matrices independently of the preconditioner factorizations. */
std::shared_ptr<const mfem::DenseMatrix> matrix(const int rows, const int columns, std::initializer_list<double> values)
{
auto result = std::make_shared<mfem::DenseMatrix>(rows, columns);
auto value = values.begin();
for (int row = 0; row < rows; ++row)
{
for (int column = 0; column < columns; ++column)
{
(*result)(row, column) = *value++;
}
}
return result;
}
/** @brief Apply using preallocated MFEM coordinates, retaining the factory's zero-guess contract. */
mfem::Vector apply(const Inverse& inverse, std::initializer_list<double> values)
{
mfem::Vector input(values.size());
std::copy(values.begin(), values.end(), input.GetData());
mfem::Vector output(inverse.action->Height());
output = 91.0;
inverse.action->Mult(input, output);
return output;
}
} // namespace
TEST_CASE("A physical inverse graph reuses accepted results and preserves a frozen solve after failed preparation", "[unit][preconditioner][graph]")
{
const auto choice = pc::make_preconditioner<Temperature, HeatBalance>(inverse::Diagonal{});
using Recipe = decltype(choice);
using Block = pc::keys::Block<Recipe::Rows, Recipe::Values>;
auto physical = operators::linearized_block(matrix(2, 2, {2.0, 0.0, 0.0, -4.0}));
const auto context = dependencies::make_dependency_context(dependencies::borrow<Block>(physical));
auto cache = dependencies::make_computation_cache<pc::Failure>(pc::make_preconditioner_graph(choice), context);
REQUIRE(cache.try_prepare(context));
auto frozen = cache.share<Recipe::Result>();
CHECK(apply(*frozen, {6.0, 8.0})[0] == Catch::Approx(3.0));
CHECK(apply(*frozen, {6.0, 8.0})[1] == Catch::Approx(-2.0));
const auto reused = cache.try_prepare(context);
REQUIRE(reused);
CHECK(reused->computed_count() == 0);
CHECK(cache.share<Recipe::Result>() == frozen);
physical = operators::linearized_block(matrix(2, 2, {0.0, 0.0, 0.0, -4.0}));
const auto rejected = cache.try_prepare(context);
REQUIRE_FALSE(rejected);
CHECK(rejected.error().error.code == Code::PRECONDITIONER_SINGULAR_OPERATOR);
CHECK_FALSE(cache.ready());
CHECK_THROWS(cache.share<Recipe::Result>());
CHECK(apply(*frozen, {6.0, 8.0})[0] == Catch::Approx(3.0));
physical = operators::linearized_block(matrix(2, 2, {3.0, 0.0, 0.0, -8.0}));
REQUIRE(cache.try_prepare(context));
CHECK(cache.share<Recipe::Result>() != frozen);
CHECK(apply(cache.get<Recipe::Result>(), {6.0, 8.0})[0] == Catch::Approx(2.0));
cache.invalidate();
CHECK(apply(*frozen, {6.0, 8.0})[1] == Catch::Approx(-2.0));
}
TEST_CASE("Composed inverse graphs obey separate physical state and equation orders", "[unit][preconditioner][graph]")
{
using Values = dependencies::KeyList<Density, Temperature>;
using Rows = dependencies::KeyList<HeatBalance, Density>;
const serif::discretization::blocks::SystemLayout<Values, Rows> layout{.state_sizes = {1, 2}, .residual_sizes = {2, 1}};
const auto heat = pc::make_preconditioner<Temperature, HeatBalance>(inverse::Diagonal{});
const auto density = pc::make_preconditioner<Density>(inverse::Diagonal{});
// A zero cross block makes the triangular coupling act blockwise, isolating the coordinate orders.
const auto choice = pc::couple(heat, density, coupling::SecondThenFirst{});
const auto context = dependencies::make_dependency_context(
pc::physical_block<HeatBalance, Temperature>(operators::linearized_block(matrix(2, 2, {2.0, 0.0, 0.0, 4.0}))),
pc::physical_block<Density, Density>(operators::linearized_block(matrix(1, 1, {5.0}))),
pc::physical_block<HeatBalance, Density>(operators::linearized_block(matrix(2, 1, {0.0, 0.0}))),
dependencies::borrow<pc::keys::Layout>(layout));
auto cache = dependencies::make_computation_cache<pc::Failure>(pc::make_preconditioner_graph(choice, layout), context);
REQUIRE(cache.try_prepare(context));
const auto result = apply(cache.get<pc::keys::Preconditioner>(), {6.0, 8.0, 20.0});
CHECK(result[0] == Catch::Approx(4.0));
CHECK(result[1] == Catch::Approx(3.0));
CHECK(result[2] == Catch::Approx(2.0));
}
TEST_CASE("Constraint graph refresh follows child inverse changes without requiring a new root Jacobian", "[unit][preconditioner][graph]")
{
using D = dependencies::KeyList<Density>;
using R = dependencies::KeyList<MassConstraint>;
using V = dependencies::KeyList<MassParameter>;
using Constraints = pc::ConstraintCoordinates<R, V>;
const auto field_choice = pc::make_preconditioner<Density>(inverse::Diagonal{});
const auto choice = pc::with_constraints<Constraints>(field_choice);
using Recipe = decltype(choice);
auto fields = operators::linearized_block(matrix(1, 1, {2.0}));
const auto context = dependencies::make_dependency_context(
dependencies::borrow<pc::keys::Block<D, D>>(fields),
dependencies::own<pc::keys::Block<D, V>>(operators::linearized_block(matrix(1, 1, {1.0}))),
dependencies::own<pc::keys::Block<R, D>>(operators::linearized_block(matrix(1, 1, {3.0}))),
dependencies::own<pc::keys::Block<R, V>>(operators::linearized_block(matrix(1, 1, {4.0}))));
auto cache = dependencies::make_computation_cache<pc::Failure>(pc::make_preconditioner_graph(choice), context);
REQUIRE(cache.try_prepare(context));
auto old = cache.share<Recipe::Result>();
const auto old_result = apply(*old, {7.0, 15.0});
CHECK(old_result[0] == Catch::Approx(2.6));
CHECK(old_result[1] == Catch::Approx(1.8));
fields = operators::linearized_block(matrix(1, 1, {4.0}));
auto refreshed = cache.try_prepare(context);
REQUIRE(refreshed);
CHECK(refreshed->status<Recipe::Result>() == dependencies::PreparationStatus::computed);
const auto result = apply(cache.get<Recipe::Result>(), {7.0, 15.0});
CHECK(4.0 * result[0] + result[1] == Catch::Approx(7.0));
CHECK(3.0 * result[0] + 4.0 * result[1] == Catch::Approx(15.0));
CHECK(apply(*old, {7.0, 15.0})[0] == Catch::Approx(old_result[0]));
}
TEST_CASE("An incompatible physical representation returns a preparation error", "[unit][preconditioner][graph]")
{
const auto choice = pc::make_preconditioner<Density>(inverse::Diagonal{});
const auto context = dependencies::make_dependency_context(pc::physical_block<Density, Density>(operators::LinearizedBlock(matrix(1, 1, {2.0}))));
auto cache = dependencies::make_computation_cache<pc::Failure>(pc::make_preconditioner_graph(choice), context);
const auto result = cache.try_prepare(context);
REQUIRE_FALSE(result);
CHECK(result.error().error.code == Code::PRECONDITIONER_INCOMPATIBLE_STRATEGY);
}
TEST_CASE("MFEM adapters bind accepted Jacobians and reuse the existing normalization transform", "[unit][preconditioner][graph]")
{
namespace normalization = serif::solver::normalization;
auto physical = matrix(2, 2, {2.0, 0.0, 0.0, 4.0});
const auto inverse = pc::backend::mfem::try_dense_inverse(*physical);
REQUIRE(inverse);
pc::backend::mfem::Solver solver(*inverse, physical);
mfem::Vector input(2), output(2), wrong_size(1);
input = 2.0;
CHECK_THROWS_WITH(solver.Mult(wrong_size, output), Catch::Matchers::ContainsSubstring("PRECONDITIONER_SIZE_MISMATCH"));
CHECK_THROWS_WITH(solver.Mult(input, wrong_size), Catch::Matchers::ContainsSubstring("PRECONDITIONER_SIZE_MISMATCH"));
solver.Mult(input, output);
CHECK(output[0] == Catch::Approx(1.0));
CHECK(output[1] == Catch::Approx(0.5));
CHECK_NOTHROW(solver.SetOperator(*physical));
const auto other = matrix(2, 2, {2.0, 0.0, 0.0, 4.0});
CHECK_THROWS(solver.SetOperator(*other));
const auto scaling = normalization::make_diagonal_normalization(std::vector<double>{3.0, 5.0}, std::vector<double>{7.0, 11.0});
REQUIRE(scaling);
normalization::backend::mfem::ScaledJacobianOperator jacobian(*physical, *scaling);
normalization::backend::mfem::ScaledPreconditioner scaled(solver, jacobian);
mfem::Vector state(2), residual(2), recovered(2);
state[0] = 2.0;
state[1] = -3.0;
jacobian.Mult(state, residual);
scaled.Mult(residual, recovered);
CHECK(recovered[0] == Catch::Approx(state[0]));
CHECK(recovered[1] == Catch::Approx(state[1]));
}

View File

@@ -0,0 +1,101 @@
#include <array>
#include <expected>
#include <memory>
#include <type_traits>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/solver/preconditioner/preconditioner.hpp"
#include "serif/utils/dependencies/computation_cache.hpp"
namespace
{
namespace pc = serif::solver::preconditioner;
namespace dependencies = serif::utils::dependencies;
namespace physical = serif::operators::backend::mfem;
using Code = serif::utils::errors::SERiFErrorCode;
struct Density
{
};
struct DensityEquation
{
};
struct PhysicalScale
{
};
struct PreparedScale
{
};
using DensityKeys = dependencies::KeyList<Density>;
using DensityRows = dependencies::KeyList<DensityEquation>;
using DensityBlock = pc::keys::Block<DensityRows, DensityKeys>;
} // namespace
TEST_CASE("Explicit upstream computations retain move-only owners and share ordinary preconditioner invalidation", "[unit][preconditioner][graph]")
{
using Layout = serif::discretization::blocks::SystemLayout<DensityKeys, DensityRows>;
const Layout layout{.state_sizes = {2}, .residual_sizes = {2}};
const auto choice = pc::make_preconditioner<Density, DensityEquation>(pc::strategies::inverse::Diagonal{});
auto prepared_block = dependencies::make_computation<DensityBlock, dependencies::InputList<PreparedScale>>(
[coefficients = std::make_unique<std::array<double, 2>>(std::array<double, 2>{2.0, 3.0})](double scale) -> std::expected<physical::LinearizedBlock, pc::Failure>
{
if (scale <= 0.0)
{
return std::unexpected(pc::Failure{Code::PRECONDITIONER_INVALID_CALIBRATION, "The upstream physical scale must be positive."});
}
auto matrix = std::make_shared<mfem::DenseMatrix>(2);
*matrix = 0.0;
(*matrix)(0, 0) = (*coefficients)[0] * scale;
(*matrix)(1, 1) = (*coefficients)[1] * scale;
return physical::linearized_block(std::move(matrix));
});
static_assert(!std::copy_constructible<decltype(prepared_block)>);
auto prepared_scale = dependencies::make_computation<PreparedScale, dependencies::InputList<PhysicalScale>>([](double scale)
{
return 2.0 * scale;
});
// The explicitly supplied nodes are deliberately in the opposite order from their numerical dependency.
auto graph = pc::make_preconditioner_graph(choice, layout, std::move(prepared_block), std::move(prepared_scale));
double physical_scale = 1.0;
const auto context = dependencies::make_dependency_context(dependencies::borrow<PhysicalScale>(physical_scale), dependencies::borrow<pc::keys::Layout>(layout));
auto cache = dependencies::make_computation_cache<pc::Failure>(std::move(graph), context);
REQUIRE(cache.try_prepare(context));
const auto accepted = cache.share<pc::keys::Preconditioner>();
mfem::Vector rhs(2), result(2);
rhs[0] = 6.0;
rhs[1] = 12.0;
accepted->action->Mult(rhs, result);
CHECK(result[0] == Catch::Approx(1.5));
CHECK(result[1] == Catch::Approx(2.0));
const auto reused = cache.try_prepare(context);
REQUIRE(reused);
CHECK(reused->computed_count() == 0);
CHECK(cache.share<pc::keys::Preconditioner>() == accepted);
physical_scale = 2.0;
const auto changed = cache.try_prepare(context);
REQUIRE(changed);
CHECK(changed->status<PreparedScale>() == dependencies::PreparationStatus::computed);
CHECK(changed->status<DensityBlock>() == dependencies::PreparationStatus::computed);
CHECK(changed->status<pc::keys::Preconditioner>() == dependencies::PreparationStatus::computed);
cache.get<pc::keys::Preconditioner>().action->Mult(rhs, result);
CHECK(result[0] == Catch::Approx(0.75));
CHECK(result[1] == Catch::Approx(1.0));
physical_scale = 0.0;
const auto rejected = cache.try_prepare(context);
REQUIRE_FALSE(rejected);
CHECK(rejected.error().error.code == Code::PRECONDITIONER_INVALID_CALIBRATION);
CHECK(rejected.error().error.message == "The upstream physical scale must be positive.");
CHECK_FALSE(cache.ready());
accepted->action->Mult(rhs, result);
CHECK(result[0] == Catch::Approx(1.5));
CHECK(result[1] == Catch::Approx(2.0));
physical_scale = 3.0;
REQUIRE(cache.try_prepare(context));
cache.get<pc::keys::Preconditioner>().action->Mult(rhs, result);
CHECK(result[0] == Catch::Approx(0.5));
CHECK(result[1] == Catch::Approx(2.0 / 3.0));
}

View File

@@ -0,0 +1,219 @@
#include <algorithm>
#include <array>
#include <expected>
#include <initializer_list>
#include <memory>
#include <type_traits>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/solver/preconditioner/preconditioner.hpp"
#include "serif/utils/dependencies/computation_cache.hpp"
namespace
{
namespace pc = serif::solver::preconditioner;
namespace dependencies = serif::utils::dependencies;
namespace physical = serif::operators::backend::mfem;
namespace coupling = pc::strategies::coupling;
using Inverse = pc::backend::mfem::Inverse;
using Code = serif::utils::errors::SERiFErrorCode;
struct Density
{
};
struct Temperature
{
};
struct HeatBalance
{
};
using DensityKeys = dependencies::KeyList<Density>;
using HeatKeys = dependencies::KeyList<HeatBalance>;
using TemperatureKeys = dependencies::KeyList<Temperature>;
using DensityBlock = pc::keys::Block<DensityKeys, DensityKeys>;
using HeatBlock = pc::keys::Block<HeatKeys, TemperatureKeys>;
/** @brief Create independently owned diagonal or one-entry physical matrices. */
[[nodiscard]] std::shared_ptr<const mfem::DenseMatrix> diagonal(std::initializer_list<double> values)
{
auto matrix = std::make_shared<mfem::DenseMatrix>(values.size());
*matrix = 0.0;
int index = 0;
for (double value : values)
{
(*matrix)(index, index) = value;
++index;
}
return matrix;
}
/** @brief Apply a compiled inverse without leaving initialization to the implementation. */
[[nodiscard]] mfem::Vector apply(const Inverse& inverse, std::initializer_list<double> coefficients)
{
mfem::Vector input(coefficients.size());
std::copy(coefficients.begin(), coefficients.end(), input.GetData());
mfem::Vector result(inverse.action->Height());
result = 17.0;
inverse.action->Mult(input, result);
return result;
}
/** @brief A custom strategy's reported success must still satisfy the physical block contract. */
struct CustomInverse final
{
std::expected<Inverse, pc::Failure> result;
/** @brief Return a chosen result so malformed successful declarations can be tested independently. */
[[nodiscard]] std::expected<Inverse, pc::Failure> try_inverse(const physical::LinearizedBlock&) const
{
return result;
}
};
} // namespace
TEST_CASE("Shared physical layouts reject incorrect constituent sizes even when packed totals agree", "[unit][preconditioner][graph]")
{
using Layout = serif::discretization::blocks::SystemLayout<dependencies::KeyList<Density, Temperature>, dependencies::KeyList<HeatBalance, Density>>;
Layout layout{.state_sizes = {1, 2}, .residual_sizes = {2, 1}};
const auto choice = pc::couple(pc::make_preconditioner<Temperature, HeatBalance>(pc::strategies::inverse::Diagonal{}),
pc::make_preconditioner<Density>(pc::strategies::inverse::Diagonal{}), coupling::SecondThenFirst{});
const auto context = dependencies::make_dependency_context(
dependencies::own<HeatBlock>(physical::linearized_block(diagonal({2.0, 4.0}))),
dependencies::own<DensityBlock>(physical::linearized_block(diagonal({5.0}))),
dependencies::own<pc::keys::Block<HeatKeys, DensityKeys>>(physical::linearized_block(std::make_shared<::mfem::DenseMatrix>(2, 1))),
dependencies::borrow<pc::keys::Layout>(layout));
auto cache = dependencies::make_computation_cache<pc::Failure>(pc::make_preconditioner_graph(choice, layout), context);
REQUIRE(cache.try_prepare(context));
const auto accepted = cache.share<pc::keys::Preconditioner>();
SECTION("Unknown block counts are not interchangeable")
{
layout.state_sizes = {2, 1};
}
SECTION("Equation block counts are checked independently")
{
layout.residual_sizes = {1, 2};
}
SECTION("Matching wrong row and column partitions do not hide a physical mismatch")
{
layout.state_sizes = {2, 1};
layout.residual_sizes = {1, 2};
}
const auto rejected = cache.try_prepare(context);
REQUIRE_FALSE(rejected);
CHECK(rejected.error().error.code == Code::PRECONDITIONER_SIZE_MISMATCH);
CHECK_FALSE(cache.ready());
CHECK_THROWS(cache.share<pc::keys::Preconditioner>());
const auto old = apply(*accepted, {6.0, 8.0, 20.0});
CHECK(old[0] == Catch::Approx(4.0));
CHECK(old[1] == Catch::Approx(3.0));
CHECK(old[2] == Catch::Approx(2.0));
layout.state_sizes = {1, 2};
layout.residual_sizes = {2, 1};
const auto recovered = cache.try_prepare(context);
REQUIRE(recovered);
CHECK(recovered->status<pc::keys::Inverse<HeatKeys, TemperatureKeys>>() == dependencies::PreparationStatus::reused);
CHECK(recovered->status<pc::keys::Inverse<DensityKeys, DensityKeys>>() == dependencies::PreparationStatus::reused);
}
TEST_CASE("Triangular preconditioner graphs require and track only their used cross coupling", "[unit][preconditioner][graph]")
{
using Required = pc::keys::Block<DensityKeys, TemperatureKeys>;
using Unused = pc::keys::Block<HeatKeys, DensityKeys>;
const auto choice = pc::couple(pc::make_preconditioner<Density>(pc::strategies::inverse::Diagonal{}),
pc::make_preconditioner<Temperature, HeatBalance>(pc::strategies::inverse::Diagonal{}), coupling::SecondThenFirst{});
const auto graph = pc::make_preconditioner_graph(choice);
using Graph = decltype(graph);
using Result = decltype(choice)::Result;
static_assert(dependencies::ContainsKey<Required, typename Graph::Inputs>);
static_assert(!dependencies::ContainsKey<Unused, typename Graph::Inputs>);
static_assert(dependencies::ContainsKey<DensityBlock, typename Graph::Inputs>);
static_assert(dependencies::ContainsKey<HeatBlock, typename Graph::Inputs>);
auto used = physical::linearized_block(diagonal({4.0}));
const auto context = dependencies::make_dependency_context(
dependencies::own<DensityBlock>(physical::linearized_block(diagonal({2.0}))),
dependencies::own<HeatBlock>(physical::linearized_block(diagonal({3.0}))), dependencies::borrow<Required>(used));
auto cache = dependencies::make_computation_cache<pc::Failure>(graph, context);
REQUIRE(cache.try_prepare(context));
const auto result = apply(cache.get<Result>(), {6.0, 12.0});
CHECK(result[0] == Catch::Approx(-5.0));
CHECK(result[1] == Catch::Approx(4.0));
auto ignored = physical::linearized_block(diagonal({99.0}));
const auto extended = dependencies::make_dependency_context(
dependencies::own<DensityBlock>(physical::linearized_block(diagonal({2.0}))),
dependencies::own<HeatBlock>(physical::linearized_block(diagonal({3.0}))),
dependencies::borrow<Required>(used), dependencies::borrow<Unused>(ignored));
auto extended_cache = dependencies::make_computation_cache<pc::Failure>(graph, extended);
REQUIRE(extended_cache.try_prepare(extended));
const auto accepted = extended_cache.share<Result>();
ignored = physical::linearized_block(diagonal({-13.0}));
const auto reused = extended_cache.try_prepare(extended);
REQUIRE(reused);
CHECK(reused->computed_count() == 0);
CHECK(extended_cache.share<Result>() == accepted);
used = physical::linearized_block(diagonal({2.0}));
const auto refreshed = extended_cache.try_prepare(extended);
REQUIRE(refreshed);
CHECK(refreshed->source_changed<Required>());
CHECK(refreshed->status<Result>() == dependencies::PreparationStatus::computed);
CHECK(refreshed->status<pc::keys::Inverse<DensityKeys, DensityKeys>>() == dependencies::PreparationStatus::reused);
CHECK(refreshed->status<pc::keys::Inverse<HeatKeys, TemperatureKeys>>() == dependencies::PreparationStatus::reused);
const auto updated = apply(extended_cache.get<Result>(), {6.0, 12.0});
CHECK(updated[0] == Catch::Approx(-1.0));
CHECK(updated[1] == Catch::Approx(4.0));
CHECK(apply(*accepted, {6.0, 12.0})[0] == Catch::Approx(result[0]));
CHECK(apply(*accepted, {6.0, 12.0})[1] == Catch::Approx(result[1]));
}
TEST_CASE("Custom inverse graph results must preserve the selected physical action contract", "[unit][preconditioner][graph]")
{
std::expected<Inverse, pc::Failure> result = Inverse{.action = diagonal({0.5, 0.25})};
Code expected_code = Code::PRECONDITIONER_INVALID_OPERATOR;
SECTION("Successful preparation cannot publish a null inverse")
{
result->action.reset();
}
SECTION("An inverse must reverse the selected physical dimensions")
{
result->action = diagonal({0.5});
expected_code = Code::PRECONDITIONER_SIZE_MISMATCH;
}
SECTION("The declared application contract must be valid")
{
result->contract = static_cast<pc::ApplicationContract>(99);
expected_code = Code::PRECONDITIONER_INVALID_OPTIONS;
}
SECTION("An originating custom failure propagates unchanged")
{
expected_code = Code::PRECONDITIONER_INVALID_CALIBRATION;
result = std::unexpected(pc::Failure{expected_code, "Custom calibration failed once."});
}
const auto choice = pc::make_preconditioner<Temperature, HeatBalance>(CustomInverse{result});
const auto context = dependencies::make_dependency_context(dependencies::own<HeatBlock>(physical::linearized_block(diagonal({2.0, 4.0}))));
auto cache = dependencies::make_computation_cache<pc::Failure>(pc::make_preconditioner_graph(choice), context);
const auto rejected = cache.try_prepare(context);
REQUIRE_FALSE(rejected);
CHECK(rejected.error().error.code == expected_code);
if (expected_code == Code::PRECONDITIONER_INVALID_CALIBRATION)
{
CHECK(rejected.error().error.message == "Custom calibration failed once.");
}
}
TEST_CASE("Empty physical constraint lists reuse the field recipe and accepted inverse", "[unit][preconditioner][graph]")
{
const auto fields = pc::make_preconditioner<Density>(pc::strategies::inverse::Diagonal{});
using Empty = dependencies::KeyList<>;
const auto choice = pc::with_constraints<pc::ConstraintCoordinates<Empty, Empty>>(fields);
static_assert(std::same_as<decltype(fields), decltype(choice)>);
const auto context = dependencies::make_dependency_context(dependencies::own<DensityBlock>(physical::linearized_block(diagonal({2.0}))));
auto cache = dependencies::make_computation_cache<pc::Failure>(pc::make_preconditioner_graph(choice), context);
REQUIRE(cache.try_prepare(context));
CHECK(apply(cache.get<typename decltype(choice)::Result>(), {8.0})[0] == Catch::Approx(4.0));
}

View File

@@ -0,0 +1,233 @@
#include <initializer_list>
#include <memory>
#include <array>
#include <cmath>
#include <limits>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/backend/mfem/solver/preconditioner/inverse/amg.hpp"
#include "serif/backend/mfem/solver/preconditioner/inverse/dense.hpp"
#include "serif/backend/mfem/solver/preconditioner/inverse/diagonal.hpp"
#ifdef MFEM_USE_MPI
#include "mfem/general/communication.hpp"
#endif
namespace
{
namespace preconditioner = serif::solver::preconditioner;
namespace backend = preconditioner::backend::mfem;
namespace options = preconditioner::strategies::inverse;
using Code = serif::utils::errors::SERiFErrorCode;
/** @brief Make small right-hand sides readable without coupling expectations to a numerical implementation. */
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 inverse actions at the original MeanField tolerances. */
void check_vector(const mfem::Vector& actual, const mfem::Vector& expected, const double tolerance = 2.0e-13)
{
REQUIRE(actual.Size() == expected.Size());
for (int index = 0; index < actual.Size(); ++index)
{
CHECK(actual[index] == Catch::Approx(expected[index]).epsilon(tolerance).margin(tolerance));
}
}
/** @brief Reuse one positive definite mass fixture for polynomial linearity and frozen ownership. */
std::shared_ptr<mfem::DenseMatrix> mass_matrix()
{
auto matrix = std::make_shared<mfem::DenseMatrix>(3);
*matrix = 0.0;
(*matrix)(0, 0) = 4.0;
(*matrix)(1, 1) = 3.0;
(*matrix)(2, 2) = 2.0;
(*matrix)(0, 1) = (*matrix)(1, 0) = 1.0;
(*matrix)(1, 2) = (*matrix)(2, 1) = 1.0;
return matrix;
}
/** @brief Check the fixed-map contract independently of a particular inverse algorithm. */
void check_fixed_linear(const backend::Inverse& inverse, const mfem::Vector& x, const mfem::Vector& y, const double tolerance)
{
REQUIRE(inverse.contract == preconditioner::ApplicationContract::fixed_linear);
mfem::Vector px(x.Size()), py(x.Size()), actual(x.Size()), expected(x.Size()), combination(x);
inverse.action->Mult(x, px);
inverse.action->Mult(y, py);
combination *= 1.7;
combination.Add(-0.4, y);
actual = std::numeric_limits<double>::quiet_NaN();
const auto* storage = actual.GetData();
inverse.action->Mult(combination, actual);
CHECK(actual.GetData() == storage);
expected = px;
expected *= 1.7;
expected.Add(-0.4, py);
check_vector(actual, expected, tolerance);
inverse.action->Mult(x, actual);
check_vector(actual, px, 0.0);
combination = 0.0;
inverse.action->Mult(combination, actual);
CHECK(actual.Norml2() == 0.0);
actual = x;
inverse.action->Mult(actual, actual);
check_vector(actual, px, tolerance);
mfem::Vector overlapping_storage(x.Size() + 1);
for (const int shift : {0, 1})
{
mfem::Vector overlapping_input(overlapping_storage.GetData() + shift, x.Size());
mfem::Vector overlapping_output(overlapping_storage.GetData() + 1 - shift, x.Size());
overlapping_input = x;
inverse.action->Mult(overlapping_input, overlapping_output);
check_vector(overlapping_output, px, tolerance);
}
}
} // namespace
TEST_CASE("Signed diagonal inverses own their factors and reject unrepresentable reciprocals", "[preconditioner][inverse][unit]")
{
std::vector<double> diagonal{2.0, -4.0, 0.5};
const auto inverse = backend::try_diagonal_inverse(diagonal);
REQUIRE(inverse);
diagonal.assign(3, 77.0);
mfem::Vector action(3);
inverse->action->Mult(vector({4.0, 8.0, -1.0}), action);
check_vector(action, vector({2.0, -2.0, -2.0}));
inverse->action->MultTranspose(vector({4.0, 8.0, -1.0}), action);
check_vector(action, vector({2.0, -2.0, -2.0}));
check_fixed_linear(*inverse, vector({1.0, -2.0, 0.25}), vector({-0.5, 0.75, 3.0}), 2.0e-13);
for (const double bad : {0.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN(), std::numeric_limits<double>::denorm_min()})
{
CHECK_FALSE(backend::try_diagonal_inverse(std::array{bad}));
}
CHECK_FALSE(backend::try_diagonal_inverse({}));
}
TEST_CASE("Dense inverse retains signed pivoted solves transpose and independent snapshots", "[preconditioner][inverse][unit]")
{
mfem::DenseMatrix matrix(2);
matrix(0, 0) = 4.0;
matrix(0, 1) = 1.0;
matrix(1, 0) = 2.0;
matrix(1, 1) = 3.0;
const auto inverse = backend::try_dense_inverse(matrix);
REQUIRE(inverse);
matrix = 0.0;
matrix(0, 0) = 2.0;
matrix(1, 1) = 4.0;
const auto replacement = backend::try_dense_inverse(matrix);
REQUIRE(replacement);
mfem::Vector action(2);
inverse->action->Mult(vector({7.0, 1.0}), action);
check_vector(action, vector({2.0, -1.0}));
inverse->action->MultTranspose(vector({6.0, -1.0}), action);
check_vector(action, vector({2.0, -1.0}));
replacement->action->Mult(vector({7.0, 1.0}), action);
check_vector(action, vector({3.5, 0.25}));
check_fixed_linear(*inverse, vector({1.0, -2.0}), vector({-0.5, 0.75}), 2.0e-13);
}
TEST_CASE("Dense conditioning uses scale-independent infinity norms and recoverable failures", "[preconditioner][inverse][unit]")
{
for (const double scale : {1.0e-250, 1.0, 1.0e250})
{
mfem::DenseMatrix matrix(2);
matrix(0, 0) = 0.0;
matrix(0, 1) = 2.0 * scale;
matrix(1, 0) = 3.0 * scale;
matrix(1, 1) = scale;
const auto inverse = backend::try_dense_inverse(matrix, {.minimum_reciprocal_condition = 0.49});
REQUIRE(inverse);
CHECK(backend::try_dense_inverse(matrix, {.minimum_reciprocal_condition = 0.5}));
const auto ill_conditioned = backend::try_dense_inverse(matrix, {.minimum_reciprocal_condition = 0.51});
REQUIRE_FALSE(ill_conditioned);
CHECK(ill_conditioned.error().code == Code::PRECONDITIONER_ILL_CONDITIONED_OPERATOR);
mfem::Vector action(2);
inverse->action->Mult(vector({-2.0 * scale, 5.0 * scale}), action);
check_vector(action, vector({2.0, -1.0}));
}
mfem::DenseMatrix singular(2);
singular = 1.0;
const auto rejected = backend::try_dense_inverse(singular);
REQUIRE_FALSE(rejected);
CHECK(rejected.error().code == Code::PRECONDITIONER_SINGULAR_OPERATOR);
singular(0, 0) = std::numeric_limits<double>::quiet_NaN();
const auto nonfinite = backend::try_dense_inverse(singular);
REQUIRE_FALSE(nonfinite);
CHECK(nonfinite.error().code == Code::PRECONDITIONER_NONFINITE_VALUE);
CHECK_FALSE(backend::try_dense_inverse(singular, {.minimum_reciprocal_condition = -1.0}));
CHECK_FALSE(backend::try_dense_inverse(mfem::DenseMatrix(2, 3)));
mfem::DenseMatrix disparate(2);
disparate = 0.0;
disparate(0, 0) = 1.0e308;
disparate(1, 1) = 1.0e-308;
const auto disparate_inverse = backend::try_dense_inverse(disparate);
REQUIRE(disparate_inverse);
mfem::Vector action(2);
disparate_inverse->action->Mult(vector({1.0e308, -1.0e-308}), action);
check_vector(action, vector({1.0, -1.0}));
disparate_inverse->action->MultTranspose(vector({1.0e308, -1.0e-308}), action);
check_vector(action, vector({1.0, -1.0}));
CHECK_FALSE(backend::try_dense_inverse(disparate, {.minimum_reciprocal_condition = 1.0e-14}));
}
TEST_CASE("One-process BoomerAMG preserves fixed-cycle linearity", "[preconditioner][inverse][integration]")
{
auto matrix = std::make_shared<mfem::SparseMatrix>(32);
for (int row = 0; row < matrix->Height(); ++row)
{
matrix->Add(row, row, 2.0);
if (row > 0)
{
matrix->Add(row, row - 1, -1.0);
matrix->Add(row - 1, row, -1.0);
}
}
matrix->Finalize();
const auto zero = std::make_shared<mfem::SparseMatrix>(2);
zero->Add(0, 0, 0.0);
zero->Add(1, 1, 0.0);
zero->Finalize();
const auto unsupported = backend::try_amg_inverse(zero);
REQUIRE_FALSE(unsupported);
CHECK(unsupported.error().code == Code::PRECONDITIONER_INVALID_OPERATOR);
CHECK_FALSE(backend::try_amg_inverse(matrix, options::FixedAMG{.cycles = 0}));
#ifdef MFEM_USE_MPI
if (!mfem::Mpi::IsInitialized())
{
mfem::Mpi::Init();
}
const auto fixed = backend::try_amg_inverse(matrix, options::FixedAMG{.cycles = 2});
REQUIRE(fixed);
mfem::Vector exact(32), second(32), rhs(32), second_rhs(32), action(32), residual(32);
for (int index = 0; index < 32; ++index)
{
exact[index] = 1.0;
second[index] = 0.25 + index;
}
matrix->Mult(exact, rhs);
matrix->Mult(second, second_rhs);
check_fixed_linear(*fixed, rhs, second_rhs, 1.0e-11);
fixed->action->Mult(rhs, action);
matrix->Mult(action, residual);
residual -= rhs;
CHECK(residual.Norml2() < rhs.Norml2());
matrix.reset();
fixed->action->Mult(rhs, action);
CHECK(std::isfinite(action.Norml2()));
#else
const auto unavailable = backend::try_amg_inverse(matrix);
REQUIRE_FALSE(unavailable);
CHECK(unavailable.error().code == Code::PRECONDITIONER_INCOMPATIBLE_STRATEGY);
#endif
}

View File

@@ -0,0 +1,102 @@
#include <array>
#include <limits>
#include <catch2/catch_test_macros.hpp>
#include "serif/discretization/blocks/vector_view.hpp"
#include "serif/operators/linearized_system_view.hpp"
#include "serif/utils/dependencies/dependencies.hpp"
namespace
{
namespace blocks = serif::discretization::blocks;
namespace dependencies = serif::utils::dependencies;
using Kind = blocks::RepresentedValueKind;
struct Density
{
};
struct Shape
{
};
struct Gradient
{
};
struct Potential
{
};
struct Enthalpy
{
};
struct MassConstraint
{
};
struct CentralConstraint
{
};
using Values = dependencies::KeyList<Density, Shape, Gradient, Potential, Enthalpy, MassConstraint, CentralConstraint>;
using Residuals = dependencies::KeyList<Gradient, Potential, Density, Shape, Enthalpy, MassConstraint, CentralConstraint>;
using Layout = blocks::SystemLayout<Values, Residuals>;
} // namespace
TEST_CASE("System layout preserves every uneven physical block in independent state and residual orders", "[preconditioner][layout][unit]")
{
// The original equilibrium_coordinates.cpp fixture deliberately totals 22 on both sides with different orders.
const Layout layout{.state_sizes = {2, 3, 4, 5, 6, 1, 1}, .residual_sizes = {4, 5, 2, 3, 6, 1, 1}};
CHECK(layout.total_size<Kind::value>() == 22);
CHECK(layout.total_size<Kind::residual>() == 22);
CHECK(layout.offset<Kind::value, Density>() == 0);
CHECK(layout.offset<Kind::residual, Density>() == 9);
CHECK(layout.offset<Kind::value, Enthalpy>() == 14);
CHECK(layout.offset<Kind::residual, Enthalpy>() == 14);
CHECK(layout.offset<Kind::value, MassConstraint>() == 20);
CHECK(layout.offset<Kind::value, CentralConstraint>() == 21);
std::array<double, 22> values{}, residuals{};
for (std::size_t index = 0; index < values.size(); ++index)
{
values[index] = 100.0 + index;
residuals[index] = -200.0 - index;
}
const blocks::StateView<Layout> state(layout, values);
const blocks::ResidualView<Layout, const double> residual(layout, residuals);
CHECK(state.get<Density>().data() == values.data());
CHECK(state.get<Enthalpy>().data() == values.data() + 14);
CHECK(residual.get<Density>().data() == residuals.data() + 9);
CHECK(residual.get<Gradient>().data() == residuals.data());
state.get<MassConstraint>()[0] = 5.0;
state.get<CentralConstraint>()[0] = 7.0;
CHECK(values[20] == 5.0);
CHECK(values[21] == 7.0);
CHECK(residual.get<Density>()[0] == -209.0);
}
TEST_CASE("Block vector views reject mismatched storage and overflowing coordinate metadata", "[preconditioner][layout][validation]")
{
Layout layout{.state_sizes = {2, 3, 4, 5, 6, 1, 1}, .residual_sizes = {4, 5, 2, 3, 6, 1, 1}};
std::array<double, 21> short_values{};
CHECK_THROWS(blocks::StateView<Layout>(layout, short_values));
std::array<double, 22> values{};
blocks::StateView<Layout> state(layout, values);
layout.state_sizes[0] = 23;
CHECK_THROWS(state.get<Density>());
layout.state_sizes[0] = std::numeric_limits<std::size_t>::max();
CHECK_THROWS(layout.total_size<Kind::value>());
CHECK_THROWS(layout.offset<Kind::value, Gradient>());
CHECK_THROWS(blocks::StateView<Layout>(layout, values));
const blocks::SystemLayout<dependencies::KeyList<>, dependencies::KeyList<>> empty{};
CHECK(empty.total_size<Kind::value>() == 0);
CHECK(empty.total_size<Kind::residual>() == 0);
}
TEST_CASE("Linearized system views borrow the declared physical blocks without changing their identity", "[preconditioner][layout][unit]")
{
using DensityBlock = serif::operators::PhysicalBlock<Density, Density>;
using SurfaceBlock = serif::operators::PhysicalBlock<Shape, Enthalpy>;
const Layout layout{.state_sizes = {2, 3, 4, 5, 6, 1, 1}, .residual_sizes = {4, 5, 2, 3, 6, 1, 1}};
const auto sources = dependencies::make_dependency_context(dependencies::own<DensityBlock>(std::array{2.0, 3.0}), dependencies::own<SurfaceBlock>(std::array{5.0, 7.0}));
const serif::operators::LinearizedSystemView system(layout, sources);
CHECK(&system.layout() == &layout);
CHECK(&system.blocks() == &sources);
CHECK(&system.block<Density, Density>() == &sources.get<DensityBlock>());
CHECK(&system.block<Shape, Enthalpy>() == &sources.get<SurfaceBlock>());
}

View File

@@ -0,0 +1,89 @@
#include <array>
#include <memory>
#include <span>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "mfem/linalg/densemat.hpp"
#include "serif/backend/mfem/solver/preconditioner/coupling.hpp"
#include "serif/backend/mfem/solver/preconditioner/inverse/diagonal.hpp"
namespace
{
namespace backend = serif::solver::preconditioner::backend::mfem;
using CouplingOrder = serif::solver::preconditioner::strategies::coupling::CouplingOrder;
/** @brief Build a small independent physical block from row-major entries. */
[[nodiscard]] backend::OperatorHandle material_block(int rows, int columns, std::span<const double> entries)
{
auto matrix = std::make_shared<::mfem::DenseMatrix>(rows, columns);
for (int row = 0; row < rows; ++row)
{
for (int column = 0; column < columns; ++column)
{
(*matrix)(row, column) = entries[row * columns + column];
}
}
return matrix;
}
/** @brief Use the production diagonal inverse for each independently scaled material component. */
[[nodiscard]] backend::Inverse scalar_inverse(double diagonal)
{
const auto inverse = backend::try_diagonal_inverse(std::span<const double>{&diagonal, 1});
REQUIRE(inverse);
return *inverse;
}
} // namespace
TEST_CASE("Material surface policies compose the same two-block inverse with signed triangular algebra", "[unit][preconditioner][material]")
{
const backend::TwoBlockCouplings material_couplings{.first_from_second = material_block(1, 1, std::array{4.0})};
const backend::TwoBlockCouplings surface_couplings{.first_from_second = material_block(2, 1, std::array{3.0, 8.0}), .second_from_first = material_block(1, 2, std::array{5.0, 7.0})};
const auto material = backend::try_coupled_inverse(scalar_inverse(2.0), scalar_inverse(9.0), material_couplings, CouplingOrder::second_then_first);
REQUIRE(material);
const auto diagonal_material = backend::try_coupled_inverse(scalar_inverse(2.0), scalar_inverse(9.0), {}, CouplingOrder::independent);
REQUIRE(diagonal_material);
// Each subsystem keeps its own native order: material=(density, enthalpy), followed by surface.
::mfem::Vector right_hand_side(3), actual(3);
right_hand_side[0] = 29.0;
right_hand_side[1] = 43.0;
right_hand_side[2] = 44.0;
const double enthalpy = 43.0 / 9.0;
const double density = (29.0 - 4.0 * enthalpy) / 2.0;
const double surface = 44.0 / 6.0;
const double corrected_enthalpy = (43.0 - 8.0 * surface) / 9.0;
const double ldu_surface = (44.0 - 5.0 * density - 7.0 * enthalpy) / 6.0;
const double ldu_enthalpy = (43.0 - 8.0 * ldu_surface) / 9.0;
struct PolicyCase
{
backend::Inverse material;
CouplingOrder order;
std::array<double, 3> expected;
};
const std::array cases{
PolicyCase{*diagonal_material, CouplingOrder::independent, {14.5, enthalpy, surface}},
PolicyCase{*material, CouplingOrder::independent, {density, enthalpy, surface}},
PolicyCase{*material, CouplingOrder::second_then_first, {(29.0 - 3.0 * surface - 4.0 * corrected_enthalpy) / 2.0, corrected_enthalpy, surface}},
PolicyCase{*material, CouplingOrder::lower_diagonal_upper, {(29.0 - 3.0 * ldu_surface - 4.0 * ldu_enthalpy) / 2.0, ldu_enthalpy, ldu_surface}}};
for (const PolicyCase& policy : cases)
{
const auto inverse = backend::try_coupled_inverse(policy.material, scalar_inverse(6.0), surface_couplings, policy.order);
REQUIRE(inverse);
actual = 7.0;
inverse->action->Mult(right_hand_side, actual);
for (int index = 0; index < 3; ++index)
{
CHECK(actual[index] == Catch::Approx(policy.expected[index]).margin(2e-13));
}
::mfem::Vector zero(3);
zero = 0.0;
inverse->action->Mult(zero, actual);
CHECK(actual.Norml2() == 0.0);
}
const auto schur = backend::try_schur_operator(material_block(1, 1, std::array{6.0}), *material, surface_couplings);
REQUIRE(schur);
}

View File

@@ -0,0 +1,22 @@
preconditioner_test_sources = files(
'border.cpp',
'coupling.cpp',
'graph.cpp',
'graph_extensions.cpp',
'graph_review.cpp',
'inverses.cpp',
'layout.cpp',
'material.cpp',
'parity.cpp',
'pressure_rows.cpp',
'../equations/fixture.cpp',
)
serif_preconditioner_tests = executable(
'serif_preconditioner_tests',
preconditioner_test_sources,
dependencies: [serif_dep, catch2_dep, stroid_dep],
include_directories: include_directories('../include'),
build_rpath: mfem_runtime_prefix == '' ? '' : mfem_runtime_prefix / 'lib',
)
test('serif_preconditioner_tests', serif_preconditioner_tests, suite: 'preconditioner', timeout: 300, is_parallel: false)

View File

@@ -0,0 +1,58 @@
#include <array>
#include <initializer_list>
#include <memory>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "mfem/linalg/densemat.hpp"
#include "serif/backend/mfem/solver/preconditioner/coupling.hpp"
#include "serif/backend/mfem/solver/preconditioner/inverse/diagonal.hpp"
namespace
{
namespace backend = serif::solver::preconditioner::backend::mfem;
using Order = serif::solver::preconditioner::strategies::coupling::CouplingOrder;
/** @brief Express the original small physical couplings in row-major order. */
std::shared_ptr<mfem::DenseMatrix> matrix(const int rows, const int columns, const std::initializer_list<double> entries)
{
REQUIRE(entries.size() == static_cast<std::size_t>(rows * columns));
auto result = std::make_shared<mfem::DenseMatrix>(rows, columns);
auto entry = entries.begin();
for (int row = 0; row < rows; ++row)
{
for (int column = 0; column < columns; ++column)
{
(*result)(row, column) = *entry++;
}
}
return result;
}
/** @brief Prepare the original signed scalar inverse without another test-only numerical implementation. */
backend::Inverse scalar_inverse(const double diagonal)
{
const auto inverse = backend::try_diagonal_inverse(std::array{diagonal});
REQUIRE(inverse);
return *inverse;
}
} // namespace
TEST_CASE("Generic coupling preserves the original material-gravity factorization tolerances", "[preconditioner][parity][unit]")
{
const auto material = scalar_inverse(2.0);
const auto gravity = scalar_inverse(5.0);
const backend::TwoBlockCouplings cross{.first_from_second = matrix(1, 1, {7.0}), .second_from_first = matrix(1, 1, {3.0})};
const std::array orders{Order::independent, Order::second_then_first, Order::lower_diagonal_upper};
const std::array<std::array<double, 2>, 3> expected{{{5.5, 2.6}, {-3.6, 2.6}, {7.95, -0.7}}};
mfem::Vector rhs(2), result(2);
rhs[0] = 11.0;
rhs[1] = 13.0;
for (std::size_t policy = 0; policy < orders.size(); ++policy)
{
const auto inverse = backend::try_coupled_inverse(material, gravity, cross, orders[policy]);
REQUIRE(inverse);
inverse->action->Mult(rhs, result);
CHECK(result[0] == Catch::Approx(expected[policy][0]).margin(2.0e-14));
CHECK(result[1] == Catch::Approx(expected[policy][1]).margin(2.0e-14));
}
}

View File

@@ -0,0 +1,189 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <memory>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/backend/mfem/geometry/surface/pressure_boundary.hpp"
#include "serif/backend/mfem/geometry/surface/pressure_field_block.hpp"
#include "serif/tests/surface/boundary_fixture.hpp"
#include "serif/utils/dependencies/computation_cache.hpp"
namespace
{
namespace test = serif::tests::surface;
namespace fixture = test::boundary;
namespace physics = serif::geometry::surface;
namespace boundary = physics::backend::mfem;
namespace operators = serif::operators::backend::mfem;
namespace dependencies = serif::utils::dependencies;
using Temperature = fixture::ContinuousScalar<test::quantities::Temperature, 3>;
using Vector = ::mfem::Vector;
/** @brief Give the volume block nonzero off-diagonal couplings that constrained rows must completely remove. */
[[nodiscard]] std::shared_ptr<const ::mfem::SparseMatrix> volume_matrix(int rows, int columns)
{
auto matrix = std::make_shared<::mfem::SparseMatrix>(rows, columns);
for (int row = 0; row < rows; ++row)
{
matrix->Add(row, row % columns, 2.0);
matrix->Add(row, (row + 1) % columns, -0.25);
}
matrix->Finalize();
return matrix;
}
/** @brief Compare each physical row rather than a norm that could hide a wrong small boundary entry. */
void check_rows(const Vector& actual, const Vector& expected)
{
REQUIRE(actual.Size() == expected.Size());
for (int row = 0; row < actual.Size(); ++row)
{
CHECK(actual[row] == Catch::Approx(expected[row]).margin(3e-13));
}
}
/** @brief Exercise different nodal bases at equal dimensions so an interpolated diagonal cannot be mistaken for a row sum. */
template <typename Formulation>
void check_pressure_field()
{
fixture::Fixture mesh;
::mfem::H1_FECollection uniform_collection(3, 3, ::mfem::BasisType::ClosedUniform);
::mfem::FiniteElementSpace uniform_space(&mesh.mesh, &uniform_collection);
serif::discretization::backend::mfem::TermBinding<Temperature, fixture::Schema> temperature_binding(uniform_space);
REQUIRE(temperature_binding.size() == mesh.density.size());
Vector density = fixture::sequence(mesh.density.size(), 0.8, 0.2);
Vector temperature = fixture::sequence(temperature_binding.size(), 1.7, 0.4);
const Vector direction = fixture::sequence(temperature.Size(), 0.2, 0.1);
Vector zero_density(density.Size());
zero_density = 0.0;
const auto state = operators::make_term_coefficients(operators::bind(mesh.density, density), operators::bind(temperature_binding, temperature));
const auto directions = operators::make_term_coefficients(operators::bind(mesh.density, zero_density), operators::bind(temperature_binding, direction));
test::GasEOS model;
const auto selected = boundary::make_pressure_boundary<Formulation>(state);
/** @brief The ideal-gas fixture has no variable parameters; observational counters do not change its physics. */
const auto eos_tracking = [](const test::GasEOS&)
{
return 0;
};
const auto inputs = boundary::make_pressure_boundary_inputs(selected, dependencies::borrow<physics::keys::EquationOfState>(model, dependencies::track_by(eos_tracking)), physics::IsobaricSurface{test::quantities::PressureValue{0.8}}, state);
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(boundary::make_pressure_boundary_graph<Formulation>(), inputs);
REQUIRE(cache.try_prepare(inputs));
std::vector<physics::SurfaceLinearization<Formulation>> accepted = cache.template get<physics::keys::Linearization>();
const auto volume = volume_matrix(density.Size(), temperature.Size());
const operators::LinearizedBlock block = boundary::pressure_field_block<Temperature>(operators::linearized_block(volume), *selected, accepted);
REQUIRE(block.has_diagonal());
REQUIRE(block.has_assembly());
const auto assembled = block.assemble();
const std::vector<double> diagonal = block.diagonal();
Vector actual(density.Size()), expected(density.Size()), assembled_action(density.Size());
volume->Mult(direction, expected);
boundary::apply_jacobian_rows(*selected, accepted, directions, expected);
block.action()->Mult(direction, actual);
assembled->Mult(direction, assembled_action);
check_rows(actual, expected);
check_rows(assembled_action, expected);
const ::mfem::SparseMatrix& interpolation = selected->traces.template get<Temperature>().interpolation();
bool interpolating_diagonal = false;
for (int index = 0; index < selected->selected_rows->size(); ++index)
{
const int row = selected->selected_rows->reduced_dofs()[index];
const double derivative = accepted[index].field_derivatives.template get<Temperature>();
CHECK(diagonal[row] == Catch::Approx(derivative * interpolation(index, row)).margin(3e-14));
if (std::abs(interpolation(index, row) - 1.0) > 1e-3)
{
interpolating_diagonal = true;
}
}
REQUIRE(interpolating_diagonal);
for (int row = 0; row < assembled->Height(); ++row)
{
CHECK(diagonal[row] == Catch::Approx((*assembled)(row, row)).margin(3e-14));
}
Vector alias(direction);
block.action()->Mult(alias, alias);
check_rows(alias, expected);
const Vector dual = fixture::sequence(density.Size(), 0.4, 0.8);
Vector transpose(temperature.Size()), assembled_transpose(temperature.Size());
block.action()->MultTranspose(dual, transpose);
assembled->MultTranspose(dual, assembled_transpose);
check_rows(transpose, assembled_transpose);
CHECK(dual * actual == Catch::Approx(direction * transpose).margin(2e-12));
alias = dual;
block.action()->MultTranspose(alias, alias);
check_rows(alias, assembled_transpose);
// Later accepted candidates cannot mutate the frozen row coefficients of an earlier physical block.
for (auto& point : accepted)
{
point.field_derivatives.template get<Temperature>() *= 2.0;
}
const auto refreshed = boundary::pressure_field_block<Temperature>(operators::linearized_block(volume), *selected, accepted);
refreshed.action()->Mult(direction, assembled_action);
block.action()->Mult(direction, actual);
check_rows(actual, expected);
for (int row : selected->selected_rows->reduced_dofs())
{
CHECK(assembled_action[row] == Catch::Approx(2.0 * expected[row]).margin(3e-13));
}
const auto absent = boundary::pressure_field_block<test::Entropy>(operators::linearized_block(volume), *selected, accepted);
absent.action()->Mult(direction, actual);
volume->Mult(direction, expected);
const auto absent_matrix = absent.assemble();
const auto absent_diagonal = absent.diagonal();
for (int row : selected->selected_rows->reduced_dofs())
{
expected[row] = 0.0;
CHECK(absent_diagonal[row] == 0.0);
}
check_rows(actual, expected);
absent_matrix->Mult(direction, actual);
check_rows(actual, expected);
accepted.pop_back();
CHECK_THROWS(boundary::pressure_field_block<Temperature>(operators::linearized_block(volume), *selected, accepted));
}
} // namespace
TEST_CASE("Selected pressure blocks retain general EOS interpolation in actions diagonals and sparse rows", "[unit][preconditioner][surface][operators]")
{
SECTION("Density carries the surface relation without enthalpy")
{
check_pressure_field<fixture::DensityFormulation<Temperature>>();
}
SECTION("Direct pressure derivatives include every thermodynamic field")
{
check_pressure_field<fixture::PressureFormulation<Temperature>>();
}
}
TEST_CASE("Generic physical row replacement preserves optional capabilities and rejects invalid rows", "[unit][preconditioner][surface][operators]")
{
const auto original = volume_matrix(3, 3);
auto replacement = std::make_shared<::mfem::SparseMatrix>(1, 3);
replacement->Add(0, 0, 0.2);
replacement->Add(0, 1, -0.7);
replacement->Finalize();
const operators::LinearizedBlock action_only(original);
const auto replaced = operators::replace_block_rows(action_only, {1}, replacement);
CHECK_FALSE(replaced.has_diagonal());
CHECK_FALSE(replaced.has_assembly());
const auto full = operators::replace_block_rows(operators::linearized_block(original), {1}, replacement);
CHECK(full.diagonal()[1] == -0.7);
CHECK_THROWS(operators::replace_block_rows(action_only, {-1}, replacement));
CHECK_THROWS(operators::replace_block_rows(action_only, {3}, replacement));
CHECK_THROWS(operators::replace_block_rows(action_only, {}, replacement));
auto repeated = std::make_shared<::mfem::SparseMatrix>(2, 3);
repeated->Finalize();
CHECK_THROWS(operators::replace_block_rows(action_only, {1, 1}, repeated));
auto nonfinite = std::make_shared<::mfem::SparseMatrix>(1, 3);
nonfinite->Add(0, 0, std::numeric_limits<double>::infinity());
nonfinite->Finalize();
CHECK_THROWS(operators::replace_block_rows(action_only, {1}, nonfinite));
}