Files
MeanField/tests/solver/coupled_trial_correction.cpp

792 lines
38 KiB
C++

#include <algorithm>
#include <cmath>
#include <concepts>
#include <cstdint>
#include <expected>
#include <limits>
#include <memory>
#include <stdexcept>
#include <utility>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include <mfem.hpp>
#include <mpi.h>
import mean_field;
namespace coupled_trial_correction_test {
using namespace mean_field;
using namespace mean_field::solver;
class DenseOperation final : public mfem::Operator {
public:
explicit DenseOperation(mfem::DenseMatrix matrix)
: mfem::Operator(matrix.Height(), matrix.Width()), m_matrix(std::move(matrix)) { }
void Mult(const mfem::Vector &input, mfem::Vector &output) const override {
if (!prepared) throw std::logic_error("The test operator is not prepared.");
output.SetSize(Height());
m_matrix.Mult(input, output);
++applications;
}
void DenormalizeState(const mfem::Vector &normalized, mfem::Vector &physical) const { physical = normalized; }
void Prepare(mfem::DenseMatrix matrix) {
m_matrix = std::move(matrix);
prepared = true;
++generation;
}
[[nodiscard]] bool IsPrepared() const noexcept { return prepared; }
[[nodiscard]] std::uint64_t GetPreparationGeneration() const noexcept { return generation; }
[[nodiscard]] const DenseOperation &GetProblem() const noexcept { return *this; }
[[nodiscard]] const mfem::DenseMatrix &Matrix() const noexcept { return m_matrix; }
bool prepared{true};
mutable int applications{0};
std::uint64_t generation{1};
private:
mfem::DenseMatrix m_matrix;
};
class ExactInverse final : public mfem::Solver {
public:
static constexpr auto applicationContract = preconditioning::ApplicationContract::stationary_linear;
explicit ExactInverse(const DenseOperation &operation)
: mfem::Solver(operation.Width()), m_operation(operation) { Refresh(); }
void SetOperator(const mfem::Operator &) override { }
void Mult(const mfem::Vector &input, mfem::Vector &output) const override {
if (!IsCurrent()) throw std::logic_error("The test inverse is stale.");
output.SetSize(Height());
if (Height() > 0) {
mfem::DenseMatrixInverse inverse(m_operation.Matrix());
inverse.Mult(input, output);
if (Height() > 1) output(1) += extraSecondComponent * input(0);
}
++applications;
}
void Refresh() { m_generation = m_operation.GetPreparationGeneration(); }
[[nodiscard]] bool IsCurrent() const noexcept {
return m_operation.IsPrepared() && m_generation == m_operation.GetPreparationGeneration();
}
mutable int applications{0};
double extraSecondComponent{0.0};
private:
const DenseOperation &m_operation;
std::uint64_t m_generation{};
};
[[nodiscard]] mfem::DenseMatrix identity(const int size) {
mfem::DenseMatrix matrix(size);
matrix = 0.0;
for (int i = 0; i < size; ++i) matrix(i, i) = 1.0;
return matrix;
}
[[nodiscard]] mfem::Vector coupledResidual(const mfem::Vector &state) {
mfem::Vector result(3);
result(0) = 1.0 + state(0) + 10.0 * state(1) * state(2);
result(1) = 1.0 + state(1);
result(2) = 1.0 + state(2);
return result;
}
[[nodiscard]] mfem::DenseMatrix coupledJacobian(const mfem::Vector &state) {
auto matrix = identity(3);
matrix(0, 1) = 10.0 * state(2);
matrix(0, 2) = 10.0 * state(1);
return matrix;
}
[[nodiscard]] CoupledTrialCorrectionOptions exactOptions() {
return {.control = {.relativeTolerance = 1.e-12, .absoluteTolerance = 1.e-14, .maximumIterations = 6},
.backend = {.restartLength = 4, .printLevel = -1}, .maximumCorrectionRatio = 4.0};
}
enum class BackendBehavior { exact, inaccurate, failed_status, non_finite };
struct BackendProbe {
BackendBehavior behavior{BackendBehavior::exact};
int calls{0};
bool allInitialGuessesZero{true};
};
struct TestBackend final : LinearBackendConfigurationTag {
static constexpr auto supportedPreconditionerContract = preconditioning::ApplicationContract::flexible;
std::shared_ptr<BackendProbe> probe;
explicit TestBackend(std::shared_ptr<BackendProbe> value) : probe(std::move(value)) { }
};
template <typename Operation, typename Inverse>
class PreparedTestBackend final {
public:
PreparedTestBackend(const Operation &operation, Inverse &inverse, const MPI_Comm communicator,
std::shared_ptr<BackendProbe> probe)
: m_operation(operation), m_inverse(inverse), m_communicator(communicator), m_probe(std::move(probe)) { }
[[nodiscard]] const Operation &GetOperator() const noexcept { return m_operation; }
[[nodiscard]] const Inverse &GetPreconditioner() const noexcept { return m_inverse; }
[[nodiscard]] MPI_Comm GetCommunicator() const noexcept { return m_communicator; }
[[nodiscard]] bool IsReady() const noexcept { return m_communicator != MPI_COMM_NULL; }
[[nodiscard]] int RightHandSideSize() const noexcept { return m_operation.Height(); }
[[nodiscard]] int CorrectionSize() const noexcept { return m_operation.Width(); }
[[nodiscard]] LinearSolveReport Solve(const mfem::Vector &rhs, mfem::Vector &correction,
const LinearSolveControl &control) {
++m_probe->calls;
for (int i = 0; i < correction.Size(); ++i)
m_probe->allInitialGuessesZero &= correction(i) == 0.0;
m_inverse.Mult(rhs, correction);
if (m_probe->behavior == BackendBehavior::inaccurate) correction *= 0.5;
if (m_probe->behavior == BackendBehavior::non_finite && correction.Size() > 0)
correction(0) = std::numeric_limits<double>::quiet_NaN();
double local = rhs * rhs, global = 0.0;
if (MPI_Allreduce(&local, &global, 1, MPI_DOUBLE, MPI_SUM, m_communicator) != MPI_SUCCESS)
throw std::runtime_error("The test backend could not reduce its norm.");
// A backend claiming exact convergence is deliberately insufficient
// evidence: the correction owner must verify the actual J action.
return {.status = m_probe->behavior == BackendBehavior::failed_status ?
LinearSolveStatus::maximum_iterations : LinearSolveStatus::converged,
.control = control, .iterations = 1, .rightHandSideNorm = std::sqrt(global),
.trueResidualNorm = 0.0, .relativeTrueResidualNorm = 0.0};
}
private:
const Operation &m_operation;
Inverse &m_inverse;
MPI_Comm m_communicator;
std::shared_ptr<BackendProbe> m_probe;
};
template <typename Operation, typename Inverse>
[[nodiscard]] auto prepareLinearBackend(TestBackend recipe, const Operation &operation, Inverse &inverse,
const MPI_Comm communicator) {
return PreparedTestBackend<Operation, Inverse>(operation, inverse, communicator, std::move(recipe.probe));
}
static_assert(CoupledTrialOperation<DenseOperation>);
static_assert(CoupledTrialInverse<ExactInverse>);
static_assert(!CoupledTrialOperation<mfem::Operator>);
static_assert(!CoupledTrialInverse<mfem::Solver>);
static_assert(LinearBackendRuntimeAvailableFor<TestBackend, DenseOperation, ExactInverse>);
using ScriptedCorrection = PreparedCoupledTrialCorrection<DenseOperation, ExactInverse, TestBackend>;
} // namespace coupled_trial_correction_test
TEST_CASE("A coupled trial correction preserves the tangent and removes mixed nonlinear curvature",
"[solver][coupled_trial_correction]") {
using namespace coupled_trial_correction_test;
using Catch::Approx;
mfem::Vector base(3), direction(3), correction(3), prediction(3);
base = 0.0;
direction = -1.0;
const auto baseResidual = coupledResidual(base);
DenseOperation operation(coupledJacobian(base));
ExactInverse inverse(operation);
PreparedCoupledTrialCorrection prepared(operation, inverse, MPI_COMM_SELF, exactOptions());
prepared.CaptureBase(baseResidual, direction);
const auto capturedGeneration = prepared.GetBaseGeneration();
CHECK(prepared.GetBaseDirectionNorm() == Approx(std::sqrt(3.0)));
double previousScaledDisplacement = 0.0;
for (const double alpha : {.5, .25, .125, .0625}) {
CAPTURE(alpha);
mfem::Vector trial(direction);
trial *= alpha;
const auto rawResidual = coupledResidual(trial);
operation.Prepare(coupledJacobian(trial));
inverse.Refresh();
correction = 17.0;
const auto report = prepared.SolveCorrection(alpha, rawResidual, correction);
REQUIRE(report.Admissible());
REQUIRE(report.verifiedGate);
CHECK(report.linearSolve.Converged());
CHECK(report.linearSolve.iterations <= exactOptions().control.maximumIterations);
CHECK(report.verifiedTrueResidualNorm <= report.convergenceThreshold);
CHECK(correction(0) == Approx(-10.0 * alpha * alpha).margin(1.e-13));
CHECK(std::abs(correction(1)) < 1.e-13);
CHECK(std::abs(correction(2)) < 1.e-13);
trial += correction;
const auto correctedResidual = coupledResidual(trial);
prepared.BuildLinearPrediction(alpha, prediction);
mfem::Vector remainder(correctedResidual);
remainder -= prediction;
CHECK(remainder.Norml2() < 1.e-12);
CHECK(correctedResidual.Norml2() < rawResidual.Norml2());
if (alpha == .5) {
CHECK(rawResidual.Norml2() > baseResidual.Norml2());
CHECK(correctedResidual.Norml2() == Approx(.5 * baseResidual.Norml2()));
}
trial /= alpha;
trial -= direction;
const double scaledDisplacement = trial.Norml2();
CHECK(scaledDisplacement == Approx(10.0 * alpha));
if (previousScaledDisplacement > 0.0)
CHECK(scaledDisplacement == Approx(previousScaledDisplacement / 2.0));
previousScaledDisplacement = scaledDisplacement;
CHECK(prepared.GetBaseGeneration() == capturedGeneration);
for (int i = 0; i < 3; ++i) CHECK(direction(i) == -1.0);
}
}
TEST_CASE("Coupled correction captures the base action while solving with the current trial Jacobian",
"[solver][coupled_trial_correction][lifetime]") {
using namespace coupled_trial_correction_test;
using Catch::Approx;
DenseOperation operation(identity(2));
ExactInverse inverse(operation);
auto probe = std::make_shared<BackendProbe>();
ScriptedCorrection prepared(operation, inverse, MPI_COMM_SELF, exactOptions(), TestBackend(probe));
mfem::Vector f(2), p(2), prediction(2), correction(2), trialResidual(2);
f = 1.0;
p = -1.0;
prepared.CaptureBase(f, p);
const auto generation = prepared.GetBaseGeneration();
auto matrix = identity(2);
matrix(0, 0) = 2.0;
matrix(1, 1) = 4.0;
operation.Prepare(matrix);
const int applications = operation.applications;
prepared.BuildLinearPrediction(.5, prediction);
CHECK(operation.applications == applications);
CHECK(prediction(0) == .5);
CHECK(prediction(1) == .5);
trialResidual = prediction;
trialResidual(0) += .2;
trialResidual(1) += .4;
REQUIRE_THROWS_AS(prepared.SolveCorrection(.5, trialResidual, correction), std::invalid_argument);
inverse.Refresh();
const auto report = prepared.SolveCorrection(.5, trialResidual, correction);
REQUIRE(report.Admissible());
CHECK(correction(0) == Approx(-.1));
CHECK(correction(1) == Approx(-.1));
CHECK(prepared.GetBaseGeneration() == generation);
CHECK(probe->allInitialGuessesZero);
prepared.Invalidate();
CHECK_FALSE(prepared.HasBase());
CHECK_THROWS_AS(prepared.BuildLinearPrediction(.5, prediction), std::invalid_argument);
}
TEST_CASE("Coupled correction independently rejects a false auxiliary convergence report",
"[solver][coupled_trial_correction][failure]") {
using namespace coupled_trial_correction_test;
DenseOperation operation(identity(2));
ExactInverse inverse(operation);
auto probe = std::make_shared<BackendProbe>();
ScriptedCorrection prepared(operation, inverse, MPI_COMM_SELF, exactOptions(), TestBackend(probe));
mfem::Vector f(2), p(2), trial(2), correction(2);
f = 1.0;
p = -1.0;
trial = .75;
prepared.CaptureBase(f, p);
SECTION("The returned correction fails the independently computed true gate") {
probe->behavior = BackendBehavior::inaccurate;
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK_FALSE(report.Admissible());
CHECK(report.status == CoupledTrialCorrectionStatus::linear_failure);
CHECK(report.linearSolve.Converged());
CHECK_FALSE(report.verifiedGate);
CHECK(report.verifiedTrueResidualNorm > report.convergenceThreshold);
}
SECTION("An accurate correction does not erase backend nonconvergence") {
probe->behavior = BackendBehavior::failed_status;
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK_FALSE(report.Admissible());
CHECK(report.status == CoupledTrialCorrectionStatus::linear_failure);
CHECK_FALSE(report.linearSolve.Converged());
}
SECTION("A nonfinite returned correction is never admissible") {
probe->behavior = BackendBehavior::non_finite;
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK_FALSE(report.Admissible());
CHECK(report.status == CoupledTrialCorrectionStatus::non_finite);
CHECK_FALSE(report.verifiedGate);
}
CHECK(probe->calls == 1);
CHECK(probe->allInitialGuessesZero);
for (int i = 0; i < 2; ++i) {
CHECK(f(i) == 1.0);
CHECK(p(i) == -1.0);
CHECK(trial(i) == .75);
}
}
TEST_CASE("Coupled correction bounds work and correction size without changing the Newton direction",
"[solver][coupled_trial_correction][safeguard]") {
using namespace coupled_trial_correction_test;
using Catch::Approx;
DenseOperation operation(identity(2));
ExactInverse inverse(operation);
auto options = exactOptions();
options.maximumCorrectionRatio = .1;
auto probe = std::make_shared<BackendProbe>();
ScriptedCorrection prepared(operation, inverse, MPI_COMM_SELF, options, TestBackend(probe));
mfem::Vector f(2), p(2), trial(2), correction(2);
f = 1.0;
p = -1.0;
trial = .9;
prepared.CaptureBase(f, p);
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK(report.status == CoupledTrialCorrectionStatus::norm_limit);
CHECK_FALSE(report.Admissible());
CHECK(report.verifiedGate);
CHECK(report.maximumCorrectionNorm == Approx(.1 * .5 * std::sqrt(2.0)));
CHECK(report.correctionNorm > report.maximumCorrectionNorm);
CHECK(report.linearSolve.iterations <= options.control.maximumIterations);
CHECK(probe->calls == 1);
CHECK(probe->allInitialGuessesZero);
CHECK(p(0) == -1.0);
CHECK(p(1) == -1.0);
}
TEST_CASE("Zero coupled remainder does not manufacture an auxiliary displacement",
"[solver][coupled_trial_correction][safeguard]") {
using namespace coupled_trial_correction_test;
DenseOperation operation(identity(2));
ExactInverse inverse(operation);
auto probe = std::make_shared<BackendProbe>();
ScriptedCorrection prepared(operation, inverse, MPI_COMM_SELF, exactOptions(), TestBackend(probe));
mfem::Vector f(2), p(2), trial(2), correction(2);
f = 1.0;
p = -1.0;
trial = .5;
correction = 900.0;
prepared.CaptureBase(f, p);
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK(report.Admissible());
CHECK(report.verifiedGate);
CHECK(correction.Norml2() == 0.0);
CHECK(report.correctionNorm == 0.0);
CHECK(report.verifiedTrueResidualNorm == 0.0);
CHECK(probe->calls <= 1);
CHECK(probe->allInitialGuessesZero);
}
TEST_CASE("Coupled correction rejects invalid controls before numerical work",
"[solver][coupled_trial_correction][configuration]") {
using namespace coupled_trial_correction_test;
auto options = exactOptions();
SECTION("A nonfinite cap cannot define a bounded correction") {
options.maximumCorrectionRatio = std::numeric_limits<double>::infinity();
}
SECTION("A negative cap has no feasible correction size") { options.maximumCorrectionRatio = -1.0; }
SECTION("A zero cap cannot enable the correction strategy") { options.maximumCorrectionRatio = 0.0; }
SECTION("The auxiliary iteration budget must be finite and positive") { options.control.maximumIterations = 0; }
SECTION("The auxiliary tolerance cannot be NaN") {
options.control.relativeTolerance = std::numeric_limits<double>::quiet_NaN();
}
CHECK_THROWS_AS(options.Validate(), std::invalid_argument);
}
TEST_CASE("Coupled correction decisions remain collective with an empty peer rank",
"[solver][coupled_trial_correction][mpi]") {
using namespace coupled_trial_correction_test;
using Catch::Approx;
int rank = 0;
REQUIRE(MPI_Comm_rank(MPI_COMM_WORLD, &rank) == MPI_SUCCESS);
// Only one rank owns this tiny synthetic problem. A distributed stop must
// not rely on every rank having a local entry to inspect.
const int localSize = rank == 0 ? 2 : 0;
DenseOperation operation(identity(localSize));
ExactInverse inverse(operation);
auto probe = std::make_shared<BackendProbe>();
ScriptedCorrection prepared(operation, inverse, MPI_COMM_WORLD, exactOptions(), TestBackend(probe));
mfem::Vector f(localSize), p(localSize), trial(localSize), correction(localSize);
f = 1.0;
p = -1.0;
trial = .75;
prepared.CaptureBase(f, p);
SECTION("Finite global norms include the owning rank exactly once") {
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK(report.Admissible());
CHECK(report.correctionNorm == Approx(std::sqrt(2.0) * .25));
CHECK(probe->calls == 1);
}
SECTION("One nonfinite trial prevents the backend call on every rank") {
if (rank == 0) trial(0) = std::numeric_limits<double>::quiet_NaN();
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK(report.status == CoupledTrialCorrectionStatus::non_finite);
CHECK_FALSE(report.Admissible());
CHECK(probe->calls == 0);
}
SECTION("One nonfinite correction fails the independently checked global gate") {
probe->behavior = BackendBehavior::non_finite;
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK(report.status == CoupledTrialCorrectionStatus::non_finite);
CHECK_FALSE(report.Admissible());
CHECK_FALSE(report.verifiedGate);
CHECK(probe->calls == 1);
}
}
namespace coupled_trial_correction_test {
struct ToyProblem final {
MPI_Comm communicator{MPI_COMM_SELF};
[[nodiscard]] MPI_Comm GetCommunicator() const noexcept { return communicator; }
};
struct ToyState final {
DenseOperation operation{identity(3)};
ExactInverse inverse{operation};
DenseOperation *normalizedOperator{&operation};
ExactInverse *normalizedInverse{&inverse};
ExactInverse *physicalInverse{&inverse};
mfem::Vector trialNormalizedState = mfem::Vector(3);
mfem::Vector trialNormalizedResidual = mfem::Vector(3);
mfem::Vector candidatePhysicalState = mfem::Vector(3);
mfem::Vector acceptedState = mfem::Vector(3);
mfem::Vector acceptedResidual = mfem::Vector(3);
mfem::Vector normalizedCorrection = mfem::Vector(3);
LinearSolveReport outerReport{.status = LinearSolveStatus::converged, .iterations = 11,
.trueResidualNorm = .0123};
ToyProblem problem{};
int preparations{0};
bool quarticBarrier{false}, rejectCorrectedPreparation{false}, nonfiniteCorrectedResidual{false};
bool throwCorrectedPreparation{false};
ToyState() {
trialNormalizedState = 0.0;
candidatePhysicalState = 0.0;
trialNormalizedResidual = 1.0;
acceptedState = 0.0;
acceptedResidual = 1.0;
normalizedCorrection = -1.0;
}
[[nodiscard]] ToyProblem &Problem() noexcept { return problem; }
[[nodiscard]] const ToyProblem &Problem() const noexcept { return problem; }
[[nodiscard]] std::expected<void, int> PrepareTrial() {
++preparations;
candidatePhysicalState = trialNormalizedState;
if (throwCorrectedPreparation && trialNormalizedState(0) < -2.0) {
operation.prepared = false;
throw std::runtime_error("Synthetic corrected preparation failure.");
}
if (rejectCorrectedPreparation && trialNormalizedState(0) < -2.0) {
operation.prepared = false;
return std::unexpected(7);
}
trialNormalizedResidual = coupledResidual(trialNormalizedState);
auto matrix = coupledJacobian(trialNormalizedState);
if (quarticBarrier) {
// The extra term and its derivative vanish at the base and at
// alpha=.5. It makes the solved correction harmful without
// supplying the transaction an inconsistent trial Jacobian.
const double x = trialNormalizedState(0);
trialNormalizedResidual(0) += x*x*(x+.5)*(x+.5);
matrix(0, 0) += 2.0*x*(x+.5)*(2.0*x+.5);
}
operation.Prepare(matrix);
if (nonfiniteCorrectedResidual && trialNormalizedState(0) < -2.0)
trialNormalizedResidual(0) = std::numeric_limits<double>::quiet_NaN();
return {};
}
[[nodiscard]] std::expected<void, int> PrepareAlpha(const double alpha) {
trialNormalizedState = normalizedCorrection;
trialNormalizedState *= alpha;
return PrepareTrial();
}
};
[[nodiscard]] nonlinear::MetricEvaluation toyMetric(const mfem::Vector &residual) {
const double norm = residual.Norml2();
return {.residualNorm = norm, .merit = .5*norm*norm};
}
[[nodiscard]] bool toyAccepts(const nonlinear::MetricEvaluation &metric) {
return std::isfinite(metric.merit) && metric.merit < 1.5;
}
[[nodiscard]] bool toyImproves(const nonlinear::MetricEvaluation &candidate,
const nonlinear::MetricEvaluation &original) {
return std::isfinite(candidate.merit) && candidate.merit < original.merit;
}
[[nodiscard]] bool admissibleToy(const mfem::Vector &) { return true; }
void checkOuterData(const ToyState &state) {
for (int i = 0; i < 3; ++i) {
CHECK(state.acceptedState(i) == 0.0);
CHECK(state.acceptedResidual(i) == 1.0);
CHECK(state.normalizedCorrection(i) == -1.0);
}
CHECK(state.outerReport.status == LinearSolveStatus::converged);
CHECK(state.outerReport.iterations == 11);
CHECK(state.outerReport.trueResidualNorm == .0123);
}
void checkRestoredTrial(const ToyState &state, const mfem::Vector &originalState,
const mfem::Vector &originalResidual, const mfem::Vector &originalAction) {
for (int i = 0; i < 3; ++i) {
CHECK(state.trialNormalizedState(i) == originalState(i));
CHECK(state.candidatePhysicalState(i) == originalState(i));
CHECK(state.trialNormalizedResidual(i) == originalResidual(i));
}
CHECK(state.operation.IsPrepared());
CHECK(state.inverse.IsCurrent());
mfem::Vector action(3);
state.operation.Mult(state.normalizedCorrection, action);
for (int i = 0; i < 3; ++i) CHECK(action(i) == originalAction(i));
checkOuterData(state);
}
}
TEST_CASE("The production coupled transaction keeps an improved full residual and leaves outer data alone",
"[solver][coupled_trial_correction][transaction]") {
using namespace coupled_trial_correction_test;
using Catch::Approx;
ToyState state;
solver::detail::PreparedCoupledTrialTransaction transaction(state, exactOptions());
transaction.CaptureBase(state.acceptedResidual, state.normalizedCorrection);
auto preparation = state.PrepareAlpha(.5);
REQUIRE(preparation.has_value());
auto metric = toyMetric(state.trialNormalizedResidual);
transaction.BeginTrial();
transaction.TryImprove(state, .5, preparation, metric, toyMetric, toyAccepts, toyImproves, admissibleToy);
const auto &diagnostics = transaction.GetDiagnostics();
CHECK(diagnostics.status == CoupledTrialAttemptStatus::kept);
REQUIRE(diagnostics.currentSolve.has_value());
CHECK(diagnostics.currentSolve->Admissible());
CHECK(diagnostics.currentSolve->verifiedGate);
CHECK(diagnostics.attempts == 1);
CHECK(diagnostics.kept == 1);
CHECK(diagnostics.fallbacks == 0);
CHECK(diagnostics.preparations == 1);
CHECK(diagnostics.restorations == 0);
CHECK(metric.merit == Approx(.375));
CHECK(metric.merit < diagnostics.originalMerit);
REQUIRE(preparation.has_value());
for (int i = 0; i < 3; ++i) CHECK(state.trialNormalizedResidual(i) == Approx(.5));
checkOuterData(state);
transaction.BeginTrial();
CHECK(transaction.GetDiagnostics().status == CoupledTrialAttemptStatus::not_attempted);
CHECK_FALSE(transaction.GetDiagnostics().currentSolve.has_value());
CHECK(std::isnan(transaction.GetDiagnostics().originalMerit));
CHECK(transaction.GetDiagnostics().attempts == 1);
}
TEST_CASE("The production coupled transaction restores rejected corrections and their prepared operators",
"[solver][coupled_trial_correction][transaction][rollback]") {
using namespace coupled_trial_correction_test;
ToyState state;
bool rejectAdmissibility = false;
CoupledTrialAttemptStatus expected = CoupledTrialAttemptStatus::inadmissible;
SECTION("Corrected physical preparation rejects the candidate") { state.rejectCorrectedPreparation = true; }
SECTION("Corrected polynomial admissibility rejects before preparation") { rejectAdmissibility = true; }
SECTION("A consistent nonlinear map makes the solved correction worse") {
state.quarticBarrier = true;
expected = CoupledTrialAttemptStatus::not_improved;
}
SECTION("A successfully prepared nonfinite residual is unusable") {
state.nonfiniteCorrectedResidual = true;
expected = CoupledTrialAttemptStatus::non_finite;
}
solver::detail::PreparedCoupledTrialTransaction transaction(state, exactOptions());
transaction.CaptureBase(state.acceptedResidual, state.normalizedCorrection);
auto preparation = state.PrepareAlpha(.5);
REQUIRE(preparation.has_value());
const mfem::Vector originalState(state.trialNormalizedState), originalResidual(state.trialNormalizedResidual);
mfem::Vector originalAction(3);
state.operation.Mult(state.normalizedCorrection, originalAction);
auto metric = toyMetric(originalResidual);
const double originalMerit = metric.merit;
transaction.BeginTrial();
transaction.TryImprove(state, .5, preparation, metric, toyMetric, toyAccepts, toyImproves,
[rejectAdmissibility](const mfem::Vector &candidate) { return !rejectAdmissibility || candidate(0) >= -2.0; });
const auto &diagnostics = transaction.GetDiagnostics();
CHECK(diagnostics.status == expected);
REQUIRE(diagnostics.currentSolve.has_value());
CHECK(diagnostics.currentSolve->Admissible());
CHECK(diagnostics.attempts == 1);
CHECK(diagnostics.kept == 0);
CHECK(diagnostics.fallbacks == 1);
CHECK(diagnostics.restorations == 1);
CHECK(diagnostics.preparations == (rejectAdmissibility ? 1 : 2));
CHECK(metric.merit == originalMerit);
if (expected == CoupledTrialAttemptStatus::not_improved)
CHECK(diagnostics.correctedMerit > diagnostics.originalMerit);
REQUIRE(preparation.has_value());
checkRestoredTrial(state, originalState, originalResidual, originalAction);
}
TEST_CASE("The coupled transaction skips acceptable trials and bounds attempts per captured direction",
"[solver][coupled_trial_correction][transaction][budget]") {
using namespace coupled_trial_correction_test;
ToyState state;
state.quarticBarrier = true;
solver::detail::PreparedCoupledTrialTransaction transaction(state, exactOptions(), 1);
transaction.CaptureBase(state.acceptedResidual, state.normalizedCorrection);
auto preparation = state.PrepareAlpha(.05);
auto metric = toyMetric(state.trialNormalizedResidual);
REQUIRE(toyAccepts(metric));
const int before = state.preparations;
transaction.BeginTrial();
transaction.TryImprove(state, .05, preparation, metric, toyMetric, toyAccepts, toyImproves, admissibleToy);
CHECK(transaction.GetDiagnostics().status == CoupledTrialAttemptStatus::original_accepted);
CHECK(transaction.GetDiagnostics().attempts == 0);
CHECK(state.preparations == before);
CHECK_FALSE(transaction.GetDiagnostics().currentSolve.has_value());
preparation = state.PrepareAlpha(.5);
metric = toyMetric(state.trialNormalizedResidual);
transaction.BeginTrial();
transaction.TryImprove(state, .5, preparation, metric, toyMetric, toyAccepts, toyImproves, admissibleToy);
CHECK(transaction.GetDiagnostics().status == CoupledTrialAttemptStatus::not_improved);
CHECK(transaction.GetDiagnostics().attempts == 1);
const auto iterations = transaction.GetDiagnostics().iterations;
transaction.BeginTrial();
transaction.TryImprove(state, .5, preparation, metric, toyMetric, toyAccepts, toyImproves, admissibleToy);
CHECK(transaction.GetDiagnostics().status == CoupledTrialAttemptStatus::budget_exhausted);
CHECK(transaction.GetDiagnostics().attempts == 1);
CHECK(transaction.GetDiagnostics().iterations == iterations);
CHECK_FALSE(transaction.GetDiagnostics().currentSolve.has_value());
checkOuterData(state);
}
TEST_CASE("A rank-local corrected admissibility rejection restores the original trial everywhere",
"[solver][coupled_trial_correction][transaction][rollback][mpi]") {
using namespace coupled_trial_correction_test;
int rank = 0;
REQUIRE(MPI_Comm_rank(MPI_COMM_WORLD, &rank) == MPI_SUCCESS);
ToyState state;
state.problem.communicator = MPI_COMM_WORLD;
solver::detail::PreparedCoupledTrialTransaction transaction(state, exactOptions());
transaction.CaptureBase(state.acceptedResidual, state.normalizedCorrection);
auto preparation = state.PrepareAlpha(.5);
const mfem::Vector originalState(state.trialNormalizedState), originalResidual(state.trialNormalizedResidual);
mfem::Vector originalAction(3);
state.operation.Mult(state.normalizedCorrection, originalAction);
auto metric = toyMetric(originalResidual);
transaction.BeginTrial();
transaction.TryImprove(state, .5, preparation, metric, toyMetric, toyAccepts, toyImproves,
[rank](const mfem::Vector &) { return rank != 0; });
CHECK(transaction.GetDiagnostics().status == CoupledTrialAttemptStatus::inadmissible);
CHECK(transaction.GetDiagnostics().restorations == 1);
checkRestoredTrial(state, originalState, originalResidual, originalAction);
}
TEST_CASE("A coupled auxiliary gate or cap failure preserves the already prepared original candidate",
"[solver][coupled_trial_correction][transaction][failure]") {
using namespace coupled_trial_correction_test;
ToyState state;
auto options = exactOptions();
auto expected = CoupledTrialAttemptStatus::norm_limit;
SECTION("A correction outside the cap is rejected without mutation") {
options.maximumCorrectionRatio = .1;
}
SECTION("A bounded unsuccessful Krylov solve is rejected without mutation") {
// This stationary perturbation prevents one Krylov vector from spanning
// the exact correction. The failure comes from a real verified solve.
state.inverse.extraSecondComponent = .1;
options.control.maximumIterations = 1;
options.backend.restartLength = 1;
expected = CoupledTrialAttemptStatus::linear_failure;
}
solver::detail::PreparedCoupledTrialTransaction transaction(state, options);
transaction.CaptureBase(state.acceptedResidual, state.normalizedCorrection);
auto preparation = state.PrepareAlpha(.5);
const mfem::Vector originalState(state.trialNormalizedState), originalResidual(state.trialNormalizedResidual);
mfem::Vector originalAction(3);
state.operation.Mult(state.normalizedCorrection, originalAction);
auto metric = toyMetric(originalResidual);
transaction.BeginTrial();
transaction.TryImprove(state, .5, preparation, metric, toyMetric, toyAccepts, toyImproves, admissibleToy);
const auto &diagnostics = transaction.GetDiagnostics();
CHECK(diagnostics.status == expected);
REQUIRE(diagnostics.currentSolve.has_value());
CHECK_FALSE(diagnostics.currentSolve->Admissible());
CHECK(diagnostics.attempts == 1);
CHECK(diagnostics.fallbacks == 1);
CHECK(diagnostics.preparations == 0);
CHECK(diagnostics.restorations == 0);
CHECK(diagnostics.iterations <= static_cast<std::uint64_t>(options.control.maximumIterations));
if (expected == CoupledTrialAttemptStatus::linear_failure)
CHECK_FALSE(diagnostics.currentSolve->verifiedGate);
checkRestoredTrial(state, originalState, originalResidual, originalAction);
}
TEST_CASE("Coupled correction options and step lengths must agree across ranks",
"[solver][coupled_trial_correction][configuration][mpi]") {
using namespace coupled_trial_correction_test;
int rank = 0, ranks = 0;
REQUIRE(MPI_Comm_rank(MPI_COMM_WORLD, &rank) == MPI_SUCCESS);
REQUIRE(MPI_Comm_size(MPI_COMM_WORLD, &ranks) == MPI_SUCCESS);
DenseOperation operation(identity(2));
ExactInverse inverse(operation);
auto options = exactOptions();
SECTION("A rank-local invalid cap fails collectively") {
if (rank == 0) options.maximumCorrectionRatio = std::numeric_limits<double>::quiet_NaN();
const auto create = [&] { return PreparedCoupledTrialCorrection(operation, inverse, MPI_COMM_WORLD, options); };
CHECK_THROWS_AS(create(), std::invalid_argument);
}
SECTION("Different valid caps fail collectively") {
if (ranks < 2) return;
options.maximumCorrectionRatio = rank == 0 ? 1.0 : 2.0;
const auto create = [&] { return PreparedCoupledTrialCorrection(operation, inverse, MPI_COMM_WORLD, options); };
CHECK_THROWS_AS(create(), std::invalid_argument);
}
SECTION("Different step lengths cannot form one global trial") {
if (ranks < 2) return;
PreparedCoupledTrialCorrection prepared(operation, inverse, MPI_COMM_WORLD, options);
mfem::Vector f(2), p(2), prediction(2);
f = 1.0;
p = -1.0;
prepared.CaptureBase(f, p);
CHECK_THROWS_AS(prepared.BuildLinearPrediction(rank == 0 ? .5 : .25, prediction), std::invalid_argument);
}
}
TEST_CASE("Finite entries with an overflowing auxiliary norm never authorize a correction",
"[solver][coupled_trial_correction][failure]") {
using namespace coupled_trial_correction_test;
DenseOperation operation(identity(2));
ExactInverse inverse(operation);
auto probe = std::make_shared<BackendProbe>();
ScriptedCorrection prepared(operation, inverse, MPI_COMM_SELF, exactOptions(), TestBackend(probe));
mfem::Vector f(2), p(2), trial(2), correction(2);
f = 1.0;
p = -1.0;
trial = 1.e200;
prepared.CaptureBase(f, p);
const auto report = prepared.SolveCorrection(.5, trial, correction);
CHECK(report.status == CoupledTrialCorrectionStatus::non_finite);
CHECK_FALSE(report.Admissible());
CHECK_FALSE(report.verifiedGate);
CHECK(probe->calls == 0);
}
TEST_CASE("Exceptions after coupled mutation restore the original prepared candidate before escaping",
"[solver][coupled_trial_correction][transaction][rollback][exception]") {
using namespace coupled_trial_correction_test;
ToyState state;
enum class FailureSite { admissibility, preparation, metric, comparison };
FailureSite site = FailureSite::admissibility;
SECTION("The admissibility callback throws") { site = FailureSite::admissibility; }
SECTION("Physical preparation invalidates its operator and throws") {
site = FailureSite::preparation;
state.throwCorrectedPreparation = true;
}
SECTION("Evaluation of the corrected metric throws") { site = FailureSite::metric; }
SECTION("Comparison of the corrected full merit throws") { site = FailureSite::comparison; }
solver::detail::PreparedCoupledTrialTransaction transaction(state, exactOptions());
transaction.CaptureBase(state.acceptedResidual, state.normalizedCorrection);
auto preparation = state.PrepareAlpha(.5);
const mfem::Vector originalState(state.trialNormalizedState), originalResidual(state.trialNormalizedResidual);
mfem::Vector originalAction(3);
state.operation.Mult(state.normalizedCorrection, originalAction);
auto metric = toyMetric(originalResidual);
const double originalMerit = metric.merit;
int evaluations = 0;
transaction.BeginTrial();
const auto attempt = [&] {
transaction.TryImprove(state, .5, preparation, metric,
[&](const mfem::Vector &residual) {
if (++evaluations == 1 && site == FailureSite::metric)
throw std::runtime_error("Synthetic corrected metric failure.");
return toyMetric(residual);
}, toyAccepts,
[&](const auto &candidate, const auto &original) {
if (site == FailureSite::comparison)
throw std::runtime_error("Synthetic corrected comparison failure.");
return toyImproves(candidate, original);
}, [&](const mfem::Vector &) {
if (site == FailureSite::admissibility)
throw std::runtime_error("Synthetic corrected admissibility failure.");
return true;
});
};
CHECK_THROWS_AS(attempt(), std::runtime_error);
CHECK(transaction.GetDiagnostics().kept == 0);
CHECK(transaction.GetDiagnostics().restorations == 1);
CHECK(metric.merit == originalMerit);
REQUIRE(preparation.has_value());
checkRestoredTrial(state, originalState, originalResidual, originalAction);
}