Files
MeanField_SERiF/tests/solver/continuation_driver.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

228 lines
12 KiB
C++

#include <array>
#include <limits>
#include <optional>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "serif/solver/continuation/polytropic.hpp"
#include "serif/solver/continuation/driver.hpp"
namespace {
namespace family = serif::solver::continuation;
namespace driver = serif::solver::continuation;
using Momentum = serif::model::FixedAngularMomentum;
using State = std::array<double, 3>;
/** @brief Define physical continuation without finite-element or nonlinear-solver dependencies. */
[[nodiscard]] auto make_family() {
const auto target = serif::model::make_stellar_model(serif::eos::models::Polytrope{2.0, 0.73}, []() {},
Momentum{serif::dimensions::AngularMomentumValue{0.1}}, serif::model::FixedTotalMass{serif::dimensions::MassValue{1.0}});
return family::in(family::PolytropicIndex{}, family::AngularMomentum{})
.with(family::preserve_pressure_at_reference_density(serif::dimensions::DensityValue{0.2})).bind(target);
}
struct Diagnostics {
std::uint64_t attempted_iterations{1};
std::optional<double> final_residual_norm{0.0};
};
struct Report {
bool success{true};
bool current{true};
State physical{};
Diagnostics diagnostic{};
/** @brief Test the same certificate interface as an owning nonlinear result. */
[[nodiscard]] bool converged() const { return success; }
/** @brief Rejected initial states have no prepared physical snapshot. */
[[nodiscard]] bool has_state() const { return current; }
/** @brief Transfer physical rather than normalized coordinates. */
[[nodiscard]] const State& state() const { return physical; }
/** @brief Expose attempted work even when correction fails. */
[[nodiscard]] const Diagnostics& diagnostics() const { return diagnostic; }
/** @brief Keep driver diagnostic composition independent of solver-specific status enums. */
[[nodiscard]] std::string message() const { return success ? "converged" : "corrector rejected"; }
};
}
TEST_CASE("Continuation qualifies starting models and transfers only successful physical states", "[unit][solver][continuation][driver]") {
const auto bound = make_family();
auto options = family::make_options(bound, family::starting_at(family::PolytropicIndex{}, 1.0), family::prefer(family::PolytropicIndex{}));
options.steps.initial_step_fraction.fill(1.0);
options.steps.maximum_rejections_per_axis = 1;
const State initial{2.0, 3.0, 5.0};
std::vector<State> inputs;
std::vector<std::array<double, 2>> attempted;
const auto result = driver::run(bound, initial, [&](auto model, State state, driver::CorrectionControls controls) {
CHECK(controls.absolute_tolerance == 1.0e-8);
inputs.push_back(state);
const double index = model.equation_of_state().polytropic_index();
const double momentum = model.template specification<Momentum>().target().value();
attempted.push_back({index, momentum});
if (index == 2.0 && momentum == 0.0) {
return Report{.success = false, .physical = {999.0, 999.0, 999.0}, .diagnostic = {.attempted_iterations = 3, .final_residual_norm = 2.0}};
}
state[0] += 1.0;
return Report{.physical = state, .diagnostic = {.attempted_iterations = 2, .final_residual_norm = 1.0e-10}};
}, options, 1.0e-8);
REQUIRE(result.converged());
REQUIRE(result.last_converged());
CHECK(attempted == std::vector<std::array<double, 2>>{{1.0, 0.0}, {2.0, 0.0}, {1.0, 0.1}, {2.0, 0.1}});
CHECK(inputs[1] == inputs[2]);
CHECK(inputs[2][0] == 3.0);
CHECK(initial == State{2.0, 3.0, 5.0});
CHECK(result.last_converged()->state() == State{5.0, 3.0, 5.0});
CHECK(result.total_work() == 9);
CHECK(result.total_attempts() == 4);
CHECK(result.counters.rejections == 1);
CHECK(result.accepted_coordinates == result.target);
}
TEST_CASE("Continuation starting fallbacks are bounded and explicit starts disable them", "[unit][solver][continuation][driver]") {
const auto bound = make_family();
std::vector<std::array<double, 2>> attempted;
const auto correct = [&](auto model, State state, driver::CorrectionControls) {
const auto coordinates = bound.coordinates_of(model);
attempted.push_back(coordinates);
return Report{.success = coordinates == bound.target_coordinates(), .physical = state,
.diagnostic = {.attempted_iterations = 1, .final_residual_norm = coordinates == bound.target_coordinates() ? 0.0 : 1.0}};
};
const auto result = driver::run(bound, State{}, correct, family::make_options(bound), 1.0e-8);
CHECK(result.converged());
CHECK(attempted == std::vector<std::array<double, 2>>{{1.0, 0.0}, {2.0, 0.0}, {1.0, 0.1}, {2.0, 0.1}});
CHECK(result.starting_attempts == 4);
CHECK(result.counters.attempts == 0);
attempted.clear();
const auto stopped = driver::run(bound, State{}, correct, family::make_options(bound, family::starting_at(family::PolytropicIndex{}, 1.0)), 1.0e-8);
CHECK_FALSE(stopped.converged());
CHECK(stopped.last_converged() == nullptr);
CHECK(stopped.status == driver::Status::starting_star_failed);
CHECK(attempted.size() == 1);
}
TEST_CASE("Continuation preserves intermediate ownership when attempt work or progress limits stop it", "[unit][solver][continuation][driver]") {
const auto bound = make_family();
const auto correct = [](auto, State state, driver::CorrectionControls controls) {
CHECK(controls.maximum_work >= 1);
state[1] += 2.0;
return Report{.physical = state};
};
auto options = family::make_options(bound);
options.steps.maximum_attempts = 2;
options.maximum_records = 1;
const auto attempts = driver::run(bound, State{}, correct, options, 1.0e-8);
CHECK_FALSE(attempts.converged());
CHECK(attempts.status == driver::Status::attempt_limit);
REQUIRE(attempts.last_converged());
CHECK(attempts.last_converged()->state()[1] == 4.0);
CHECK(attempts.stages.size() == 1);
CHECK(attempts.omitted_records == 1);
CHECK(attempts.total_attempts() == 2);
options.steps.maximum_attempts = 128;
options.maximum_work = 2;
const auto work = driver::run(bound, State{}, correct, options, 1.0e-8);
CHECK(work.status == driver::Status::work_limit);
CHECK(work.total_work() == 2);
CHECK(work.last_converged()->state()[1] == 4.0);
CHECK(attempts.last_converged()->state()[1] == 4.0);
options.maximum_work = 100;
options.steps.maximum_rejections_per_axis = 1;
int calls = 0;
const auto blocked = driver::run(bound, State{}, [&](auto, State state, driver::CorrectionControls) {
return Report{.success = calls++ == 0, .physical = state};
}, options, 1.0e-8);
CHECK(blocked.status == driver::Status::no_progress);
REQUIRE(blocked.last_converged());
CHECK(blocked.accepted_coordinates == bound.starting_coordinates());
}
TEST_CASE("A supplied starting star must belong to the family and qualify at the fixed absolute tolerance", "[unit][solver][continuation][driver]") {
const auto bound = make_family();
const driver::StartingStar anchor{bound.model_at({1.5, 0.02}), State{7.0, 11.0, 13.0}};
int calls = 0;
const auto correct = [&](auto model, State state, driver::CorrectionControls controls) {
if (calls++ == 0) {
CHECK(bound.coordinates_of(model) == std::array<double, 2>{1.5, 0.02});
CHECK(state == anchor.state);
CHECK(controls.absolute_tolerance == 1.0e-9);
}
return Report{.physical = state, .diagnostic = {.attempted_iterations = 1, .final_residual_norm = 1.0e-10}};
};
const auto result = driver::run(bound, anchor, correct, family::make_options(bound), 1.0e-9);
CHECK(result.converged());
CHECK(calls >= 2);
CHECK(anchor.state == State{7.0, 11.0, 13.0});
CHECK_THROWS_AS(driver::run(bound, anchor, correct, family::make_options(bound,
family::starting_at(family::PolytropicIndex{}, 1.2)), 1.0e-9), std::invalid_argument);
const auto false_certificate = driver::run(bound, anchor, [](auto, State state, driver::CorrectionControls) {
return Report{.physical = state, .diagnostic = {.final_residual_norm = 1.0}};
}, family::make_options(bound), 1.0e-9);
CHECK_FALSE(false_certificate.converged());
CHECK_FALSE(false_certificate.last_converged());
CHECK(false_certificate.starting_attempts == 1);
const auto unrelated = anchor.model.transform_specifications([](const auto& value) {
if constexpr (std::same_as<std::remove_cvref_t<decltype(value)>, serif::model::FixedTotalMass>) {
return serif::model::FixedTotalMass{serif::dimensions::MassValue{2.0}};
} else {
return value;
}
});
CHECK_THROWS_AS(driver::run(bound, driver::StartingStar{unrelated, anchor.state}, correct, family::make_options(bound), 1.0e-9), std::invalid_argument);
}
TEST_CASE("Invalid continuation laws stop without replacing the last converged star", "[unit][solver][continuation][driver]") {
const auto prototype = make_family();
const auto bound = family::in(family::PolytropicIndex{}).with(family::law(family::PolytropicConstant{}, family::depends_on(family::PolytropicIndex{}), [](double index) {
return index == 1.0 || index == 2.0 ? 0.73 : std::numeric_limits<double>::quiet_NaN();
})).bind(prototype.target_model());
auto options = family::make_options(bound);
int calls = 0;
const auto result = driver::run(bound, State{}, [&](auto, State state, driver::CorrectionControls) {
++calls;
return Report{.physical = state};
}, options, 1.0e-8);
CHECK(result.status == driver::Status::invalid_model);
CHECK(calls == 1);
REQUIRE(result.last_converged());
CHECK(result.accepted_coordinates == bound.starting_coordinates());
CHECK(result.counters.fatal_failures == 1);
CHECK(result.total_attempts() == 2);
CHECK_THROWS_AS(driver::run(bound, State{}, [](auto, State state, driver::CorrectionControls) { return Report{.physical = state}; }, options, 0.0), std::invalid_argument);
}
TEST_CASE("Continuation rejects invalid residual certificates before promoting any physical state", "[unit][solver][continuation][driver]") {
const auto bound = make_family();
const State initial{2.0, 3.0, 5.0};
const std::array<std::optional<double>, 4> invalid_norms{
std::nullopt, -1.0, std::numeric_limits<double>::quiet_NaN(), std::numeric_limits<double>::infinity()};
for (const auto invalid : invalid_norms) {
for (const bool reject_initial : {false, true}) {
auto options = family::make_options(bound, family::starting_at(family::PolytropicIndex{}, 1.0));
options.steps.maximum_attempts = 2;
int calls = 0;
const auto result = driver::run(bound, initial, [&](auto, State state, driver::CorrectionControls) {
if (++calls == 1 && !reject_initial) {
state[0] += 1.0;
return Report{.physical = state};
}
return Report{.physical = {999.0, 999.0, 999.0}, .diagnostic = {.final_residual_norm = invalid}};
}, options, 1.0e-8);
CHECK_FALSE(result.converged());
REQUIRE(result.failed);
REQUIRE_FALSE(result.stages.empty());
CHECK(result.stages.back().disposition == driver::StageDisposition::not_converged);
CHECK(initial == State{2.0, 3.0, 5.0});
if (reject_initial) {
CHECK(result.status == driver::Status::starting_star_failed);
CHECK_FALSE(result.last_converged());
CHECK(calls == 1);
} else {
CHECK(result.status == driver::Status::attempt_limit);
REQUIRE(result.last_converged());
CHECK(result.last_converged()->state() == State{3.0, 3.0, 5.0});
CHECK(result.accepted_coordinates == options.start);
CHECK(calls == 2);
}
}
}
}