Files
MeanField_SERiF/tests/solver/continuation_controller.cpp
Emily Boudreaux cb20faeb3d test(tests): added test suite
The test suite is extensive. Note that LLMs were used to aid in the development of the test suite. All LLM generated code was manually reviewed.
2026-11-09 13:53:17 -05:00

295 lines
14 KiB
C++

#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <limits>
#include <memory>
#include <stdexcept>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/solver/continuation/controller.hpp"
namespace continuation_controller_test {
using namespace serif::solver::continuation;
using Catch::Approx;
/** @brief Hold growth fixed to isolate the selected controller behavior. */
template <std::size_t N> CoordinateOptions<N> fixed_steps(double initial, double minimum, double maximum) {
CoordinateOptions<N> options;
options.initial_step_fraction.fill(initial);
options.minimum_step_fraction.fill(minimum);
options.maximum_step_fraction.fill(maximum);
options.growth_factor = 1.;
return options;
}
struct FakeContext {
std::array<double, 2> coordinates;
std::array<double, 3> fields;
int prepared_generation;
};
}
TEST_CASE("Continuation proposals preserve accepted coordinates and external contexts until promotion",
"[unit][solver][continuation_controller][transaction]") {
using namespace continuation_controller_test;
auto options = fixed_steps<2>(.5, .125, 1.);
options.order = CoordinateOrder::preferred;
CoordinateController<2> controller({1., 0.}, {2., .1}, options);
auto accepted = std::make_unique<FakeContext>(FakeContext{{1., 0.}, {2., 3., 5.}, 7});
const auto originalAddress = accepted.get();
const auto p = controller.propose();
REQUIRE(p);
CHECK(controller.accepted() == controller.start());
auto trial = std::make_unique<FakeContext>(FakeContext{p->coordinates, {97., 98., 99.}, 8});
controller.resolve(p->id, AttemptOutcome::recoverable_rejection, 3);
trial.reset();
CHECK(accepted.get() == originalAddress);
CHECK(accepted->fields == std::array<double, 3>{2., 3., 5.});
CHECK(accepted->prepared_generation == 7);
CHECK(controller.accepted() == controller.start());
const auto retry = controller.propose();
REQUIRE(retry);
CHECK(retry->coordinates[0] == Approx(1.25));
trial = std::make_unique<FakeContext>(FakeContext{retry->coordinates, {11., 13., 17.}, 9});
controller.validate_resolution(retry->id, AttemptOutcome::success, 2);
CHECK(controller.accepted() == controller.start());
controller.resolve(retry->id, AttemptOutcome::success, 2);
accepted.swap(trial); // No fallible user code between resolution and swap.
CHECK(controller.accepted() == accepted->coordinates);
CHECK(accepted->prepared_generation == 9);
CHECK(controller.start() == std::array<double, 2>{1., 0.});
CHECK(controller.target() == std::array<double, 2>{2., .1});
CHECK(controller.counters().attempts == 2);
CHECK(controller.counters().successes == 1);
CHECK(controller.counters().rejections == 1);
CHECK(controller.counters().work == 5);
}
TEST_CASE("A preferred coordinate can yield to another axis while strict stages cannot",
"[unit][solver][continuation_controller][order]") {
using namespace continuation_controller_test;
auto options = fixed_steps<2>(1., .125, 1.);
options.maximum_rejections_per_axis = 1;
options.order = CoordinateOrder::preferred;
// This corrector's feasible branch requires the second coordinate first.
const auto correct = [](const auto &p) { return p.coordinates[0] <= p.coordinates[1]
? AttemptOutcome::success : AttemptOutcome::recoverable_rejection; };
CoordinateController<2> preferred({0., 0.}, {1., 1.}, options);
std::vector<std::size_t> attempted;
while (const auto p = preferred.propose()) {
attempted.push_back(p->axis);
preferred.resolve(p->id, correct(*p), 1);
}
CHECK(attempted == std::vector<std::size_t>{0, 1, 0});
CHECK(preferred.status() == ControllerStatus::complete);
CHECK(preferred.accepted() == preferred.target());
options.order = CoordinateOrder::strict;
CoordinateController<2> strict({0., 0.}, {1., 1.}, options);
const auto blocked = strict.propose();
REQUIRE(blocked);
strict.resolve(blocked->id, correct(*blocked), 1);
CHECK(strict.status() == ControllerStatus::no_progress);
CHECK_FALSE(strict.propose());
CHECK(strict.accepted() == strict.start());
CHECK(strict.counters().attempts == 1);
}
TEST_CASE("Automatic progress compares fractions while strict order honors the supplied permutation",
"[unit][solver][continuation_controller][order]") {
using namespace continuation_controller_test;
auto options = fixed_steps<2>(.5, .125, .5);
options.axis_order = {1, 0};
CoordinateController<2> automatic({0., 1000.}, {1., -1000.}, options);
const auto first = automatic.propose();
REQUIRE(first);
CHECK(first->axis == 1);
CHECK(first->coordinates == std::array<double, 2>{0., 0.});
automatic.resolve(first->id, AttemptOutcome::success);
const auto second = automatic.propose();
REQUIRE(second);
CHECK(second->axis == 0); // Largest remaining fraction, independent of units.
options.order = CoordinateOrder::strict;
CoordinateController<2> strict({0., 1000.}, {1., -1000.}, options);
auto p = strict.propose();
REQUIRE(p);
strict.resolve(p->id, AttemptOutcome::success);
p = strict.propose();
REQUIRE(p);
CHECK(p->axis == 1);
CHECK(p->coordinates == std::array<double, 2>{0., -1000.});
}
TEST_CASE("Adaptive retries shrink actual clipped progress and successful steps grow within limits",
"[unit][solver][continuation_controller][adaptation]") {
using namespace continuation_controller_test;
auto options = fixed_steps<1>(.5, .05, .5);
options.growth_factor = 2.;
CoordinateController<1> controller({0.}, {1.}, options);
auto p = controller.propose();
REQUIRE(p);
CHECK(p->coordinates[0] == .5);
controller.resolve(p->id, AttemptOutcome::recoverable_rejection, 4);
p = controller.propose();
REQUIRE(p);
CHECK(p->coordinates[0] == .25);
controller.resolve(p->id, AttemptOutcome::success, 2);
p = controller.propose();
REQUIRE(p);
CHECK(p->coordinates[0] == .75);
controller.resolve(p->id, AttemptOutcome::success, 2);
p = controller.propose();
REQUIRE(p);
CHECK(p->coordinates[0] == 1.);
CHECK(p->step_fraction == .25);
controller.resolve(p->id, AttemptOutcome::recoverable_rejection, 3);
p = controller.propose();
REQUIRE(p);
CHECK(p->coordinates[0] == .875); // Half the clipped .25, not half the nominal .5.
controller.resolve(p->id, AttemptOutcome::success, 1);
p = controller.propose();
REQUIRE(p);
CHECK(p->coordinates[0] == 1.);
controller.resolve(p->id, AttemptOutcome::success, 1);
CHECK(controller.status() == ControllerStatus::complete);
CHECK(controller.accepted()[0] == controller.target()[0]);
CHECK(controller.counters().work == 13);
}
TEST_CASE("Continuation handles zero spans exact endpoints and budgets without extra attempts",
"[unit][solver][continuation_controller][bounds]") {
using namespace continuation_controller_test;
CoordinateController<2> zero({2., .1}, {2., .1});
CHECK(zero.status() == ControllerStatus::complete);
CHECK_FALSE(zero.propose());
CHECK(zero.counters().attempts == 0);
auto options = fixed_steps<2>(1., .1, 1.);
options.maximum_attempts = 1;
CoordinateController<2> one({2., .1}, {2., -.2}, options);
auto p = one.propose();
REQUIRE(p);
CHECK(p->axis == 1);
one.resolve(p->id, AttemptOutcome::success);
CHECK(one.status() == ControllerStatus::complete); // Success wins on the last allowed attempt.
options.initial_step_fraction.fill(.5);
CoordinateController<2> bounded({0., 0.}, {1., 1.}, options);
p = bounded.propose();
REQUIRE(p);
bounded.resolve(p->id, AttemptOutcome::success);
CHECK(bounded.status() == ControllerStatus::attempt_limit);
CHECK_FALSE(bounded.propose());
CHECK(bounded.accepted() == std::array<double, 2>{.5, 0.});
}
TEST_CASE("Repeated rejection terminates at minimum progress and cannot spin on unrepresentable steps",
"[unit][solver][continuation_controller][bounds]") {
using namespace continuation_controller_test;
auto options = fixed_steps<1>(.5, .125, 1.);
CoordinateController<1> reject({0.}, {1.}, options);
std::vector<double> attempted;
while (const auto p = reject.propose()) {
attempted.push_back(p->coordinates[0]);
reject.resolve(p->id, AttemptOutcome::recoverable_rejection, 1);
}
CHECK(attempted == std::vector<double>{.5, .25, .125});
CHECK(reject.status() == ControllerStatus::no_progress);
CHECK(reject.counters().work == 3);
CHECK(reject.accepted() == reject.start());
options.initial_step_fraction[0] = .25;
const double start = 1.;
const double target = std::nextafter(start, 2.);
CoordinateController<1> rounded({start}, {target}, options);
CHECK_FALSE(rounded.propose());
CHECK(rounded.status() == ControllerStatus::no_progress);
CHECK(rounded.counters().attempts == 0);
}
TEST_CASE("Decimal continuation endpoints remain exact with reverse and zero-length coordinates",
"[unit][solver][continuation_controller][bounds]") {
using namespace continuation_controller_test;
auto options = fixed_steps<3>(.17, .01, .17);
CoordinateController<3> controller({1.1, -.07, 3.}, {3.3, -.13, 3.}, options);
while (const auto p = controller.propose()) {
const auto previous = controller.accepted();
for (std::size_t i = 0; i < 3; ++i) {
if (i != p->axis) {
CHECK(p->coordinates[i] == previous[i]);
} else {
CHECK(p->coordinates[i] != previous[i]);
CHECK(p->coordinates[i] >= std::min(controller.start()[i], controller.target()[i]));
CHECK(p->coordinates[i] <= std::max(controller.start()[i], controller.target()[i]));
}
}
controller.resolve(p->id, AttemptOutcome::success, 1);
}
CHECK(controller.status() == ControllerStatus::complete);
CHECK(controller.accepted() == controller.target());
CHECK(controller.counters().attempts <= 14);
}
TEST_CASE("Invalid continuation resolutions leave pending and accepted state unchanged",
"[unit][solver][continuation_controller][transaction][invalid]") {
using namespace continuation_controller_test;
auto options = fixed_steps<1>(.25, .125, 1.);
CoordinateController<1> controller({0.}, {1.}, options);
CHECK_THROWS_AS(controller.resolve(1, AttemptOutcome::success), std::logic_error);
const auto p = controller.propose();
REQUIRE(p);
CHECK_THROWS_AS(controller.propose(), std::logic_error);
CHECK_THROWS_AS(controller.resolve(p->id + 1, AttemptOutcome::success), std::logic_error);
CHECK_THROWS_AS(controller.resolve(p->id, static_cast<AttemptOutcome>(99)), std::invalid_argument);
CHECK(controller.pending()->id == p->id);
CHECK(controller.accepted() == controller.start());
CHECK(controller.counters().work == 0);
controller.resolve(p->id, AttemptOutcome::success, std::numeric_limits<std::uint64_t>::max());
const auto next = controller.propose();
REQUIRE(next);
const auto accepted = controller.accepted();
CHECK_THROWS_AS(controller.validate_resolution(next->id, AttemptOutcome::success, 1), std::overflow_error);
CHECK_THROWS_AS(controller.resolve(next->id, AttemptOutcome::success, 1), std::overflow_error);
CHECK(controller.accepted() == accepted);
CHECK(controller.pending()->id == next->id);
CHECK(controller.counters().successes == 1);
CHECK(controller.counters().work == std::numeric_limits<std::uint64_t>::max());
controller.resolve(next->id, AttemptOutcome::fatal_failure, 0);
CHECK(controller.status() == ControllerStatus::fatal_failure);
CHECK(controller.accepted() == accepted);
CHECK(controller.counters().fatal_failures == 1);
CHECK_FALSE(controller.propose());
CHECK_THROWS_AS(controller.resolve(next->id, AttemptOutcome::success), std::logic_error);
}
TEST_CASE("Continuation rejects unsupported controls before proposing or doing work",
"[unit][solver][continuation_controller][invalid]") {
using namespace continuation_controller_test;
const double nan = std::numeric_limits<double>::quiet_NaN();
const double inf = std::numeric_limits<double>::infinity();
for (const double bad : {nan, inf, -inf}) {
CHECK_THROWS_AS((CoordinateController<1>({bad}, {1.})), std::invalid_argument);
CHECK_THROWS_AS((CoordinateController<1>({0.}, {bad})), std::invalid_argument);
}
CHECK_THROWS_AS((CoordinateController<1>({-std::numeric_limits<double>::max()},
{std::numeric_limits<double>::max()})), std::invalid_argument);
const auto invalid = [](auto change) {
CoordinateOptions<2> options;
change(options);
CHECK_THROWS_AS((CoordinateController<2>({0., 0.}, {1., 1.}, options)), std::invalid_argument);
};
invalid([&](auto &o) { o.initial_step_fraction[0] = nan; });
invalid([](auto &o) { o.minimum_step_fraction[1] = 0.; });
invalid([](auto &o) { o.maximum_step_fraction[0] = 1.1; });
invalid([](auto &o) { o.minimum_step_fraction[0] = .5; });
invalid([](auto &o) { o.maximum_step_fraction[0] = .1; });
invalid([](auto &o) { o.growth_factor = .9; });
invalid([&](auto &o) { o.growth_factor = inf; });
invalid([](auto &o) { o.shrink_factor = 0.; });
invalid([](auto &o) { o.shrink_factor = 1.; });
invalid([](auto &o) { o.maximum_attempts = 0; });
invalid([](auto &o) { o.maximum_rejections_per_axis = 0; });
invalid([](auto &o) { o.axis_order = {0, 0}; });
invalid([](auto &o) { o.axis_order = {0, 2}; });
invalid([](auto &o) { o.order = static_cast<CoordinateOrder>(99); });
}