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

38
tests/solver/archive.cpp Normal file
View File

@@ -0,0 +1,38 @@
#include <sstream>
#include <catch2/catch_test_macros.hpp>
#include "serif/tests/solver/archive.hpp"
TEST_CASE("Validation rechecks reject missing mismatched and repeated coordinate identities", "[unit][solver][validation_archive]") {
namespace archive = serif::tests::solver::archive;
const std::string identity = "coordinates=density_primal_v1;density=0:8;potential=8:8";
std::istringstream valid("stage=seed\n" + identity + "\nstage=complete_converged\n");
CHECK_NOTHROW(archive::require_preparation_identity(valid, identity));
for (const std::string& text : {
std::string{"stage=complete_converged\n"},
std::string{"coordinates=mixed_gravity_v1;density=0:8;potential=8:8\n"},
std::string{"coordinates=density_primal_v1;density=8:8;potential=0:8\n"},
identity + '\n' + identity + '\n'}) {
std::istringstream invalid(text);
CHECK_THROWS(archive::require_preparation_identity(invalid, identity));
}
const std::string mapping = "mapping=kelvin_mesh_radii_v1,r_star_ref=1,r_inf_ref=5";
std::istringstream correct_map(mapping + '\n');
CHECK_NOTHROW(archive::require_preparation_identity(correct_map, mapping));
std::istringstream wrong_map("mapping=kelvin_mesh_radii_v1,r_star_ref=1,r_inf_ref=2\n");
CHECK_THROWS(archive::require_preparation_identity(wrong_map, mapping));
std::istringstream missing_map(identity + '\n');
CHECK_THROWS(archive::require_preparation_identity(missing_map, mapping));
}
TEST_CASE("Validation coordinate identities distinguish equal-length layouts and meshes", "[unit][solver][validation_archive]") {
namespace fixture = serif::tests::solver;
namespace archive = fixture::archive;
using Fields = serif::utils::dependencies::KeyList<fixture::Density, fixture::Potential, fixture::Constant, fixture::Omega, fixture::Shape>;
using SwappedFields = serif::utils::dependencies::KeyList<fixture::Potential, fixture::Density, fixture::Constant, fixture::Omega, fixture::Shape>;
const serif::discretization::blocks::SystemLayout<Fields, Fields> layout{.state_sizes = {8, 8, 1, 1, 25}, .residual_sizes = {8, 8, 1, 1, 25}};
const serif::discretization::blocks::SystemLayout<SwappedFields, SwappedFields> swapped{.state_sizes = layout.state_sizes, .residual_sizes = layout.residual_sizes};
const std::string identity = archive::coordinate_identity(layout, "mesh-a");
CHECK(identity != archive::coordinate_identity(swapped, "mesh-a"));
CHECK(identity != archive::coordinate_identity(layout, "mesh-b"));
CHECK(identity.find("density=0:8;potential=8:8;surface_level=16:1;angular_velocity=17:1;shape=18:25") != std::string::npos);
}

View File

@@ -0,0 +1,197 @@
#include <cmath>
#include <expected>
#include <functional>
#include <limits>
#include <memory>
#include <span>
#include <string>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/solver/nonlinear/newton.hpp"
namespace {
namespace solver = serif::solver;
/** @brief Keep accepted and trial coordinates independently owned. */
struct State {
std::vector<double> coefficients;
/** @brief Edit a candidate's physical coordinate. */
std::span<double> values() { return coefficients; }
/** @brief Inspect an immutable accepted state. */
std::span<const double> values() const { return coefficients; }
};
/** @brief Use unequal state and residual scales to expose coordinate mistakes. */
struct Coordinates {
/** @brief The normalized unknown is twice the physical unknown. */
void denormalize_state(std::span<const double> normalized, std::span<double> physical) const {
physical[0] = normalized[0] / 2.0;
}
};
/** @brief Freeze the residual and derivative belonging to one physical candidate. */
struct System {
std::vector<double> residual_values;
double derivative;
/** @brief Supply the complete normalized residual to the generic merit function. */
const std::vector<double>& residual() const { return residual_values; }
/** @brief Supply the inverse coordinate transform for physical trial steps. */
Coordinates coordinates() const { return {}; }
};
/** @brief Supply only the preparation and ownership contract required by Newton. */
struct Problem {
using Vector = std::vector<double>;
using System = ::System;
using Model = solver::NoModel;
std::function<std::pair<double, double>(double)> equation = [](double value) {
return std::pair{value * value - 2.0, 2.0 * value};
};
/** @brief This algebraic regression deliberately has no stellar description. */
std::shared_ptr<const Model> model_owner() const { return {}; }
/** @brief Negative trial states are an ordinary recoverable numerical rejection. */
std::expected<std::shared_ptr<const System>, std::string> evaluate(const State& state) const {
if (state.values()[0] < 0.0) {
return std::unexpected("negative trial");
}
const auto [residual, derivative] = equation(state.values()[0]);
return std::make_shared<const System>(System{.residual_values = {3.0 * residual}, .derivative = 1.5 * derivative});
}
/** @brief A scalar derivative needs no additional preparation resources. */
auto prepare(const State& state) const { return evaluate(state); }
/** @brief Every injected error represents numerical rejection, not configuration. */
bool recoverable(const std::string&) const { return true; }
/** @brief Preserve the concrete reason exposed by preparation. */
const std::string& failure_message(const std::string& message) const { return message; }
};
/** @brief Solve scalar Newton equations exactly, with optional scripted failures or oversized steps. */
struct ScalarNewton : solver::StandardNewton {
std::shared_ptr<std::vector<double>> guesses{std::make_shared<std::vector<double>>()};
std::function<void(int, std::vector<double>&)> scripted_correction;
/** @brief Record the incoming correction so backtracking reuse is independently observable. */
solver::linear::Report solve(auto&, const auto&, const auto& prepared, const std::vector<double>& rhs,
std::vector<double>& correction, const auto&) const {
guesses->push_back(correction[0]);
correction[0] = rhs[0] / prepared->derivative;
if (scripted_correction) {
scripted_correction(static_cast<int>(guesses->size()), correction);
}
return {.status = solver::linear::Status::converged, .iterations = 1};
}
};
}
TEST_CASE("Backend independent Newton solves in physical coordinates", "[solver][nonlinear][backend_independent]") {
Problem problem;
std::shared_ptr<const System> initial;
const auto observe = [&](const solver::SolveEvent& event, const State&, const auto& system) {
if (event.kind == solver::EventKind::initial) {
initial = system;
}
};
const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.strategies = ScalarNewton{}}, observe);
REQUIRE(result.converged());
CHECK(result.state().values()[0] == Catch::Approx(std::sqrt(2.0)).margin(1.0e-8));
REQUIRE(initial);
CHECK(initial->residual()[0] == -3.0);
CHECK(result.system() != initial);
}
TEST_CASE("Backend independent Newton retains safeguarded corrections", "[solver][nonlinear][backend_independent]") {
Problem problem;
problem.equation = [](double value) { return std::pair{value, 1.0}; };
ScalarNewton strategy;
strategy.scripted_correction = [](int iteration, std::vector<double>& correction) {
correction[0] = iteration == 1 ? -6.0 : -0.5;
};
const auto result = solver::solve_newton(problem, State{{1.0}},
solver::SolverOptions{.method = {.maximum_iterations = 2}, .strategies = strategy});
REQUIRE(result.has_state());
CHECK(result.status() == solver::SolveStatus::maximum_iterations);
REQUIRE(strategy.guesses->size() == 2);
CHECK((*strategy.guesses)[0] == 0.0);
CHECK((*strategy.guesses)[1] == -4.5);
CHECK(result.diagnostics().rejected_trials == 2);
CHECK(result.state().values()[0] == 0.125);
}
TEST_CASE("Backend independent Newton checks correction contracts", "[solver][nonlinear][backend_independent]") {
Problem problem;
ScalarNewton strategy;
SECTION("Nonfinite corrections retain the accepted state") {
strategy.scripted_correction = [](int, std::vector<double>& correction) {
correction[0] = std::numeric_limits<double>::infinity();
};
const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.strategies = strategy});
CHECK(result.status() == solver::SolveStatus::linear_solve_failure);
CHECK(result.state().values()[0] == 1.0);
}
SECTION("Wrong correction sizes reject the backend contract") {
strategy.scripted_correction = [](int, std::vector<double>& correction) { correction.resize(2); };
CHECK_THROWS_AS(solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.strategies = strategy}), std::logic_error);
}
}
TEST_CASE("Observer lifecycle covers rejected initial states without publishing a fictitious snapshot", "[solver][nonlinear][observer]") {
struct Observer {
int& starts;
int& finishes;
int& events;
solver::SolveStatus& status;
/** @brief Record entry before the initial physical evaluation. */
void started() { ++starts; }
/** @brief Receive termination even when there is no accepted state. */
void finished(solver::SolveStatus value, const solver::SolveDiagnostics&) {
++finishes;
status = value;
}
/** @brief Count only events that provide a valid physical snapshot. */
void operator()(const solver::SolveEvent&, const State&, const std::shared_ptr<const System>&) { ++events; }
};
Problem problem;
int starts = 0, finishes = 0, events = 0;
solver::SolveStatus status = solver::SolveStatus::converged;
solver::SolveTiming timing{.seconds = 12.0};
const auto result = solver::solve_newton(problem, State{{-1.0}}, solver::SolverOptions{.strategies = ScalarNewton{}},
solver::TimedObserver{timing, Observer{starts, finishes, events, status}});
CHECK(result.status() == solver::SolveStatus::initial_state_rejected);
CHECK_FALSE(result.has_state());
CHECK(starts == 1);
CHECK(finishes == 1);
CHECK(events == 0);
CHECK(status == result.status());
CHECK(timing.seconds >= 12.0);
}
TEST_CASE("Optional timing preserves callback stop requests and numerical results", "[solver][nonlinear][observer]") {
Problem problem;
const auto stop = [](const solver::SolveEvent& event, const State&, const auto&) {
return event.kind == solver::EventKind::accepted ? solver::SolveControl::stop : solver::SolveControl::continue_solve;
};
const auto options = solver::SolverOptions{.strategies = ScalarNewton{}};
const auto ordinary = solver::solve_newton(problem, State{{0.1}}, options, stop);
solver::SolveTiming timing;
const auto observed = solver::solve_newton(problem, State{{0.1}}, options, solver::TimedObserver{timing, stop});
CHECK(observed.status() == ordinary.status());
CHECK(observed.state().values()[0] == ordinary.state().values()[0]);
CHECK(observed.diagnostics().attempted_iterations == ordinary.diagnostics().attempted_iterations);
CHECK(observed.diagnostics().line_search_trials == ordinary.diagnostics().line_search_trials);
CHECK(std::isfinite(timing.seconds));
CHECK(timing.seconds >= 0.0);
}
TEST_CASE("Timing does not count solves interrupted by the final user callback", "[solver][nonlinear][observer]") {
Problem problem;
solver::SolveTiming timing{.seconds = 7.0};
const auto reject_reporting = [](const solver::SolveEvent& event, const State&, const auto&) {
serif::utils::errors::serif_ensure<serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(
event.kind != solver::EventKind::finished, "Injected final reporting failure.");
};
CHECK_THROWS(solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.strategies = ScalarNewton{}},
solver::TimedObserver{timing, reject_reporting}));
CHECK(timing.seconds == 7.0);
}

View File

@@ -0,0 +1,294 @@
#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); });
}

View File

@@ -0,0 +1,227 @@
#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);
}
}
}
}

View File

@@ -0,0 +1,288 @@
#include <algorithm>
#include <array>
#include <memory>
#include <string_view>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/solver/continuation/continuation.hpp"
#include "serif/solver/continuation/equilibrium.hpp"
#include "serif/backend/mfem/solver/seed/state.hpp"
#include "serif/solver/seed/polytrope.hpp"
#include "serif/tests/solver/stellar_fixture.hpp"
#include "serif/utils/dependencies/timing.hpp"
namespace {
namespace family_api = serif::solver::continuation;
namespace solver = serif::solver;
namespace model = serif::model;
namespace backend = model::backend::mfem;
namespace fixture = serif::tests::solver;
namespace dependencies = serif::utils::dependencies;
namespace dimensions = serif::dimensions;
namespace normalization = solver::normalization;
using Density = fixture::Density;
/** @brief A known density field isolates public continuation orchestration from stellar conditioning. */
struct DensityEquation : serif::equations::Contribution<serif::discretization::forms::Operand<Density>> {
using Value = serif::discretization::forms::Operand<Density>;
using Operands = serif::utils::types::TypeList<Value>;
using ResidualQuantity = dimensions::MassDensity;
static constexpr std::string_view name = "continuation density regression";
double target;
/** @brief Bind a physical density target read from the fresh stage's EOS. */
explicit DensityEquation(double density) : target(density) {}
/** @brief Integrate the difference from the exact representable constant field. */
[[nodiscard]] serif::equations::PointLinearization<Operands> evaluate(const serif::equations::EquationPoint<Operands>& point) const {
serif::equations::PointLinearization<Operands> result;
result.residual = point.fields.template get<Density>() - target;
result.field_derivatives.template get<Density>() = 1.0;
return result;
}
/** @brief A constant target adds no polynomial order. */
[[nodiscard]] int additional_order(Value, int) const { return 0; }
};
/** @brief Rebuild an explicitly simple equation from the same EOS parameter family as a full star. */
[[nodiscard]] auto density_model() {
return model::make_stellar_model(serif::eos::models::Polytrope{1.5, 1.0}, [](const auto& physics) {
return model::EquilibriumEquations{.interior_equations = serif::equations::EquationRegistry{
DensityEquation{physics.equation_of_state().polytropic_index()}}};
});
}
/** @brief Bind only density and prescribed displacement, retaining the real stellar reference mesh. */
[[nodiscard]] auto density_discretization(const std::shared_ptr<fixture::StellarResources>& resources) {
return backend::make_stellar_discretization(resources, dependencies::make_dependency_context(
dependencies::borrow<Density>(resources->density), dependencies::borrow<fixture::Displacement>(resources->displacement)),
*resources->mesh.exterior_coordinate->values, resources->mapper);
}
/** @brief Change numerical normalization deliberately while preserving physical coefficient meanings. */
[[nodiscard]] auto numerical_coordinates(double radius) {
return normalization::NormalizationOptions{.scheme = *normalization::prepare_stellar_reference_scales(
dimensions::MassValue{1.0}, dimensions::LengthValue{radius}, dimensions::QuantityValue<dimensions::GravitationalConstant>{1.0})};
}
}
TEST_CASE("Public continuation solves fresh models and transfers physical finite-element coefficients", "[integration][solver][continuation]") {
const auto resources = fixture::make_resources();
const auto finite_elements = density_discretization(resources);
const auto family = family_api::in(family_api::PolytropicIndex{}).with(
family_api::preserve_pressure_at_reference_density(dimensions::DensityValue{1.0})).bind(density_model());
using Description = decltype(family.target_model().equations()(family.target_model()));
using Plan = model::EquilibriumPlan<serif::eos::models::Polytrope, Description>;
::mfem::Vector displacement(resources->displacement.size());
displacement = 0.0;
auto state = model::StellarState(backend::equilibrium_layout<Plan>(finite_elements, displacement));
std::ranges::fill(state.values(), 0.8);
int configurations = 0;
const auto configure = [&](auto physical) {
++configurations;
const double n = physical.equation_of_state().polytropic_index();
return ::serif::model::make_discretization(::serif::model::DiscretizationOptions{
.model = std::move(physical), .finite_elements = finite_elements, .normalization = numerical_coordinates(n),
.preconditioner = solver::preconditioner::make_preconditioner<Density>(solver::preconditioner::strategies::inverse::Diagonal{})});
};
auto options = family_api::make_options(family);
options.steps.initial_step_fraction = {1.0};
const auto result = solver::continue_equilibrium(family, configure, state, displacement, options);
REQUIRE(result.converged());
REQUIRE(result.last_converged() != nullptr);
CHECK(configurations == 2);
CHECK(result.last_converged()->model().equation_of_state().polytropic_index() == 1.5);
CHECK(result.last_converged()->model().equation_of_state().polytropic_constant() == 1.0);
CHECK(*result.last_converged()->diagnostics().convergence_threshold == 1.0e-10);
CHECK(*result.last_converged()->diagnostics().final_residual_norm <= 1.0e-10);
for (const double density : result.last_converged()->state().values()) {
CHECK(density == Catch::Approx(1.5).margin(1.0e-8));
}
CHECK(std::ranges::all_of(state.values(), [](double value) { return value == 0.8; }));
SECTION("a same-size different mesh cannot silently reinterpret physical coefficients") {
const auto other_resources = fixture::make_resources();
int calls = 0;
const auto changed_resources = [&](auto physical) {
auto configuration = configure(std::move(physical));
return ::serif::model::DiscretizationOptions{.model = std::move(configuration.model),
.finite_elements = ++calls > 1 ? density_discretization(other_resources) : finite_elements,
.normalization = std::move(configuration.normalization), .preconditioner = std::move(configuration.preconditioner)};
};
CHECK_THROWS_AS(solver::continue_equilibrium(family, changed_resources, state, displacement, options), std::invalid_argument);
CHECK(calls == 2);
}
SECTION("the configuration factory cannot change an exact physical target") {
const auto wrong_model = [&](auto physical) {
return configure(physical.transform_specifications([](const auto&) { return serif::eos::models::Polytrope{1.7, 1.0}; }));
};
CHECK_THROWS_AS(solver::continue_equilibrium(family, wrong_model, state, displacement, options), std::invalid_argument);
}
SECTION("configuration callback failures do not become continuation retries") {
int calls = 0;
const auto rejected = [&](auto physical) {
++calls;
serif::utils::errors::serif_ensure<serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT, std::invalid_argument>(false, "Deliberately invalid configuration.");
return configure(std::move(physical));
};
CHECK_THROWS_AS(solver::continue_equilibrium(family, rejected, state, displacement, options), std::invalid_argument);
CHECK(calls == 1);
}
SECTION("fixed-geometry solves accept reused and consumed configurations") {
auto configuration = configure(family.model_at(family.target_coordinates()));
auto options = solver::continuation::default_solver_options();
CHECK(solver::solve_equilibrium(configuration, result.last_converged()->state(), displacement, options).converged());
CHECK(solver::solve_equilibrium(std::move(configuration), result.last_converged()->state(), displacement, std::move(options)).converged());
}
}
TEST_CASE("Public continuation qualifies the complete moving stellar assembly before declaring an anchor", "[integration][solver][continuation]") {
const auto original = fixture::make_model();
const auto target = model::make_stellar_model(original.equation_of_state(), family_api::identified_equations(original.equations(),
dimensions::QuantityValue<dimensions::GravitationalConstant>{1.0}),
original.specification<model::FixedTotalMass>(), original.specification<model::FixedAngularMomentum>());
const auto family = family_api::in(family_api::AngularMomentum{}).bind(target);
const auto radial = solver::seed::make_polytropic_seed(target);
REQUIRE(radial);
const auto resources = fixture::make_resources(radial->scales.radius.value());
const auto finite_elements = fixture::make_discretization(resources, 0);
const auto state = solver::seed::backend::mfem::make_polytropic_state(target, finite_elements, *radial);
REQUIRE(state);
const auto configure = [&](auto physical) {
return ::serif::model::make_discretization(::serif::model::DiscretizationOptions{.model = std::move(physical),
.finite_elements = finite_elements, .normalization = numerical_coordinates(radial->scales.radius.value())});
};
auto solver_options = solver::continuation::default_solver_options();
solver_options.method.maximum_iterations = 0;
const auto result = solver::continue_equilibrium(family, configure, *state, family_api::make_options(family), solver_options);
CHECK_FALSE(result.converged());
CHECK(result.last_converged() == nullptr);
REQUIRE(result.failed.get() != nullptr);
CHECK(result.failed.get()->has_state());
CHECK(result.failed.get()->status() == solver::SolveStatus::maximum_iterations);
CHECK(*result.failed.get()->diagnostics().convergence_threshold == 1.0e-10);
auto configuration = configure(family.model_at(family.target_coordinates()));
CHECK(solver::solve_equilibrium(configuration, *state, solver_options).status() == solver::SolveStatus::maximum_iterations);
CHECK(solver::solve_equilibrium(std::move(configuration), *state, std::move(solver_options)).status() == solver::SolveStatus::maximum_iterations);
}
TEST_CASE("Graph timing forwards through fixed bindings and preserves solve and restart results", "[integration][solver][graph_timing]") {
const auto physical = density_model();
const auto resources = fixture::make_resources();
const auto finite_elements = density_discretization(resources);
using Description = decltype(physical.equations()(physical));
using Plan = model::EquilibriumPlan<serif::eos::models::Polytrope, Description>;
::mfem::Vector displacement(resources->displacement.size());
displacement = 0.0;
auto state = model::StellarState(backend::equilibrium_layout<Plan>(finite_elements, displacement));
std::ranges::fill(state.values(), 0.8);
const auto configuration = model::make_discretization(model::DiscretizationOptions{
.model = physical, .finite_elements = finite_elements, .normalization = numerical_coordinates(1.0),
.preconditioner = solver::preconditioner::make_preconditioner<Density>(solver::preconditioner::strategies::inverse::Diagonal{})});
const auto prescribed = dependencies::make_dependency_context();
SECTION("physical and configured bindings accept owning states and explicit field views") {
dependencies::GraphTiming timing;
const auto observer_factory = dependencies::time_graph_nodes(timing);
auto physical_state = backend::bind_stellar_model(physical, finite_elements, state, displacement, {}, prescribed, observer_factory);
auto normalized_state = backend::bind_stellar_model(configuration, state, displacement, {}, prescribed,
backend::detail::NoAdditionalComputations{}, observer_factory);
const auto fields = backend::detail::state_coefficients(finite_elements, state, prescribed);
auto physical_views = backend::bind_stellar_model(physical, finite_elements, fields.context(), displacement, {}, observer_factory);
auto normalized_views = backend::bind_stellar_model(configuration, fields.context(), displacement, {}, observer_factory);
REQUIRE(timing.runs().size() == 4);
REQUIRE(physical_state.evaluate(state, displacement));
REQUIRE(normalized_state.evaluate(state, displacement));
REQUIRE(physical_views.evaluate(fields.context(), displacement));
REQUIRE(normalized_views.evaluate(fields.context(), displacement));
for (const auto& run : timing.runs()) {
CHECK(std::ranges::any_of(run->nodes, [](const auto& node) { return node.completed != 0; }));
CHECK(std::ranges::all_of(run->nodes, [](const auto& node) { return node.rejected == 0 && node.exceptions == 0; }));
}
const auto first = timing.runs().front()->nodes;
REQUIRE(physical_state.evaluate(state, displacement));
for (const auto& node : timing.runs().front()->nodes) {
CHECK(node.calls == first[node.index].calls);
CHECK(node.seconds == first[node.index].seconds);
}
}
SECTION("timing does not change physical results, iteration counts, or checkpoint history") {
auto options = solver::continuation::default_solver_options();
const auto plain = solver::solve_equilibrium(configuration, state, displacement, options);
dependencies::GraphTiming timing;
const auto timed = solver::solve_equilibrium(configuration, state, displacement, options, solver::NoObserver{}, {}, prescribed,
dependencies::time_graph_nodes(timing));
REQUIRE(plain.converged());
REQUIRE(timed.converged());
CHECK(timed.diagnostics().accepted_iterations == plain.diagnostics().accepted_iterations);
CHECK(timed.diagnostics().total_linear_iterations == plain.diagnostics().total_linear_iterations);
CHECK(timed.diagnostics().line_search_trials == plain.diagnostics().line_search_trials);
CHECK(timed.diagnostics().final_residual_norm == plain.diagnostics().final_residual_norm);
CHECK(std::ranges::equal(timed.state().values(), plain.state().values()));
REQUIRE(timing.runs().size() == 1);
CHECK(std::ranges::any_of(timing.runs().front()->nodes, [](const auto& node) { return node.completed > 1; }));
options.method.maximum_iterations = 0;
const auto stopped = solver::solve_equilibrium(configuration, state, displacement, options);
REQUIRE(stopped.has_checkpoint());
CHECK(stopped.status() == solver::SolveStatus::maximum_iterations);
options = solver::continuation::default_solver_options();
const auto resumed = solver::resume_equilibrium(configuration, stopped.checkpoint(), displacement, options, solver::NoObserver{}, {}, prescribed,
dependencies::time_graph_nodes(timing));
REQUIRE(resumed.converged());
CHECK(std::ranges::equal(resumed.state().values(), plain.state().values()));
CHECK(resumed.diagnostics().accepted_iterations == plain.diagnostics().accepted_iterations);
CHECK(resumed.diagnostics().final_residual_norm == plain.diagnostics().final_residual_norm);
REQUIRE(timing.runs().size() == 2);
CHECK(timing.runs()[0] != timing.runs()[1]);
}
}
TEST_CASE("Moving-star node timings remain separate across a rebuilt checkpoint", "[integration][solver][graph_timing]") {
const auto physical = fixture::make_model();
const auto radial = solver::seed::make_polytropic_seed(physical);
REQUIRE(radial);
const auto resources = fixture::make_resources(radial->scales.radius.value());
const auto finite_elements = fixture::make_discretization(resources, 0);
const auto state = solver::seed::backend::mfem::make_polytropic_state(physical, finite_elements, *radial);
REQUIRE(state);
const auto configuration = model::make_discretization(model::DiscretizationOptions{
.model = physical, .finite_elements = finite_elements, .normalization = numerical_coordinates(radial->scales.radius.value())});
const auto prescribed = dependencies::make_dependency_context();
dependencies::GraphTiming physical_timing;
auto physical_evaluator = backend::bind_stellar_model(physical, finite_elements, *state, {}, prescribed,
dependencies::time_graph_nodes(physical_timing));
REQUIRE(physical_timing.runs().size() == 1);
CHECK(std::ranges::all_of(physical_timing.runs().front()->nodes, [](const auto& node) { return node.calls == 0; }));
CHECK(physical_evaluator.layout() == state->layout());
auto options = solver::continuation::default_solver_options();
options.method.maximum_iterations = 0;
const auto plain = solver::solve_equilibrium(configuration, *state, options);
dependencies::GraphTiming timing;
const auto timed = solver::solve_equilibrium(configuration, *state, options, solver::NoObserver{}, {}, prescribed,
dependencies::time_graph_nodes(timing));
REQUIRE(plain.has_checkpoint());
REQUIRE(timed.has_checkpoint());
CHECK(timed.status() == plain.status());
CHECK(timed.diagnostics().final_residual_norm == plain.diagnostics().final_residual_norm);
CHECK(std::ranges::equal(timed.state().values(), plain.state().values()));
REQUIRE(timing.runs().size() == 1);
const auto before_resume = timing.runs().front()->nodes;
const auto resumed = solver::resume_equilibrium(configuration, timed.checkpoint(), options, solver::NoObserver{}, {}, prescribed,
dependencies::time_graph_nodes(timing));
REQUIRE(resumed.has_checkpoint());
CHECK(resumed.status() == plain.status());
CHECK(resumed.diagnostics().final_residual_norm == plain.diagnostics().final_residual_norm);
CHECK(std::ranges::equal(resumed.state().values(), plain.state().values()));
REQUIRE(timing.runs().size() == 2);
CHECK(timing.runs()[0] != timing.runs()[1]);
for (const auto& node : timing.runs().front()->nodes) {
CHECK(node.calls == before_resume[node.index].calls);
CHECK(node.seconds == before_resume[node.index].seconds);
CHECK(node.calls == timing.runs()[1]->nodes[node.index].calls);
}
}

View File

@@ -0,0 +1,223 @@
#include <array>
#include <cmath>
#include <limits>
#include <memory>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/solver/continuation/polytropic.hpp"
#include "serif/solver/continuation/identified_equations.hpp"
namespace {
namespace family = serif::solver::continuation;
namespace model = serif::model;
namespace dimensions = serif::dimensions;
using Polytrope = serif::eos::models::Polytrope;
struct Definition {
double pressure{0.01};
/** @brief State explicitly which captured physical input belongs to family identity. */
[[nodiscard]] std::array<double, 1> continuation_identity() const { return {pressure}; }
};
/** @brief Retain all physical prescriptions while changing selected independent inputs. */
[[nodiscard]] auto target_model(double index = 3.0, double constant = 0.36029966935264141, double momentum = 0.035,
double mass = 1.0, double pressure = 0.01,
std::array<double, 3> axis = {1.0, 2.0, 3.0}, std::array<double, 3> center = {0.2, -0.3, 0.4}) {
return model::make_stellar_model(Polytrope{index, constant}, Definition{pressure},
model::FixedTotalMass{dimensions::MassValue{mass}},
model::FixedAngularMomentum{{.angular_momentum = dimensions::AngularMomentumValue{momentum}, .axis = axis, .center = center}});
}
/** @brief Evaluate pressure independently through the EOS relation. */
[[nodiscard]] double pressure(const Polytrope& eos, double density) {
return eos.evaluate(serif::eos::relations::PressureFromDensity{}, dimensions::DensityValue{density}).value();
}
/** @brief Compare values using a relative tolerance that does not privilege one system of units. */
[[nodiscard]] bool close(double actual, double expected) {
return std::abs(actual - expected) <= 128.0 * std::numeric_limits<double>::epsilon() * std::abs(expected);
}
class TestEOS;
struct TestPressure {
using Specification = TestEOS;
using Value = dimensions::PressureValue;
static constexpr std::string_view name = "test pressure";
static constexpr std::string_view stable_id = "test.pressure";
/** @brief Read the extension's selected physical pressure. */
[[nodiscard]] static Value read(const TestEOS& value);
/** @brief Test extension domain validation without exceptions during candidate preparation. */
[[nodiscard]] static bool valid(Value value) { return std::isfinite(value.value()) && value.value() > 0.0; }
};
class TestEOS {
public:
using ContinuationParameters = family::ParameterList<TestPressure>;
double pressure{3.0};
bool ignore_updates{false};
double clone_offset{};
/** @brief Include all physical values; fault-injection switches are test behavior. */
[[nodiscard]] std::array<double, 1> continuation_identity() const { return {pressure}; }
/** @brief Deliberately allow a faulty extension to exercise installed-value readback. */
template <typename Values>
[[nodiscard]] TestEOS with_continuation_parameters(const Values& values) const {
TestEOS result = *this;
if (!ignore_updates) {
result.pressure = values.get(TestPressure{}).value();
}
return result;
}
/** @brief Deliberately allow a faulty clone to exercise exact target identity. */
[[nodiscard]] TestEOS clone_for_continuation() const {
TestEOS result = *this;
result.pressure += clone_offset;
return result;
}
};
TestPressure::Value TestPressure::read(const TestEOS& value) { return Value{value.pressure}; }
struct MoveOnlyDefinition {
std::unique_ptr<double> pressure;
/** @brief Owning captures still identify physical family inputs. */
[[nodiscard]] std::array<double, 1> continuation_identity() const { return {*pressure}; }
};
}
TEST_CASE("Physical continuation preserves pressure pivots and exact target prescriptions", "[unit][solver][continuation][family]") {
const auto target = target_model();
const auto bound = family::in(family::PolytropicIndex{}, family::AngularMomentum{})
.with(family::preserve_pressure_at_reference_density(dimensions::DensityValue{0.2})).bind(target);
CHECK(bound.target_coordinates() == std::array<double, 2>{3.0, 0.035});
CHECK(bound.starting_coordinates() == std::array<double, 2>{1.0, 0.0});
CHECK(bound.parameter_descriptors()[0].stable_id == "polytrope.index");
CHECK(bound.parameter_descriptors()[1].stable_id == "fixed_angular_momentum.target");
for (double index : {1.0, 1.5, 2.0, 2.999999, 3.0, 3.5}) {
const auto stage = bound.model_at({index, 0.02});
CHECK(close(pressure(stage.equation_of_state(), 0.2), pressure(target.equation_of_state(), 0.2)));
CHECK(stage.template specification<model::FixedTotalMass>().target().value() == 1.0);
CHECK(stage.equations().pressure == 0.01);
const auto& rotation = stage.template specification<model::FixedAngularMomentum>();
CHECK(rotation.target().value() == 0.02);
CHECK(rotation.axis() == target.template specification<model::FixedAngularMomentum>().axis());
CHECK(rotation.center() == target.template specification<model::FixedAngularMomentum>().center());
REQUIRE(bound.try_validate_member(stage));
}
const auto endpoint = bound.model_at(bound.target_coordinates());
CHECK(endpoint.equation_of_state().polytropic_constant() == target.equation_of_state().polytropic_constant());
CHECK(bound.model_signature(endpoint) == bound.model_signature(target));
CHECK(bound.coordinates_of(endpoint) == bound.target_coordinates());
}
TEST_CASE("Pressure pivot families are invariant under consistent changes of physical units", "[unit][solver][continuation][family]") {
constexpr double density = 0.4, density_units = 100.0, pressure_units = 0.01, index = 1.5, constant = 0.73;
const double converted = pressure_units * constant / std::pow(density_units, 1.0 + 1.0 / index);
const auto first = family::in(family::PolytropicIndex{}).with(family::preserve_pressure_at_reference_density(dimensions::DensityValue{density})).bind(target_model(index, constant));
const auto second = family::in(family::PolytropicIndex{}).with(family::preserve_pressure_at_reference_density(dimensions::DensityValue{density * density_units})).bind(target_model(index, converted));
for (double candidate : {1.0, 2.0, 3.0}) {
const auto original = first.model_at({candidate}), rescaled = second.model_at({candidate});
for (double sample : {0.01, 0.4, 3.0}) {
CHECK(close(pressure(rescaled.equation_of_state(), sample * density_units), pressure_units * pressure(original.equation_of_state(), sample)));
}
}
}
TEST_CASE("Continuation family membership checks all unchanged prescriptions and captured equations", "[unit][solver][continuation][family]") {
const auto bound = family::in(family::AngularMomentum{}).bind(target_model());
CHECK(bound.try_validate_member(target_model(3.0, 0.36029966935264141, 0.01)));
CHECK_FALSE(bound.try_validate_member(target_model(2.0)));
CHECK_FALSE(bound.try_validate_member(target_model(3.0, 0.3)));
CHECK_FALSE(bound.try_validate_member(target_model(3.0, 0.36029966935264141, 0.01, 2.0)));
CHECK_FALSE(bound.try_validate_member(target_model(3.0, 0.36029966935264141, 0.01, 1.0, 0.02)));
CHECK_FALSE(bound.try_validate_member(target_model(3.0, 0.36029966935264141, 0.01, 1.0, 0.01, {0.0, 0.0, 1.0})));
CHECK_FALSE(bound.try_validate_member(target_model(3.0, 0.36029966935264141, 0.01, 1.0, 0.01, {1.0, 2.0, 3.0}, {})));
const auto index_family = family::in(family::PolytropicIndex{})
.with(family::preserve_pressure_at_reference_density(dimensions::DensityValue{0.2})).bind(target_model());
CHECK_FALSE(index_family.try_validate_member(target_model(1.5, 0.36029966935264141, 0.035)));
}
TEST_CASE("Continuation laws report invalid numerical candidates and reject inconsistent configuration", "[unit][solver][continuation][family]") {
const double nan = std::numeric_limits<double>::quiet_NaN();
for (double invalid : {0.0, -1.0, nan, std::numeric_limits<double>::infinity()}) {
CHECK_THROWS_AS(family::preserve_pressure_at_reference_density(dimensions::DensityValue{invalid}), std::invalid_argument);
}
const auto mismatched = family::in(family::PolytropicIndex{}).with(family::law(family::PolytropicConstant{}, family::depends_on(family::PolytropicIndex{}), [](double) { return 2.0; }));
CHECK_THROWS_AS(mismatched.bind(target_model()), std::invalid_argument);
const auto broken = family::in(family::PolytropicIndex{}).with(family::law(family::PolytropicConstant{}, family::depends_on(family::PolytropicIndex{}),
[nan](double index) { return index == 3.0 ? 0.36029966935264141 : nan; })).bind(target_model());
CHECK_FALSE(broken.try_model_at({2.0}));
CHECK_FALSE(broken.try_model_at({0.5}));
CHECK_FALSE(broken.try_model_at({nan}));
const auto physical = family::in(family::PolytropicIndex{}, family::AngularMomentum{})
.with(family::preserve_pressure_at_reference_density(dimensions::DensityValue{0.2})).bind(target_model());
CHECK_FALSE(physical.try_model_at({0.5, 0.01}));
CHECK_FALSE(physical.try_model_at({2.0, -0.01}));
CHECK_FALSE(physical.try_model_at({std::numeric_limits<double>::infinity(), 0.01}));
CHECK_FALSE(physical.try_model_at({2.0, nan}));
const auto overflowing = family::in(family::PolytropicIndex{}).with(
family::preserve_pressure_at_reference_density(dimensions::DensityValue{std::numeric_limits<double>::max()}));
CHECK_THROWS_AS(overflowing.bind(target_model()), std::invalid_argument);
using Function = double (*)(double);
CHECK_THROWS_AS(family::law(family::PolytropicConstant{}, family::depends_on(family::PolytropicIndex{}), static_cast<Function>(nullptr)), std::invalid_argument);
const family::ParameterLaw<family::PolytropicConstant, family::ParameterList<family::PolytropicIndex>, Function> bypassed{nullptr};
CHECK_THROWS_AS(family::in(family::PolytropicIndex{}).with(bypassed).bind(target_model()), std::invalid_argument);
using Member = double (dimensions::AngularMomentumValue::*)() const noexcept;
CHECK_THROWS_AS(family::law(family::PolytropicConstant{}, family::depends_on(family::AngularMomentum{}), static_cast<Member>(nullptr)), std::invalid_argument);
}
TEST_CASE("Continuation owns move-only laws and explicitly reproducible equation definitions", "[unit][solver][continuation][family]") {
auto definition = family::in(family::PolytropicIndex{}).with(family::law(family::PolytropicConstant{}, family::depends_on(family::PolytropicIndex{}),
[constant = std::make_unique<double>(0.3)](double index) { return *constant * index / 3.0; }));
const auto bound = std::move(definition).bind(target_model(3.0, 0.3));
CHECK(bound.model_at({1.5}).equation_of_state().polytropic_constant() == Catch::Approx(0.15));
CHECK(bound.model_at({3.0}).equation_of_state().polytropic_constant() == 0.3);
auto target = model::make_stellar_model(Polytrope{3.0, 0.3}, MoveOnlyDefinition{std::make_unique<double>(0.01)},
model::FixedAngularMomentum{dimensions::AngularMomentumValue{0.1}});
const auto movable = family::in(family::AngularMomentum{}).bind(std::move(target), [](const MoveOnlyDefinition& original) {
return MoveOnlyDefinition{std::make_unique<double>(*original.pressure)};
});
auto first = movable.model_at({0.02}), second = movable.model_at({0.04});
CHECK(first.equations().pressure.get() != second.equations().pressure.get());
CHECK(movable.try_validate_member(first));
}
TEST_CASE("Custom physical families verify installed values and exact target cloning", "[unit][solver][continuation][family]") {
const auto target = model::make_stellar_model(TestEOS{}, Definition{});
const auto custom = family::in(TestPressure{}).bind(target);
CHECK(custom.starting_coordinates() == std::array<double, 1>{3.0});
CHECK(custom.target_coordinates() == std::array<double, 1>{3.0});
const auto stage = custom.model_at({2.0});
CHECK(stage.equation_of_state().pressure == 2.0);
CHECK(custom.try_validate_member(stage));
const auto faulty = model::make_stellar_model(TestEOS{.ignore_updates = true}, Definition{},
model::FixedAngularMomentum{dimensions::AngularMomentumValue{0.1}});
const auto ignores = family::in(TestPressure{}).bind(faulty);
CHECK(ignores.try_model_at(ignores.target_coordinates()));
CHECK_FALSE(ignores.try_model_at({2.0}));
CHECK_FALSE(ignores.try_model_at({std::nextafter(3.0, 2.0)}));
CHECK(ignores.target_model().equation_of_state().pressure == 3.0);
const auto derived = family::in(family::AngularMomentum{}).with(family::law(TestPressure{}, family::depends_on(family::AngularMomentum{}),
[](dimensions::AngularMomentumValue momentum) { return dimensions::PressureValue{30.0 * momentum.value()}; })).bind(faulty);
CHECK(derived.try_model_at(derived.target_coordinates()));
CHECK_FALSE(derived.try_model_at({0.05}));
CHECK(derived.target_model().equation_of_state().pressure == 3.0);
const auto bad_clone = model::make_stellar_model(TestEOS{.clone_offset = std::numeric_limits<double>::epsilon() * 3.0}, Definition{},
model::FixedAngularMomentum{dimensions::AngularMomentumValue{0.1}});
CHECK_THROWS_AS(family::in(family::AngularMomentum{}).bind(bad_clone), std::invalid_argument);
CHECK(bad_clone.equation_of_state().pressure == 3.0);
}
TEST_CASE("Identified equation definitions retain typed captures and support move-only callables", "[unit][solver][continuation][family]") {
auto equations = family::identified_equations([offset = std::make_unique<double>(2.0)](const auto& model) {
return model.equation_of_state().polytropic_index() + *offset;
}, dimensions::PressureValue{0.02}, dimensions::QuantityValue<dimensions::GravitationalConstant>{1.0});
STATIC_CHECK_FALSE(std::copy_constructible<decltype(equations)>);
STATIC_CHECK(std::same_as<std::tuple_element_t<0, decltype(equations.identity)>, dimensions::PressureValue>);
CHECK(equations.continuation_identity() == std::array{0.02, 1.0});
CHECK(equations(target_model()) == 5.0);
auto moved = std::move(equations);
CHECK(moved(target_model(1.5)) == 3.5);
CHECK(moved.continuation_identity() == std::array{0.02, 1.0});
}

View File

@@ -0,0 +1,116 @@
#include <limits>
#include <catch2/catch_test_macros.hpp>
#include "serif/solver/continuation/polytropic.hpp"
#include "serif/solver/continuation/options.hpp"
namespace {
namespace family = serif::solver::continuation;
namespace controller = serif::solver::continuation;
/** @brief Make physical settings tests independent of finite-element resources. */
[[nodiscard]] auto bound_family() {
const auto target = serif::model::make_stellar_model(serif::eos::models::Polytrope{2.0, 0.73}, []() {},
serif::model::FixedAngularMomentum{serif::dimensions::AngularMomentumValue{0.1}});
return family::in(family::PolytropicIndex{}, family::AngularMomentum{})
.with(family::preserve_pressure_at_reference_density(serif::dimensions::DensityValue{0.2})).bind(target);
}
}
TEST_CASE("Continuation starting settings reject duplicates and invalid domains without mutation", "[unit][solver][continuation][settings]") {
const auto bound = bound_family();
auto options = family::make_options(bound);
CHECK(options.start == bound.starting_coordinates());
CHECK_FALSE(options.explicit_start);
family::starting_at(family::PolytropicIndex{}, 1.5).apply(options);
CHECK(options.start == std::array<double, 2>{1.5, 0.0});
CHECK(options.specified == std::array<bool, 2>{true, false});
CHECK(options.explicit_start);
CHECK_THROWS_AS(family::starting_at(family::PolytropicIndex{}, 1.7).apply(options), std::invalid_argument);
CHECK(options.start == std::array<double, 2>{1.5, 0.0});
for (double invalid : {0.5, std::numeric_limits<double>::quiet_NaN(), std::numeric_limits<double>::infinity()}) {
auto unchanged = family::make_options(bound);
CHECK_THROWS_AS(family::starting_at(family::PolytropicIndex{}, invalid).apply(unchanged), std::invalid_argument);
CHECK(unchanged.start == bound.starting_coordinates());
CHECK(unchanged.specified == std::array<bool, 2>{false, false});
CHECK_FALSE(unchanged.explicit_start);
}
CHECK_THROWS_AS(family::starting_at(family::AngularMomentum{}, serif::dimensions::AngularMomentumValue{-0.01}).apply(options), std::invalid_argument);
CHECK(options.specified == std::array<bool, 2>{true, false});
family::starting_at(family::AngularMomentum{}, serif::dimensions::AngularMomentumValue{0.01}).apply(options);
CHECK(options.start == std::array<double, 2>{1.5, 0.01});
CHECK(options.specified == std::array<bool, 2>{true, true});
CHECK_THROWS_AS(family::starting_at(family::AngularMomentum{}, serif::dimensions::AngularMomentumValue{0.02}).apply(options), std::invalid_argument);
CHECK(options.start == std::array<double, 2>{1.5, 0.01});
}
TEST_CASE("Continuation physical ordering and numerical controls are independent of argument order", "[unit][solver][continuation][settings]") {
const auto bound = bound_family();
controller::CoordinateOptions<2> numerical;
numerical.initial_step_fraction = {0.1, 0.2};
numerical.minimum_step_fraction = {0.001, 0.002};
numerical.maximum_step_fraction = {0.5, 0.75};
numerical.growth_factor = 1.25;
numerical.shrink_factor = 0.4;
numerical.maximum_attempts = 21;
numerical.maximum_rejections_per_axis = 3;
numerical.order = controller::CoordinateOrder::strict;
const auto first = family::make_options(bound, family::prefer(family::AngularMomentum{}), family::numerics(numerical));
const auto second = family::make_options(bound, family::numerics(numerical), family::prefer(family::AngularMomentum{}));
for (const auto* options : {&first, &second}) {
CHECK(options->has_ordering);
CHECK(options->has_numerics);
CHECK(options->steps.order == controller::CoordinateOrder::preferred);
CHECK(options->steps.axis_order == std::array<std::size_t, 2>{1, 0});
CHECK(options->steps.initial_step_fraction == numerical.initial_step_fraction);
CHECK(options->steps.minimum_step_fraction == numerical.minimum_step_fraction);
CHECK(options->steps.maximum_step_fraction == numerical.maximum_step_fraction);
CHECK(options->steps.growth_factor == numerical.growth_factor);
CHECK(options->steps.shrink_factor == numerical.shrink_factor);
CHECK(options->steps.maximum_attempts == numerical.maximum_attempts);
CHECK(options->steps.maximum_rejections_per_axis == numerical.maximum_rejections_per_axis);
CHECK(options->start == bound.starting_coordinates());
CHECK_FALSE(options->explicit_start);
}
auto options = first;
CHECK_THROWS_AS(family::prefer(family::PolytropicIndex{}).apply(options), std::invalid_argument);
CHECK_THROWS_AS(family::stages(family::PolytropicIndex{}, family::AngularMomentum{}).apply(options), std::invalid_argument);
numerical.maximum_attempts = 99;
CHECK_THROWS_AS(family::numerics(numerical).apply(options), std::invalid_argument);
CHECK(options.steps.maximum_attempts == 21);
CHECK(options.steps.initial_step_fraction == first.steps.initial_step_fraction);
CHECK(options.steps.axis_order == first.steps.axis_order);
const auto preferred = family::prefer(family::AngularMomentum{});
const auto strict = family::stages(family::AngularMomentum{}, family::PolytropicIndex{});
/** @brief A second ordering never partially changes an already selected route. */
const auto reject_second = [&](const auto& initial, const auto& duplicate) {
auto retained = family::make_options(bound, initial);
const auto order = retained.steps.order;
const auto axes = retained.steps.axis_order;
CHECK_THROWS_AS(duplicate.apply(retained), std::invalid_argument);
CHECK(retained.steps.order == order);
CHECK(retained.steps.axis_order == axes);
};
reject_second(preferred, preferred);
reject_second(strict, strict);
reject_second(preferred, strict);
reject_second(strict, preferred);
}
TEST_CASE("Strict physical continuation stages select a complete coordinate route", "[unit][solver][continuation][settings]") {
const auto bound = bound_family();
auto options = family::make_options(bound, family::stages(family::AngularMomentum{}, family::PolytropicIndex{}));
options.steps.initial_step_fraction.fill(1.0);
controller::CoordinateController<2> route(options.start, bound.target_coordinates(), options.steps);
const auto rotation = route.propose();
REQUIRE(rotation);
CHECK(rotation->axis == 1);
CHECK(rotation->coordinates == std::array<double, 2>{1.0, 0.1});
route.resolve(rotation->id, controller::AttemptOutcome::success);
const auto index = route.propose();
REQUIRE(index);
CHECK(index->axis == 0);
CHECK(index->coordinates == bound.target_coordinates());
route.resolve(index->id, controller::AttemptOutcome::success);
CHECK(route.status() == controller::ControllerStatus::complete);
}

View File

@@ -0,0 +1,292 @@
#include <algorithm>
#include <charconv>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <filesystem>
#include <format>
#include <fstream>
#include <iomanip>
#include <numeric>
#include <print>
#include <string>
#include <string_view>
#include <system_error>
#include <type_traits>
#include <stroid/IO/mesh.h>
#include "mfem/general/communication.hpp"
#include "serif/solver/equilibrium.hpp"
#include "serif/solver/seed/state.hpp"
#include "serif/tests/solver/archive.hpp"
#include "serif/tests/solver/physical_summary.hpp"
#include "serif/tests/solver/virial_output.hpp"
namespace {
/** @brief Reject partial numerical arguments rather than silently selecting different physics. */
template <typename Value>
[[nodiscard]] Value argument(std::string_view text) {
Value value{};
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value);
serif::utils::errors::serif_ensure<serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(
parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size(), "Invalid numerical argument '{}'.", text);
return value;
}
/** @brief Keep failed-solve diagnostics valid JSON when a measurement is not finite. */
[[nodiscard]] std::string number(double value) {
return std::isfinite(value) ? std::format("{:.17g}", value) : "null";
}
/**
* @brief Keep the selected equations separate from the meanings and ordering of their shared coordinates.
* @note Bump the version if equation semantics change without changing the registered equation names.
*/
template <typename Model>
[[nodiscard]] std::string formulation_identity(const Model& star) {
const auto description = serif::model::resolve_equilibrium_equations(star);
std::string identity = "formulation=stellar_equations_v1";
description.interior_equations.for_each([&]<typename Equation>(const Equation&) {
identity += ';';
identity += Equation::name;
});
identity += ";shape=";
identity += std::remove_cvref_t<decltype(description.shape_force)>::name;
return identity;
}
}
/**
* @brief Solve the scalar formulation from a radial seed and measure every archived physical request.
* @note The historical archive supplies mesh topology and sampling requests, never physical field values.
* An optional current-layout checkpoint is independently reevaluated at the declared physics with zero Newton updates.
* Its preparation record must identify the same Kelvin map, mesh, formulation and coefficient layout.
* Legacy or unmarked coordinates require a fresh solve; no convergence status is imported.
* Physical n, K and J are explicit inputs; M=G=1 and P_surface=0 match the reference family.
* Outputs distinguish algebraic convergence from the independent 1e-6 vacuum virial acceptance gate.
* accepted_state.txt remains an algebraic checkpoint; only physical_accepted_state.txt passes both checks.
*/
int main(int argc, char** argv) {
using namespace serif::utils::errors;
if (argc != 9 && argc != 10) {
std::println(stderr, "Usage: ester_validation REPORT CASE OUTPUT n K J NEWTON_CAP KRYLOV_CAP [RECHECK_STATE]");
return 2;
}
#ifdef MFEM_USE_MPI
mfem::Mpi::Init(argc, argv);
#else
std::println(stderr, "ESTER validation requires the default Hypre/MPI stellar inverse.");
return 2;
#endif
namespace fixture = serif::tests::solver;
namespace archive = fixture::archive;
namespace solver = serif::solver;
namespace model = serif::model;
namespace normalization = solver::normalization;
const std::filesystem::path report = argv[1], output = argv[3];
const std::string name = argv[2];
const double n = argument<double>(argv[4]), K = argument<double>(argv[5]), J = argument<double>(argv[6]);
const int updates = argument<int>(argv[7]), krylov = argument<int>(argv[8]);
const bool recheck = argc == 10;
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(!recheck || updates == 0,
"A saved-state recheck must use zero Newton updates; its convergence is evaluated afresh, never imported.");
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(std::isfinite(n) && n >= 1.0 && n < 3.0 &&
std::isfinite(K) && K > 0.0 && std::isfinite(J) && J >= 0.0 && updates >= 0 && updates <= 30 &&
krylov > 0 && krylov <= 600 && !std::filesystem::exists(output),
"Require 1<=n<3, positive K, nonnegative J, bounded iteration limits, and a fresh output directory.");
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(std::filesystem::create_directories(output), "Cannot create fresh output directory.");
std::ofstream progress(output / "preparation.txt");
progress << std::setprecision(17) << "archive=" << std::quoted(report.string()) << "\nrequest_case=" << std::quoted(name)
<< "\nn=" << n << "\nK=" << K << "\nM=1\nG=1\nJ=" << J << "\nP_surface=0\nnewton_cap=" << updates
<< "\nkrylov_cap=" << krylov << '\n';
// A last stage without 'complete' is an incomplete run, including an exception or process failure.
const auto stage = [&](std::string_view value) {
progress << "stage=" << value << '\n';
progress.flush();
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(progress.good(), "Cannot preserve preparation progress.");
};
stage("metadata");
const auto source = report / "inputs" / name;
const auto metadata = archive::metadata(source);
const auto parameter = [&](const char* key) { return std::stod(metadata.at(key)); };
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(parameter("central_phase") == 0.0 && parameter("Psurf") == 0.0 &&
parameter("M") == 1.0 && parameter("G") == 1.0 && parameter("R_reference") == 1.0 &&
parameter("surface_degree") == 4.0 && parameter("rotation_axis_x") == 0.0 && parameter("rotation_axis_y") == 0.0 &&
parameter("rotation_axis_z") == 1.0 && parameter("rotation_center_x") == 0.0 &&
parameter("rotation_center_y") == 0.0 && parameter("rotation_center_z") == 0.0,
"The reference requests must use the documented fixed-M/G vacuum branch and rotation convention.");
stage("mesh_load");
auto mesh = stroid::IO::LoadStroidMesh((source / "mesh.smesh").string());
if (!mesh) {
stage("failed_mesh_load");
std::println(stderr, "Cannot load the reference mesh: {}", mesh.error());
return 2;
}
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(mesh->config.r_star.has_value() && mesh->config.r_infinity.has_value() &&
mesh->config.r_star.value() == 1.0 && mesh->config.r_infinity.value() == 5.0,
"The bounded archive study requires mesh reference radii one and five.");
// Match the existing exterior coordinate's mesh radii. Record this changed map before any state import.
constexpr std::string_view mapping_identity = "mapping=kelvin_mesh_radii_v1,r_star_ref=1,r_inf_ref=5";
progress << mapping_identity << '\n';
stage("mapping_identity");
if (recheck) {
const auto preparation_path = std::filesystem::path(argv[9]).parent_path() / "preparation.txt";
std::ifstream preparation(preparation_path);
archive::require_preparation_identity(preparation, mapping_identity);
}
const double reference_radius = mesh->config.r_star.value();
const double infinity_radius = mesh->config.r_infinity.value();
const auto resources = std::make_shared<fixture::StellarResources>(std::move(*mesh), reference_radius, infinity_radius);
const auto finite_elements = fixture::make_discretization(resources, 4);
const auto star = fixture::make_model(n, K, 1.0, J, 1.0);
const std::string formulation = formulation_identity(star);
progress << formulation << '\n';
stage("formulation_identity");
if (recheck) {
std::ifstream preparation(std::filesystem::path(argv[9]).parent_path() / "preparation.txt");
archive::require_preparation_identity(preparation, formulation);
}
stage("radial_seed");
const auto radial = solver::seed::make_polytropic_seed(star, {.integration = {.step = 0.001}});
if (!radial) {
stage("failed_radial_seed");
std::println(stderr, "Radial seed failed: {}", radial.error());
return 2;
}
stage("radial_fit");
const auto fitted = solver::seed::fit_radial_seed(finite_elements, *radial);
if (!fitted) {
stage("failed_radial_fit");
std::println(stderr, "Reference-surface fit failed: {}", fitted.error());
return 2;
}
stage("state_projection");
auto initial = solver::seed::make_polytropic_state(star, finite_elements, *fitted);
if (!initial) {
stage("failed_state_projection");
std::println(stderr, "Radial projection failed: {}", initial.error());
return 2;
}
stage("surface_basis");
const auto basis_source = std::filesystem::exists(source / "surface_basis.csv") ? source : report / "inputs" / "n1_J0p05";
const double basis_error = archive::verify_surface_basis(basis_source, finite_elements, *initial);
const std::string coordinates = archive::coordinate_identity(initial->layout(), archive::mesh_identity(source / "mesh.smesh"));
progress << coordinates << '\n';
stage("coordinate_identity");
if (recheck) {
stage("saved_state_recheck");
std::ifstream preparation(std::filesystem::path(argv[9]).parent_path() / "preparation.txt");
archive::require_preparation_identity(preparation, coordinates);
const auto values = archive::read_coefficients(argv[9], initial->values().size());
std::copy(values.begin(), values.end(), initial->values().begin());
progress << "recheck_state=" << std::quoted(std::filesystem::absolute(argv[9]).string()) << '\n';
progress.flush();
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(progress.good(), "Cannot record the saved-state input.");
}
archive::save_coefficients(output / "initial_state.txt", initial->values());
std::ofstream seed_summary(output / "seed.json");
seed_summary << "{\"radial_radius\":" << number(radial->scales.radius.value())
<< ",\"radial_mass\":" << number(radial->scales.mass.value())
<< ",\"radial_central_density\":" << number(radial->scales.central_density.value())
<< ",\"fitted_radius\":" << number(fitted->radius.value()) << ",\"fitted_mass\":" << number(fitted->mass.value()) << "}\n";
seed_summary.close();
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(seed_summary.good(), "Cannot write radial-seed diagnostics.");
stage("normalization");
const auto reference = normalization::prepare_stellar_reference_scales(serif::dimensions::MassValue{1.0},
serif::dimensions::LengthValue{1.0}, serif::dimensions::QuantityValue<serif::dimensions::GravitationalConstant>{1.0});
if (!reference) {
stage("failed_normalization");
std::println(stderr, "Normalization failed: {}", reference.error());
return 2;
}
auto configuration = model::make_discretization(model::DiscretizationOptions{
.model = star, .finite_elements = finite_elements,
.normalization = normalization::NormalizationOptions{.scheme = *reference, .coordinates = normalization::Dimensionless{},
.gravitational_constant = serif::dimensions::QuantityValue<serif::dimensions::GravitationalConstant>{1.0}}
});
const solver::SolverOptions options{
.method = {.relative_tolerance = 0.0, .absolute_tolerance = 1.0e-10, .maximum_iterations = updates,
.linear_solve = {.relative_tolerance = 0.003, .absolute_tolerance = 0.0, .maximum_iterations = krylov}},
.linear_solver = {.restart_length = 160, .print_level = 1}
};
const auto observe = [&](const solver::SolveEvent& event, const auto& state, const auto& system) {
std::println("event {}, iteration {}, trial {}, residual {}, step {}: {}", static_cast<int>(event.kind), event.iteration,
event.trial, event.metric ? number(event.metric->residual_norm) : "unmeasured", event.step, event.message);
if (system) {
std::size_t offset = 0;
for (const auto size : state.layout().residual_sizes) {
const auto first = system->residual().begin() + offset;
std::println("row offset {}, count {}, norm {}", offset, size, std::sqrt(std::inner_product(first, first + size, first, 0.0)));
offset += size;
}
}
if (event.kind == solver::EventKind::accepted) {
archive::save_coefficients(output / "progress_state.txt", state.values());
}
std::fflush(stdout);
};
stage("nonlinear_solve");
const auto started = std::chrono::steady_clock::now();
const auto result = solver::solve_equilibrium(std::move(configuration), std::move(*initial), options, observe);
const double seconds = std::chrono::duration<double>(std::chrono::steady_clock::now() - started).count();
const auto& diagnostics = result.diagnostics();
stage("vacuum_virial");
std::expected<fixture::VacuumVirialAssessment, std::string> virial = std::unexpected("The nonlinear solve returned no final state.");
if (result.has_state()) {
archive::save_coefficients(output / "last_state.txt", result.state().values());
if (result.converged()) {
archive::save_coefficients(output / "accepted_state.txt", result.state().values());
}
// The driver enforces the physical z axis and origin above; M=R_reference=G=1 fixes the units.
const double omega = result.state().template get<fixture::Omega>()[0];
const serif::equations::RigidRotation rotation{{0.0, 0.0, omega}};
virial = fixture::vacuum_virial(finite_elements, result.state(), star.equation_of_state(), rotation);
}
const bool physical_accepted = result.converged() && virial && virial->passes();
fixture::write_vacuum_virial(output, virial, result.converged());
std::ofstream summary(output / "solve.json");
summary << std::setprecision(17) << "{\"n\":" << n << ",\"K\":" << K << ",\"M\":1,\"G\":1,\"J\":" << J
<< ",\"P_surface\":0,\"formulation\":\"density_primal\",\"initial_kind\":\""
<< (recheck ? "saved_state_recheck" : "fitted_radial_seed") << "\",\"sample_units\":\"canonical\""
<< ",\"formulation_identity\":" << std::quoted(formulation)
<< ",\"seed_case\":\"" << name << "\""
<< ",\"converged\":" << (result.converged() ? "true" : "false") << ",\"status\":" << static_cast<int>(result.status())
<< ",\"converged_meaning\":\"algebraic\",\"accepted_state_meaning\":\"algebraic_checkpoint\""
<< ",\"physical_accepted\":" << (physical_accepted ? "true" : "false")
<< ",\"vacuum_virial_valid\":" << (virial ? "true" : "false")
<< ",\"energy_virial_signed\":" << (virial ? number(virial->samples.back().energy_virial()) : "null")
<< ",\"force_virial_signed\":" << (virial ? number(virial->samples.back().force_virial()) : "null")
<< ",\"virial_quadrature_allowance\":" << (virial ? number(virial->quadrature_allowance) : "null")
<< ",\"virial_tolerance\":1e-6,\"virial_integration_allowance_limit\":1e-7"
<< ",\"newton_cap\":" << updates << ",\"krylov_cap\":" << krylov
<< ",\"nonlinear_tolerance\":1e-10,\"linear_relative_tolerance\":0.003,\"restart_length\":160"
<< ",\"accepted_iterations\":" << diagnostics.accepted_iterations << ",\"attempted_iterations\":" << diagnostics.attempted_iterations
<< ",\"krylov_iterations\":" << diagnostics.total_linear_iterations << ",\"rejected_trials\":" << diagnostics.rejected_trials
<< ",\"last_step\":" << number(diagnostics.last_accepted_step) << ",\"solve_seconds\":" << number(seconds)
<< ",\"initial_residual\":" << number(diagnostics.initial_residual_norm.value_or(-1.0))
<< ",\"final_residual\":" << number(diagnostics.final_residual_norm.value_or(-1.0))
<< ",\"basis_error\":" << number(basis_error) << ",\"basis_source\":\"" << basis_source.filename().string() << "\"";
if (diagnostics.last_linear_solve) {
summary << ",\"last_linear_relative_residual\":" << number(diagnostics.last_linear_solve->relative_true_residual_norm);
}
summary << "}\n";
summary.close();
serif_ensure<SERiFErrorCode::INVALID_ARGUMENT>(summary.good(), "Cannot write complete solve diagnostics.");
std::println("Termination: {}", result.message());
stage("physical_sampling");
if (result.has_state()) {
if (physical_accepted) {
archive::save_coefficients(output / "physical_accepted_state.txt", result.state().values());
}
archive::sample(report / "meanfield_runs" / name / "samples.csv", output / "samples.csv", finite_elements, result.state(), n, K);
archive::sample_summary(output, finite_elements, result.state(), report / "meanfield_runs" / name / "surface.csv");
}
if (physical_accepted) { stage("complete_physical_accepted"); }
else if (result.converged()) { stage("complete_algebraically_converged_physical_rejected"); }
else { stage("complete_unconverged"); }
std::println("Physical acceptance: {} (full vacuum virial tolerance 1e-6)", physical_accepted ? "passed" : "rejected");
return physical_accepted ? 0 : 1;
}

220
tests/solver/linear.cpp Normal file
View File

@@ -0,0 +1,220 @@
#include <cmath>
#include <limits>
#include <stdexcept>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "mfem/linalg/densemat.hpp"
#include "serif/backend/mfem/solver/linear/solve.hpp"
#include "serif/utils/error/errors.hpp"
#include "serif/backend/mfem/operators/actions.hpp"
namespace {
namespace linear = serif::solver::linear;
constexpr auto fixed = serif::solver::preconditioner::ApplicationContract::fixed_linear;
/** @brief Keep an exact identity inverse while checking the original operator address is preserved. */
class IdentityInverse final : public ::mfem::Solver {
public:
/** @brief Bind one test linearization. */
explicit IdentityInverse(const ::mfem::Operator& operation) : ::mfem::Solver(operation.Width()), m_operation(operation) {}
/** @brief Reject a counting wrapper or unrelated operator replacing the accepted linearization. */
void SetOperator(const ::mfem::Operator& operation) override { REQUIRE(&operation == &m_operation); }
/** @brief Copy the correction without changing its coordinates. */
void Mult(const ::mfem::Vector& input, ::mfem::Vector& output) const override { output = input; }
private:
const ::mfem::Operator& m_operation;
};
/** @brief Use MeanField's nonsymmetric two-coordinate problem, with solution (0.1, 0.6). */
[[nodiscard]] ::mfem::DenseMatrix example_matrix() {
::mfem::DenseMatrix matrix(2);
matrix(0, 0) = 4.0;
matrix(0, 1) = 1.0;
matrix(1, 0) = 2.0;
matrix(1, 1) = 3.0;
return matrix;
}
}
TEST_CASE("Checked FGMRES preserves restart counts and RHS-relative warm-start accuracy", "[solver][linear]") {
const ::mfem::DenseMatrix matrix = example_matrix();
IdentityInverse inverse(matrix);
::mfem::Vector rhs(2), correction(2);
rhs[0] = 1.0;
rhs[1] = 2.0;
correction = 0.0;
const linear::FGMRES options{.restart_length = 1};
const linear::Control control{.relative_tolerance = 1.0e-12, .absolute_tolerance = 1.0e-14, .maximum_iterations = 200};
const auto cold = linear::backend::mfem::solve(options, matrix, inverse, fixed, rhs, correction, control);
REQUIRE(cold.converged());
CHECK(correction[0] == Catch::Approx(0.1).margin(1.0e-11));
CHECK(correction[1] == Catch::Approx(0.6).margin(1.0e-11));
CHECK(cold.right_hand_side_norm == Catch::Approx(std::sqrt(5.0)));
CHECK(cold.true_residual_norm <= control.convergence_threshold(cold.right_hand_side_norm));
CHECK(cold.iterations == static_cast<int>(cold.preconditioner_applications));
CHECK(cold.iterations > 1);
CHECK(cold.iterations <= control.maximum_iterations);
CHECK(cold.restarts == cold.iterations - 1);
CHECK(cold.operator_applications > cold.preconditioner_applications);
CHECK(cold.solve_seconds >= 0.0);
CHECK(cold.operator_seconds >= 0.0);
CHECK(cold.preconditioner_seconds >= 0.0);
correction[0] += 1.0e-5;
correction[1] -= 1.0e-5;
const auto warm = linear::backend::mfem::solve(options, matrix, inverse, fixed, rhs, correction, control);
REQUIRE(warm.converged());
CHECK(warm.initial_residual_norm < cold.initial_residual_norm);
CHECK(warm.true_residual_norm <= control.convergence_threshold(warm.right_hand_side_norm));
}
TEST_CASE("Checked FGMRES discards an adverse or overflowing finite guess", "[solver][linear]") {
const ::mfem::DenseMatrix matrix = example_matrix();
IdentityInverse inverse(matrix);
::mfem::Vector rhs(2), correction(2);
rhs[0] = 1.0;
rhs[1] = 2.0;
correction = 0.0;
const linear::FGMRES options{.restart_length = 1};
const linear::Control control{.relative_tolerance = 1.0e-12, .absolute_tolerance = 1.0e-14, .maximum_iterations = 200};
const auto cold = linear::backend::mfem::solve(options, matrix, inverse, fixed, rhs, correction, control);
REQUIRE(cold.converged());
SECTION("Finite adverse coefficients") {
correction[0] = 100.0;
correction[1] = -100.0;
}
SECTION("Finite coefficients with overflowing action") {
correction = std::numeric_limits<double>::max() / 2.0;
}
const auto guarded = linear::backend::mfem::solve(options, matrix, inverse, fixed, rhs, correction, control);
REQUIRE(guarded.converged());
CHECK(guarded.initial_residual_norm == Catch::Approx(cold.right_hand_side_norm));
CHECK(guarded.iterations == cold.iterations);
CHECK(guarded.operator_applications == cold.operator_applications + 1);
}
TEST_CASE("Checked FGMRES verifies exact guesses and zero right-hand sides without Krylov iterations", "[solver][linear]") {
const ::mfem::DenseMatrix matrix = example_matrix();
IdentityInverse inverse(matrix);
::mfem::Vector rhs(2), correction(2);
correction[0] = 1.0;
correction[1] = 2.0;
matrix.Mult(correction, rhs);
SECTION("Exact nonzero initial guess") {
const auto report = linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction);
REQUIRE(report.converged());
CHECK(report.iterations == 0);
CHECK(report.restarts == 0);
CHECK(report.operator_applications == 2);
CHECK(report.true_residual_norm == 0.0);
}
SECTION("Zero equation discards a nonzero guess") {
rhs = 0.0;
const auto report = linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction);
REQUIRE(report.converged());
CHECK(report.iterations == 0);
CHECK(report.relative_true_residual_norm == 0.0);
CHECK(correction.Norml2() == 0.0);
}
SECTION("Variable preconditioner contract is supported") {
const auto report = linear::backend::mfem::solve({}, matrix, inverse,
serif::solver::preconditioner::ApplicationContract::variable, rhs, correction);
CHECK(report.converged());
}
}
TEST_CASE("Checked FGMRES reports exhausted budgets and nonfinite numerical results", "[solver][linear]") {
::mfem::DenseMatrix matrix = example_matrix();
IdentityInverse inverse(matrix);
::mfem::Vector rhs(2), correction(2);
rhs[0] = 1.0;
rhs[1] = 2.0;
correction = 0.0;
SECTION("A single Krylov iteration is insufficient") {
const auto report = linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction,
{.relative_tolerance = 1.0e-14, .maximum_iterations = 1});
CHECK(report.status == linear::Status::maximum_iterations);
CHECK(report.iterations == 1);
CHECK(report.true_residual_norm > report.control.convergence_threshold(report.right_hand_side_norm));
}
SECTION("Nonfinite RHS is numerical failure") {
rhs[0] = std::numeric_limits<double>::infinity();
const auto report = linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction);
CHECK(report.status == linear::Status::non_finite);
CHECK(report.operator_applications == 0);
}
SECTION("Nonfinite initial correction is numerical failure") {
correction[0] = std::numeric_limits<double>::quiet_NaN();
CHECK(linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction).status == linear::Status::non_finite);
}
SECTION("SERiF action overflow is a numerical report, without catching unrelated callback errors") {
const auto action = serif::operators::backend::mfem::linearized_action(2, 2,
[](const ::mfem::Vector&, ::mfem::Vector& output) { output = std::numeric_limits<double>::infinity(); },
[](const ::mfem::Vector&, ::mfem::Vector& output) { output = std::numeric_limits<double>::infinity(); });
IdentityInverse action_inverse(*action.action());
CHECK(linear::backend::mfem::solve({}, *action.action(), action_inverse, fixed, rhs, correction).status == linear::Status::non_finite);
}
SECTION("Nonfinite operator action cannot converge") {
matrix(0, 0) = std::numeric_limits<double>::quiet_NaN();
CHECK(linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction).status == linear::Status::non_finite);
}
}
TEST_CASE("Checked FGMRES independently rejects misleading backend convergence", "[solver][linear]") {
/** @brief Mimic a stale callback whose final action disagrees with the Krylov recurrence. */
class ChangingOperator final : public ::mfem::Operator {
public:
/** @brief Use one coordinate so the recurrence converges in one iteration. */
ChangingOperator() : ::mfem::Operator(1) {}
/** @brief Change only after MFEM's initial and Arnoldi operator applications. */
void Mult(const ::mfem::Vector& input, ::mfem::Vector& output) const override {
++applications;
output[0] = input[0] * (applications > 2 ? 2.0 : 1.0);
}
mutable int applications{0};
} operation;
IdentityInverse inverse(operation);
::mfem::Vector rhs(1), correction(1);
rhs = 1.0;
correction = 0.0;
const auto report = linear::backend::mfem::solve({}, operation, inverse, fixed, rhs, correction);
CHECK(report.status == linear::Status::backend_failure);
CHECK(report.reported_residual_norm == 0.0);
CHECK(report.true_residual_norm == 1.0);
}
TEST_CASE("Checked FGMRES validates configuration and preserves callback errors", "[solver][linear]") {
CHECK_THROWS_AS(linear::FGMRES{.restart_length = 0}.validate(), std::invalid_argument);
CHECK_THROWS_AS(linear::FGMRES{.print_level = 4}.validate(), std::invalid_argument);
for (const double invalid : {-1.0, std::numeric_limits<double>::quiet_NaN(), std::numeric_limits<double>::infinity()}) {
CHECK_THROWS_AS(linear::Control{.relative_tolerance = invalid}.validate(), std::invalid_argument);
CHECK_THROWS_AS(linear::Control{.absolute_tolerance = invalid}.validate(), std::invalid_argument);
CHECK_THROWS_AS(linear::Control{}.convergence_threshold(invalid), std::invalid_argument);
}
CHECK_THROWS_AS(linear::Control{.maximum_iterations = 0}.validate(), std::invalid_argument);
CHECK_THROWS_AS(linear::Control{.relative_tolerance = std::numeric_limits<double>::max()}.convergence_threshold(2.0), std::invalid_argument);
const linear::Control control{.relative_tolerance = 0.25, .absolute_tolerance = 0.5};
CHECK(control.convergence_threshold(4.0) == 1.0);
CHECK(control.convergence_threshold(0.0) == 0.5);
/** @brief Model a genuine programming error rather than a numerical rejection. */
class BrokenOperator final : public ::mfem::Operator {
public:
/** @brief Match one valid coefficient. */
BrokenOperator() : ::mfem::Operator(1) {}
/** @brief Exercise the error boundary without introducing a broad catch in the solver. */
void Mult(const ::mfem::Vector&, ::mfem::Vector&) const override {
serif::utils::errors::serif_error<std::logic_error, serif::utils::errors::SERiFErrorCode::SOLVER_ERROR>("Broken callback contract.");
}
} operation;
IdentityInverse inverse(operation);
::mfem::Vector rhs(1), correction(1);
rhs = 1.0;
correction = 0.0;
CHECK_THROWS_AS(linear::backend::mfem::solve({}, operation, inverse, fixed, rhs, correction), std::logic_error);
correction.SetSize(2);
CHECK_THROWS_AS(linear::backend::mfem::solve({}, operation, inverse, fixed, rhs, correction), std::invalid_argument);
}

View File

@@ -0,0 +1,99 @@
#include <limits>
#include <memory>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "mfem/fem/fe_coll.hpp"
#include "serif/backend/mfem/discretization/dof_map.hpp"
#include "serif/backend/mfem/solver/linear/solve.hpp"
#include "serif/backend/mfem/solver/normalization/operators.hpp"
#include "serif/backend/mfem/operators/actions.hpp"
#include "serif/backend/mfem/geometry/surface/boundary_rows.hpp"
namespace {
/** @brief Select every serial DOF through the same map used by production term bindings. */
class FullBinding : public serif::discretization::backend::mfem::DofMap {
/** @brief Use all elements of the test domain. */
[[nodiscard]] static ::mfem::Array<int> support(const ::mfem::FiniteElementSpace& space) {
::mfem::Array<int> supported(space.GetNE());
supported = 1;
return supported;
}
public:
/** @brief Borrow the immutable finite-element space. */
explicit FullBinding(::mfem::FiniteElementSpace& space) : DofMap(space, support(space)) {}
};
struct Fixture {
::mfem::Mesh mesh{::mfem::Mesh::MakeCartesian3D(2, 2, 1, ::mfem::Element::HEXAHEDRON)};
::mfem::H1_FECollection collection{3, 3};
::mfem::FiniteElementSpace space{&mesh, &collection};
FullBinding binding{space};
};
/** @brief Exercise the real physical DOF input check before the outer action adapter can inspect its output. */
class CheckedPhysicalIdentity final : public ::mfem::Operator {
public:
/** @brief Borrow the same supported finite-element binding used by field actions. */
explicit CheckedPhysicalIdentity(const FullBinding& binding) : ::mfem::Operator(binding.size()), m_binding(binding) {}
/** @brief Validate dimensional coefficients at the physical action boundary. */
void Mult(const ::mfem::Vector& input, ::mfem::Vector& output) const override {
m_binding.validate_values(input);
output = input;
}
private:
const FullBinding& m_binding;
};
/** @brief Keep this failure-classification test independent of inverse conditioning. */
class IdentityInverse final : public ::mfem::Solver {
public:
/** @brief Match the physical coefficient count. */
explicit IdentityInverse(int size) : ::mfem::Solver(size) {}
/** @brief The identity inverse does not store a borrowed operator. */
void SetOperator(const ::mfem::Operator&) override {}
/** @brief Apply the unchanged vector. */
void Mult(const ::mfem::Vector& input, ::mfem::Vector& output) const override { output = input; }
};
}
TEST_CASE("Checked Krylov classifies physical DOF and pressure-boundary overflow without hiding shape errors", "[solver][linear][overflow]") {
namespace solver = serif::solver;
Fixture fixture;
IdentityInverse inverse(fixture.binding.size());
::mfem::Vector rhs(fixture.binding.size()), correction(rhs.Size());
rhs = 0.0;
rhs[0] = 1.0;
SECTION("Overflowing a finite recycled guess inside physical normalization resets it to zero") {
const CheckedPhysicalIdentity physical(fixture.binding);
const double tiny = std::numeric_limits<double>::min();
const auto coordinates = solver::normalization::make_diagonal_normalization(std::vector(rhs.Size(), tiny), std::vector(rhs.Size(), tiny));
REQUIRE(coordinates);
solver::normalization::backend::mfem::ScaledJacobianOperator normalized(physical, *coordinates);
correction = 1.0e100;
const auto report = solver::linear::backend::mfem::solve({}, normalized, inverse,
solver::preconditioner::ApplicationContract::fixed_linear, rhs, correction);
REQUIRE(report.converged());
CHECK(report.initial_residual_norm == 1.0);
CHECK(correction[0] == Catch::Approx(1.0));
}
SECTION("A finite boundary derivative producing infinity becomes a numerical failure report") {
struct Boundary { std::shared_ptr<const serif::discretization::backend::mfem::DofSelection> selected_rows; };
::mfem::Array<int> rows{0};
const Boundary boundary{std::make_shared<const serif::discretization::backend::mfem::DofSelection>(fixture.binding, rows)};
const auto action = [boundary](const auto& input, auto& output) {
output = input;
::mfem::Vector values(1);
values[0] = input[0] * std::numeric_limits<double>::max();
values[0] *= std::numeric_limits<double>::max();
serif::geometry::surface::backend::mfem::detail::replace_boundary_rows(boundary, values, output);
};
const auto operation = serif::operators::backend::mfem::linearized_action(rhs.Size(), rhs.Size(), action, action);
correction = 0.0;
const auto report = solver::linear::backend::mfem::solve({}, *operation.action(), inverse,
solver::preconditioner::ApplicationContract::fixed_linear, rhs, correction);
CHECK(report.status == solver::linear::Status::non_finite);
}
::mfem::Vector wrong_size(1);
wrong_size = 0.0;
CHECK_THROWS_AS(fixture.binding.validate_values(wrong_size), std::invalid_argument);
}

32
tests/solver/meson.build Normal file
View File

@@ -0,0 +1,32 @@
solver_test_sources = files('linear.cpp', 'linear_overflow.cpp', 'seed.cpp', 'nonlinear.cpp', 'nonlinear_parity.cpp', 'strategies.cpp',
'nonconforming.cpp', 'continuation_controller.cpp', 'continuation_family.cpp', 'continuation_driver.cpp', 'continuation_settings.cpp', 'continuation_equilibrium.cpp', 'archive.cpp', 'virial.cpp')
serif_solver_tests = executable(
'serif_solver_tests',
solver_test_sources,
dependencies: [serif_dep, catch2_dep, stroid_dep],
include_directories: include_directories('../include'),
build_rpath: mfem_runtime_prefix == '' ? '' : mfem_runtime_prefix / 'lib',
)
# Complete seed regressions assemble real moving stars, including in debug builds.
test('serif_solver_tests', serif_solver_tests, args: ['~[stellar_solve]~[ester]~[nonconforming_stellar_solve]'], suite: 'solver', timeout: 1800, is_parallel: false)
# Keep the coupled hanging-node regression visible in the normal suite with its own budget.
test('serif_nonconforming_stellar_tests', serif_solver_tests, args: ['[nonconforming_stellar_solve]'],
suite: 'solver', timeout: 3600, is_parallel: false)
# The archived validation study is an explicit long-running reproduction tool.
ester_validation = executable(
'ester_validation',
'ester_validation.cpp',
dependencies: [serif_dep, stroid_dep],
include_directories: include_directories('../include'),
build_rpath: mfem_runtime_prefix == '' ? '' : mfem_runtime_prefix / 'lib',
)
# Exercise the shared acceptance loop without any finite-element backend.
serif_core_solver_tests = executable(
'serif_core_solver_tests',
'backend_independent.cpp',
dependencies: [serif_core_dep, catch2_dep],
)
test('serif_core_solver_tests', serif_core_solver_tests, suite: 'solver', timeout: 30)

View File

@@ -0,0 +1,69 @@
#include <cmath>
#include <memory>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "mfem/general/communication.hpp"
#include "serif/backend/mfem/solver/seed/state.hpp"
#include "serif/solver/equilibrium.hpp"
#include "serif/solver/seed/polytrope.hpp"
#include "serif/tests/nonconforming.hpp"
#include "serif/tests/solver/stellar_fixture.hpp"
#include "serif/tests/solver/virial.hpp"
namespace {
namespace solver = serif::solver;
namespace fixture = serif::tests::solver;
}
TEST_CASE("A fixed STROID nonconforming mesh supports a complete coupled stellar solve", "[solver][nonconforming][nonconforming_stellar_solve]") {
if (!mfem::Mpi::IsInitialized()) {
mfem::Mpi::Init();
}
const auto model = fixture::make_model();
const auto seed = solver::seed::make_polytropic_seed(model);
REQUIRE(seed);
CHECK(seed->scales.radius.value() == Catch::Approx(1.0).margin(2.0e-10));
const auto resources = std::make_shared<fixture::StellarResources>(serif::tests::nonconforming::mesh(1, 2, 6));
REQUIRE(resources->potential_space.GetTrueVSize() < resources->potential_space.GetVSize());
const auto finite_elements = fixture::make_discretization(resources, 0);
const auto initial = solver::seed::backend::mfem::make_polytropic_state(model, finite_elements, *seed);
REQUIRE(initial);
const auto scales = solver::normalization::prepare_stellar_reference_scales(
serif::dimensions::MassValue{1.0}, serif::dimensions::LengthValue{1.0});
REQUIRE(scales);
const auto configuration = serif::model::make_discretization(serif::model::DiscretizationOptions{
.model = model, .finite_elements = finite_elements,
.normalization = solver::normalization::NormalizationOptions{.scheme = *scales}});
const solver::SolverOptions options{
.method = {.relative_tolerance = 0.0, .absolute_tolerance = 1.0e-10, .maximum_iterations = 20,
.linear_solve = {.relative_tolerance = 1.0e-3, .maximum_iterations = 200}}};
const auto result = solver::solve_equilibrium(configuration, *initial, options);
INFO(result.message());
REQUIRE(result.converged());
REQUIRE(result.diagnostics().final_residual_norm);
CHECK(*result.diagnostics().final_residual_norm <= 1.0e-10);
CHECK(result.diagnostics().accepted_iterations > 0);
const auto& state = result.state();
const auto trace = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::StellarSurfaceBoundary>(resources->density);
const auto density = state.template get<fixture::Density>();
for (const int dof : trace.reduced_dofs()) {
CHECK(density[dof] == 0.0);
}
const double omega = state.template get<fixture::Omega>()[0];
const auto virial = fixture::vacuum_virial(finite_elements, state, model.equation_of_state(),
serif::equations::RigidRotation{{0.0, 0.0, omega}});
if (!virial) {
INFO(virial.error());
}
REQUIRE(virial);
const auto& physical = virial->samples.back();
INFO("Full virial = " << physical.energy_virial() << ", quadrature allowance = " << virial->quadrature_allowance
<< ", passes unchanged 1e-6 gate = " << virial->passes());
CHECK(physical.mass == Catch::Approx(1.0).margin(2.0e-9));
CHECK(std::abs(omega) < 1.0e-12);
CHECK(std::isfinite(physical.energy_virial()));
CHECK(virial->quadrature_allowance < 1.0e-7);
}

561
tests/solver/nonlinear.cpp Normal file
View File

@@ -0,0 +1,561 @@
#include "serif/tests/solver/native_problem.hpp"
#include <cmath>
#include <expected>
#include <functional>
#include <limits>
#include <memory>
#include <span>
#include <string>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "mfem/linalg/densemat.hpp"
#include "serif/solver/nonlinear/newton.hpp"
#include "serif/solver/normalization/diagonal.hpp"
namespace {
namespace solver = serif::solver;
using Catch::Matchers::WithinAbs;
/** @brief A one-coordinate physical state exercises the same owning-candidate contract as a star. */
struct State {
std::vector<double> coefficients;
/** @brief Edit the sole physical unknown. */
std::span<double> values() { return coefficients; }
/** @brief Inspect immutable accepted physical coordinates. */
std::span<const double> values() const { return coefficients; }
};
/** @brief Freeze a nonlinear residual and derivative in deliberately nontrivial coordinates. */
struct System {
std::vector<double> residual_values;
::mfem::DenseMatrix derivative{1};
solver::normalization::DiagonalNormalization normalization;
/** @brief Use state factor two and residual factor three to detect coordinate mixups. */
System(const double residual, const double jacobian) : residual_values{3.0 * residual},
normalization(*solver::normalization::make_diagonal_normalization({2.0}, {3.0})) {
derivative(0, 0) = 1.5 * jacobian;
}
/** @brief Read the immutable normalized residual. */
const std::vector<double>& residual() const { return residual_values; }
/** @brief Read the matching normalized derivative. */
const ::mfem::Operator& jacobian() const { return derivative; }
/** @brief Recover physical trial steps with the accepted coordinate factors. */
const auto& coordinates() const { return normalization; }
};
/** @brief A scalar inverse keeps this regression independent of stellar preconditioning quality. */
struct Inverse : ::mfem::Solver {
double reciprocal;
/** @brief Freeze the inverse derivative belonging to one prepared state. */
explicit Inverse(const double value) : ::mfem::Solver(1), reciprocal(1.0 / value) {}
/** @brief Reject rebinding, which would violate frozen preparation ownership. */
void SetOperator(const ::mfem::Operator&) override {}
/** @brief Apply the exact scalar inverse. */
void Mult(const ::mfem::Vector& input, ::mfem::Vector& output) const override { output[0] = reciprocal * input[0]; }
};
struct Prepared {
std::shared_ptr<const System> owner;
mutable Inverse inverse;
/** @brief Retain the system before borrowing its derivative. */
explicit Prepared(std::shared_ptr<const System> system) : owner(std::move(system)), inverse(owner->derivative(0, 0)) {}
/** @brief Inspect the matching frozen residual and Jacobian. */
const auto& system() const { return owner; }
/** @brief Borrow the inverse while its owner remains alive. */
::mfem::Solver& preconditioner() const { return inverse; }
/** @brief This scalar inverse has a fixed linear application. */
auto contract() const { return solver::preconditioner::ApplicationContract::fixed_linear; }
};
/** @brief A positive-domain scalar equation reproduces numerical rejections and preparation failures. */
struct Problem : serif::tests::solver::NativeProblem {
using System = ::System;
using Model = solver::NoModel;
std::function<std::pair<double, double>(double)> equation = [](const double x) { return std::pair{x * x - 2.0, 2.0 * x}; };
std::function<bool(double)> preparation_allowed = [](double) { return true; };
int evaluations{0};
bool numerical_rejections{true};
/** @brief Algebraic tests have no descriptive stellar model. */
std::shared_ptr<const Model> model_owner() const { return {}; }
/** @brief Reject nonfinite or negative candidates before publication. */
std::expected<std::shared_ptr<const System>, std::string> evaluate(const State& state) {
++evaluations;
const double x = state.values()[0];
if (!std::isfinite(x) || x < 0.0) {
return std::unexpected("outside physical domain");
}
const auto [residual, derivative] = equation(x);
return std::make_shared<const System>(residual, derivative);
}
/** @brief Inject inverse preparation failure without destroying accepted residual handles. */
std::expected<std::shared_ptr<const Prepared>, std::string> prepare(const State& state) {
if (!preparation_allowed(state.values()[0])) {
return std::unexpected("injected preparation failure");
}
const auto evaluated = evaluate(state);
if (!evaluated) { return std::unexpected(evaluated.error()); }
return std::make_shared<const Prepared>(*evaluated);
}
/** @brief Preserve the physical candidate diagnostic. */
std::string failure_message(const std::string& failure) const { return failure; }
/** @brief A shorter scalar trial can reenter the positive domain. */
bool recoverable(const std::string&) const { return numerical_rejections; }
};
/** @brief Model an affine element determinant using the same physical coordinate as the scalar equation. */
struct GeometryProblem : Problem {
double determinant_direction{0.0};
int inverted_trials{0};
int limit_calls{0};
/** @brief Validate every candidate even when proactive geometry checks are disabled. */
std::expected<std::shared_ptr<const System>, std::string> evaluate(const State& state) {
if (state.values()[0] <= 0.0) {
++inverted_trials;
return std::unexpected("inverted element");
}
return Problem::evaluate(state);
}
/** @brief Capture a physical tangent and return the last safe rung before its determinant vanishes. */
std::expected<serif::geometry::deformation::GeometryStepLimit, std::string> geometry_limit(
const State&, std::span<const double> direction, double maximum, double safety, bool) {
++limit_calls;
determinant_direction = direction[0];
CHECK(maximum == 1.0);
CHECK(safety == 0.9);
return serif::geometry::deformation::GeometryStepLimit{.step = 0.25, .boundary_step = 1.0 / 3.0, .limited_by_geometry = true};
}
/** @brief Use the same determinant estimate to skip unsafe backtracking rungs after an actual rejection. */
double next_step(double step, const solver::Backtracking& options, const auto& evaluation) const {
REQUIRE_FALSE(evaluation);
CHECK(evaluation.error() == "inverted element");
return solver::next_backtracking_step(step, 1.0, 1.0 + determinant_direction * step, true, options);
}
};
/** @brief Inject a failed strategy evaluation to check numerical versus configuration errors. */
struct FailedInitialization : solver::StandardNewton {
/** @brief Mimic a strategy replacing the initial evaluation with a rejected candidate. */
solver::TrialAssessment initialize(Problem&, State&, std::expected<std::shared_ptr<const System>, std::string>& evaluation, const auto&) {
evaluation = std::unexpected("injected initialization failure");
return {.usable = false};
}
};
/** @brief Return prescribed corrections to isolate orchestration from linear convergence. */
struct ScriptedBackend {
std::shared_ptr<std::vector<double>> guesses{std::make_shared<std::vector<double>>()};
std::function<void(int, ::mfem::Vector&)> correction;
/** @brief Capture the safeguarded remainder carried between accepted nonlinear iterations. */
solver::linear::Report solve(const ::mfem::Operator&, ::mfem::Solver&, solver::preconditioner::ApplicationContract,
const ::mfem::Vector&, ::mfem::Vector& output, solver::linear::Control) const {
guesses->push_back(output[0]);
correction(static_cast<int>(guesses->size()), output);
return {.status = solver::linear::Status::converged};
}
};
}
TEST_CASE("Newton converges in physical coordinates and owns its accepted checkpoint", "[solver][nonlinear]") {
std::shared_ptr<const System> first;
Problem problem;
const auto observer = [&](const solver::SolveEvent& event, const State&, const auto& system) {
if (event.kind == solver::EventKind::initial) { first = system; }
};
auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{}, observer);
REQUIRE(result.converged());
REQUIRE(result.has_state());
CHECK_THAT(result.state().values()[0], WithinAbs(std::sqrt(2.0), 1.0e-8));
CHECK(first->residual()[0] == -3.0);
CHECK(result.system() != first);
CHECK(result.diagnostics().accepted_iterations > 0);
CHECK(*result.diagnostics().final_residual_norm <= *result.diagnostics().convergence_threshold);
}
TEST_CASE("A converged terminal root needs no inverse of its singular Jacobian", "[solver][nonlinear]") {
Problem problem;
problem.equation = [](double value) {
return std::pair{(value - 1.0) * (value - 1.0) * (value + 1.0), 3.0 * value * value - 2.0 * value - 1.0};
};
int preparations = 0;
problem.preparation_allowed = [&](double value) {
++preparations;
return value != 1.0;
};
const State original{{0.0}};
const auto result = solver::solve_newton(problem, original);
REQUIRE(result.converged());
CHECK(result.state().values()[0] == 1.0);
CHECK(result.system()->residual()[0] == 0.0);
CHECK(result.system()->derivative(0, 0) == 0.0);
CHECK(result.diagnostics().accepted_iterations == 1);
CHECK(result.diagnostics().attempted_iterations == 1);
CHECK(preparations == 1);
CHECK(original.values()[0] == 0.0);
}
TEST_CASE("Newton separates rejected initial guesses from accepted iteration-limit checkpoints", "[solver][nonlinear]") {
Problem problem;
SECTION("rejected initial state") {
const auto result = solver::solve_newton(problem, State{{-1.0}});
CHECK(result.status() == solver::SolveStatus::initial_state_rejected);
CHECK_FALSE(result.has_state());
CHECK_THROWS_AS(result.state(), std::logic_error);
}
SECTION("zero budget") {
problem.preparation_allowed = [](double) { return false; };
const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.method = {.maximum_iterations = 0}});
CHECK(result.status() == solver::SolveStatus::maximum_iterations);
CHECK(result.state().values()[0] == 1.0);
CHECK(result.diagnostics().attempted_iterations == 0);
}
SECTION("last accepted iteration") {
const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.method = {.maximum_iterations = 1}});
CHECK(result.status() == solver::SolveStatus::maximum_iterations);
CHECK(result.state().values()[0] == 1.5);
CHECK(result.diagnostics().accepted_iterations == 1);
}
}
TEST_CASE("Newton retains the accepted state when preparing an otherwise acceptable candidate fails", "[solver][nonlinear]") {
Problem problem;
SECTION("a numerical rejection returns the accepted checkpoint") {
problem.preparation_allowed = [](double x) { return x == 1.0; };
const auto result = solver::solve_newton(problem, State{{1.0}});
CHECK(result.status() == solver::SolveStatus::preparation_failure);
CHECK(result.state().values()[0] == 1.0);
CHECK(result.system()->residual()[0] == -3.0);
CHECK(result.diagnostics().accepted_iterations == 0);
}
SECTION("a preparation exception propagates before any candidate is accepted") {
const State original{{1.0}};
std::shared_ptr<const System> checkpoint;
int preparations = 0;
int accepted = 0;
problem.preparation_allowed = [&](double x) {
++preparations;
serif::utils::errors::serif_ensure<serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT, std::runtime_error>(
x == 1.0, "injected preconditioner refresh failure");
return true;
};
const auto observe = [&](const solver::SolveEvent& event, const State&, const auto& system) {
if (event.kind == solver::EventKind::initial) {
checkpoint = system;
}
if (event.kind == solver::EventKind::accepted) {
++accepted;
}
};
CHECK_THROWS_WITH(solver::solve_newton(problem, original, solver::SolverOptions{}, observe),
Catch::Matchers::ContainsSubstring("injected preconditioner refresh failure"));
REQUIRE(checkpoint);
CHECK(preparations == 2);
CHECK(accepted == 0);
CHECK(original.values()[0] == 1.0);
CHECK(checkpoint->residual()[0] == -3.0);
problem.preparation_allowed = [](double) { return true; };
CHECK(solver::solve_newton(problem, original).converged());
CHECK(checkpoint->residual()[0] == -3.0);
}
}
TEST_CASE("Newton rejects trials without altering accepted handles and reuses its damped correction", "[solver][nonlinear]") {
Problem problem;
problem.equation = [](double x) { return std::pair{x, 1.0}; };
ScriptedBackend backend{.correction = [](int call, ::mfem::Vector& correction) { correction[0] = call == 1 ? -6.0 : -0.5; }};
std::vector<double> accepted_steps;
std::shared_ptr<const System> initial;
const auto observer = [&](const solver::SolveEvent& event, const State&, const auto& system) {
if (event.kind == solver::EventKind::initial) { initial = system; }
if (event.kind == solver::EventKind::trial_rejected) {
CHECK(initial->residual()[0] == 3.0);
CHECK_FALSE(system);
CHECK_FALSE(event.metric.has_value());
}
if (event.kind == solver::EventKind::accepted) {
REQUIRE(event.metric.has_value());
CHECK(event.metric->valid());
accepted_steps.push_back(event.step);
}
};
const auto result = solver::solve_newton(problem, State{{1.0}},
solver::SolverOptions{.method = {.relative_tolerance = 0.0, .absolute_tolerance = 1.0e-12, .maximum_iterations = 2}, .linear_solver = backend}, observer);
REQUIRE(accepted_steps.size() == 2);
CHECK(accepted_steps[0] == 0.25);
CHECK(accepted_steps[1] == 0.5);
REQUIRE(backend.guesses->size() == 2);
CHECK((*backend.guesses)[0] == 0.0);
CHECK((*backend.guesses)[1] == -4.5);
CHECK(result.diagnostics().rejected_trials == 2);
CHECK(result.state().values()[0] == 0.125);
}
TEST_CASE("Newton reports metric failures without accepting the failed metric", "[solver][nonlinear]") {
Problem problem;
int calls = 0;
SECTION("initial nonfinite norm") {
const auto metric = [](std::span<const double>) { return solver::MetricEvaluation{.residual_norm = std::numeric_limits<double>::quiet_NaN()}; };
const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.metric = metric});
CHECK(result.status() == solver::SolveStatus::invalid_metric);
CHECK_FALSE(result.has_state());
}
SECTION("one nonfinite trial merit") {
const auto metric = [&](std::span<const double> residual) {
if (++calls == 2) { return solver::MetricEvaluation{.merit = std::numeric_limits<double>::infinity()}; }
return solver::NormalizedResidualMetric{}(residual);
};
const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.metric = metric});
CHECK(result.converged());
CHECK(result.diagnostics().rejected_trials >= 1);
}
}
TEST_CASE("Newton checks custom correction dimensions and finiteness", "[solver][nonlinear]") {
Problem problem;
SECTION("nonfinite correction is numerical failure") {
ScriptedBackend backend{.correction = [](int, ::mfem::Vector& correction) { correction[0] = std::numeric_limits<double>::infinity(); }};
const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.linear_solver = backend});
CHECK(result.status() == solver::SolveStatus::linear_solve_failure);
CHECK(result.state().values()[0] == 1.0);
}
SECTION("wrong correction size is a backend contract error") {
ScriptedBackend backend{.correction = [](int, ::mfem::Vector& correction) { correction.SetSize(2); }};
CHECK_THROWS_AS(solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.linear_solver = backend}), std::logic_error);
}
}
TEST_CASE("Newton propagates observer errors while previously published snapshots remain valid", "[solver][nonlinear]") {
for (const auto failure_phase : {solver::EventKind::initial, solver::EventKind::before_iteration,
solver::EventKind::trial_rejected, solver::EventKind::before_accept, solver::EventKind::accepted, solver::EventKind::finished}) {
CAPTURE(static_cast<int>(failure_phase));
Problem problem;
problem.equation = [](double value) { return std::pair{value, 1.0}; };
const State original{{1.0}};
std::shared_ptr<const System> checkpoint;
bool reached_failure = false;
auto observer = [&, token = std::make_unique<int>(19)](const solver::SolveEvent& event, const State&, const auto& system) {
CHECK(*token == 19);
if (event.kind == solver::EventKind::initial) {
checkpoint = system;
}
if (event.kind == failure_phase) {
reached_failure = true;
serif::utils::errors::serif_error<std::runtime_error, serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(
"injected observer local cause at phase {}", static_cast<int>(failure_phase));
}
};
ScriptedBackend backend{.correction = [](int, ::mfem::Vector& correction) { correction[0] = -6.0; }};
const auto options = solver::SolverOptions{.method = {.maximum_iterations = 1}, .linear_solver = backend};
CHECK_THROWS_WITH(solver::solve_newton(problem, original, options, std::move(observer)),
Catch::Matchers::ContainsSubstring("injected observer local cause at phase"));
CHECK(reached_failure);
REQUIRE(checkpoint);
CHECK(checkpoint->residual()[0] == 3.0);
CHECK(original.values()[0] == 1.0);
const auto recovered = solver::solve_newton(problem, original);
CHECK(recovered.converged());
CHECK(recovered.state().values()[0] == 0.0);
CHECK(checkpoint->residual()[0] == 3.0);
}
}
TEST_CASE("Backtracking geometry estimates skip only unsafe ladder rungs", "[solver][nonlinear][geometry]") {
const solver::Backtracking options;
CHECK(solver::next_backtracking_step(1.0, 1.0, -9.0, true, options) == 0.0625);
CHECK(solver::next_backtracking_step(1.0, 1.0, -9.0, false, options) == 0.5);
CHECK(solver::next_backtracking_step(1.0, 1.0, std::numeric_limits<double>::quiet_NaN(), true, options) == 0.5);
CHECK_THROWS_AS(solver::Backtracking{.contraction = 1.0}.validate(), std::logic_error);
CHECK_THROWS_AS(solver::Newton{.relative_tolerance = -1.0}.validate(), std::logic_error);
}
TEST_CASE("Newton distinguishes strategy initialization rejection from invalid configuration", "[solver][nonlinear]") {
Problem problem;
const solver::SolverOptions options{.strategies = FailedInitialization{}};
SECTION("numerical rejection returns an unsuccessful result") {
const auto result = solver::solve_newton(problem, State{{1.0}}, options);
CHECK(result.status() == solver::SolveStatus::initial_state_rejected);
CHECK(result.message() == "injected initialization failure");
CHECK_FALSE(result.has_state());
}
SECTION("configuration failure propagates") {
problem.numerical_rejections = false;
CHECK_THROWS_AS(solver::solve_newton(problem, State{{1.0}}, options), std::logic_error);
}
}
TEST_CASE("Newton geometry safeguards use physical directions and retain validation when preflight is disabled", "[solver][nonlinear][geometry]") {
for (const bool proactive : {false, true}) {
GeometryProblem problem;
problem.equation = [](double value) { return std::pair{value, 1.0}; };
ScriptedBackend backend{.correction = [](int, ::mfem::Vector& correction) { correction[0] = -6.0; }};
std::shared_ptr<const System> first;
const auto observe = [&](const solver::SolveEvent& event, const State&, const auto& system) {
if (event.kind == solver::EventKind::initial) {
first = system;
}
};
const auto result = solver::solve_newton(problem, State{{1.0}},
solver::SolverOptions{.method = {.maximum_iterations = 1}, .linear_solver = backend,
.line_search = {.geometry_preflight = proactive}}, observe);
CHECK(problem.limit_calls == 1);
CHECK(problem.determinant_direction == -3.0);
CHECK(problem.inverted_trials == static_cast<int>(!proactive));
CHECK(result.diagnostics().rejected_trials == static_cast<int>(!proactive));
CHECK(result.diagnostics().geometry_limited_iterations == static_cast<int>(proactive));
CHECK(result.diagnostics().last_accepted_step == 0.25);
CHECK(result.state().values()[0] == 0.25);
REQUIRE(first);
CHECK(first->residual()[0] == 3.0);
}
}
namespace {
/** @brief Reject one otherwise evaluable trial explicitly, without retaining hidden rejection state. */
struct RejectFirstTrial : solver::StandardNewton {
int attempts{0};
/** @brief The second trial uses the original checked direction and may be accepted normally. */
solver::TrialAssessment trial(auto&, const auto&, const auto&, const auto&, double, auto&, auto&, const auto&, double) {
if (++attempts == 1) { return {.usable = false, .message = "injected numerical trial rejection"}; }
return {};
}
};
}
TEST_CASE("Explicit numerical trial rejection cannot invalidate the following candidate", "[solver][nonlinear]") {
Problem problem;
int rejections = 0;
const auto observe = [&](const solver::SolveEvent& event, const State&, const auto& system) {
if (event.kind == solver::EventKind::trial_rejected) {
++rejections;
CHECK(event.message == "injected numerical trial rejection");
CHECK(system != nullptr);
CHECK_FALSE(event.metric.has_value());
}
};
const auto result = solver::solve_newton(problem, State{{1.0}},
solver::SolverOptions{.strategies = RejectFirstTrial{}}, observe);
CHECK(result.converged());
CHECK(rejections == 1);
CHECK_THAT(result.state().values()[0], WithinAbs(std::sqrt(2.0), 1.0e-8));
}
TEST_CASE("Newton resumes the original accepted boundary including damped correction carry", "[solver][nonlinear][restart]") {
Problem uninterrupted_problem;
const solver::SolverOptions options{.method = {.relative_tolerance = 1.0e-10, .maximum_iterations = 20,
.linear_solve = {.relative_tolerance = 1.0e-12}}};
const auto uninterrupted = solver::solve_newton(uninterrupted_problem, State{{0.1}}, options);
REQUIRE(uninterrupted.converged());
Problem interrupted_problem;
const auto stop = [](const solver::SolveEvent& event, const auto&, const auto&) {
return event.kind == solver::EventKind::accepted ? solver::SolveControl::stop : solver::SolveControl::continue_solve;
};
solver::SolveTiming timing;
const auto interrupted = solver::solve_newton(interrupted_problem, State{{0.1}}, options, solver::TimedObserver{timing, stop});
const double interrupted_seconds = timing.seconds;
REQUIRE(interrupted.status() == solver::SolveStatus::stopped);
REQUIRE(interrupted.has_checkpoint());
const auto checkpoint = interrupted.checkpoint();
REQUIRE(checkpoint.progress.diagnostics.accepted_iterations == 1);
CHECK(checkpoint.progress.diagnostics.last_accepted_step < 1.0);
CHECK(checkpoint.progress.correction[0] != 0.0);
CHECK(checkpoint.progress.next_step == checkpoint.progress.diagnostics.last_accepted_step / options.line_search.contraction);
Problem restored_problem;
const auto resumed = solver::resume_newton(restored_problem, checkpoint, options, solver::TimedObserver{timing});
CHECK(timing.seconds >= interrupted_seconds);
REQUIRE(resumed.converged());
CHECK(resumed.state().values()[0] == uninterrupted.state().values()[0]);
CHECK(resumed.diagnostics().initial_residual_norm == uninterrupted.diagnostics().initial_residual_norm);
CHECK(resumed.diagnostics().convergence_threshold == uninterrupted.diagnostics().convergence_threshold);
CHECK(resumed.diagnostics().attempted_iterations == uninterrupted.diagnostics().attempted_iterations);
CHECK(resumed.diagnostics().line_search_trials == uninterrupted.diagnostics().line_search_trials);
CHECK(resumed.diagnostics().total_linear_iterations == uninterrupted.diagnostics().total_linear_iterations);
CHECK(resumed.diagnostics().rejected_trials == uninterrupted.diagnostics().rejected_trials);
}
TEST_CASE("Newton restart retains the consumed budget and validates checkpoint consistency", "[solver][nonlinear][restart]") {
Problem problem;
solver::SolverOptions options{.method = {.maximum_iterations = 1}};
const auto limited = solver::solve_newton(problem, State{{0.1}}, options);
REQUIRE(limited.status() == solver::SolveStatus::maximum_iterations);
const auto checkpoint = limited.checkpoint();
SECTION("Resuming consumes only the remaining total budget") {
const auto no_budget = solver::resume_newton(problem, checkpoint, options);
CHECK(no_budget.status() == solver::SolveStatus::maximum_iterations);
CHECK(no_budget.diagnostics().attempted_iterations == 1);
CHECK(no_budget.state().values()[0] == checkpoint.state.values()[0]);
options.method.maximum_iterations = 20;
const auto continued = solver::resume_newton(problem, checkpoint, options);
CHECK(continued.converged());
CHECK(continued.diagnostics().initial_residual_norm == checkpoint.progress.diagnostics.initial_residual_norm);
}
SECTION("Invalid carried coordinates are rejected before evaluation") {
auto corrupted = checkpoint;
corrupted.progress.correction.clear();
const int before = problem.evaluations;
CHECK_THROWS_AS(solver::resume_newton(problem, corrupted, options), std::logic_error);
CHECK(problem.evaluations == before);
}
SECTION("Changed convergence tolerances cannot silently start a new convergence reference") {
options.method.relative_tolerance *= 0.1;
CHECK_THROWS_AS(solver::resume_newton(problem, checkpoint, options), std::logic_error);
}
SECTION("A changed physical problem cannot pass off its residual as the saved one") {
problem.equation = [](double value) { return std::pair{value * value - 3.0, 2.0 * value}; };
CHECK_THROWS_AS(solver::resume_newton(problem, checkpoint, options), std::logic_error);
}
}
TEST_CASE("Newton stops only at accepted boundaries and does not silently discard strategy history", "[solver][nonlinear][restart]") {
Problem problem;
SECTION("An initial accepted state can be checkpointed before any linear solve") {
const auto stop = [](const solver::SolveEvent&, const auto&, const auto&) { return solver::SolveControl::stop; };
const auto stopped = solver::solve_newton(problem, State{{0.1}}, solver::SolverOptions{}, stop);
CHECK(stopped.status() == solver::SolveStatus::stopped);
REQUIRE(stopped.has_checkpoint());
CHECK(stopped.checkpoint().progress.diagnostics.attempted_iterations == 0);
CHECK(stopped.checkpoint().progress.correction[0] == 0.0);
}
SECTION("Existing void observers retain the temporary event calling convention") {
int notifications = 0;
const auto observe = [&](solver::SolveEvent&&, const auto&, const auto&) { ++notifications; };
const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{}, observe);
CHECK(result.converged());
CHECK(notifications > 0);
}
SECTION("Trial callbacks cannot interrupt an incomplete Newton step") {
const auto stop = [](const solver::SolveEvent& event, const auto&, const auto&) {
return event.kind == solver::EventKind::trial_rejected || event.kind == solver::EventKind::before_accept ?
solver::SolveControl::stop : solver::SolveControl::continue_solve;
};
const auto result = solver::solve_newton(problem, State{{0.1}}, solver::SolverOptions{}, stop);
CHECK(result.status() == solver::SolveStatus::stopped);
CHECK(result.diagnostics().accepted_iterations == 1);
CHECK(result.diagnostics().rejected_trials > 0);
CHECK(result.has_checkpoint());
}
SECTION("A before-accept stop request survives a later continue response") {
int accepted_notifications = 0;
const auto stop = [&](const solver::SolveEvent& event, const auto&, const auto&) {
if (event.kind == solver::EventKind::accepted) {
++accepted_notifications;
}
return event.kind == solver::EventKind::before_accept ? solver::SolveControl::stop : solver::SolveControl::continue_solve;
};
const auto result = solver::solve_newton(problem, State{{0.1}}, solver::SolverOptions{}, stop);
CHECK(result.status() == solver::SolveStatus::stopped);
CHECK(accepted_notifications == 1);
REQUIRE(result.has_checkpoint());
CHECK(result.checkpoint().progress.diagnostics.accepted_iterations == 1);
}
SECTION("Ordinary custom strategies still solve but cannot claim a complete restart") {
struct CustomStrategy : solver::StandardNewton {
int history{0};
};
const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.strategies = CustomStrategy{}});
CHECK(result.converged());
CHECK_FALSE(result.has_checkpoint());
CHECK_THROWS_AS(result.checkpoint(), std::logic_error);
}
}

View File

@@ -0,0 +1,195 @@
#include "serif/tests/solver/native_problem.hpp"
#include <array>
#include <cmath>
#include <expected>
#include <functional>
#include <limits>
#include <memory>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "mfem/linalg/densemat.hpp"
#include "serif/solver/nonlinear/newton.hpp"
#include "serif/solver/normalization/diagonal.hpp"
namespace {
namespace solver = serif::solver;
/** @brief Keep one independent physical value for orchestration contracts. */
struct State {
std::array<double, 1> coordinates;
/** @brief Edit only a local candidate's physical coordinate. */
std::span<double> values() { return coordinates; }
/** @brief Observe accepted coordinates without mutating them. */
std::span<const double> values() const { return coordinates; }
};
/** @brief Freeze one identity equation with dimensionless coordinates. */
struct System {
std::array<double, 1> residual_values;
::mfem::DenseMatrix derivative{1};
solver::normalization::DiagonalNormalization normalization = *solver::normalization::make_diagonal_normalization({1.0}, {1.0});
/** @brief Retain residual values independently of subsequent evaluations. */
explicit System(double value) : residual_values{value} { derivative(0, 0) = 1.0; }
/** @brief Expose the complete residual. */
const auto& residual() const { return residual_values; }
/** @brief Expose its matching linearization. */
const ::mfem::Operator& jacobian() const { return derivative; }
/** @brief Transfer normalized corrections back to physical coordinates. */
const auto& coordinates() const { return normalization; }
};
struct Identity : ::mfem::Solver {
/** @brief The test derivative is exactly one. */
Identity() : ::mfem::Solver(1) {}
/** @brief A frozen identity does not require operator rebinding. */
void SetOperator(const ::mfem::Operator&) override {}
/** @brief Apply the exact identity inverse. */
void Mult(const ::mfem::Vector& input, ::mfem::Vector& output) const override { output = input; }
};
struct Prepared {
std::shared_ptr<const System> owner;
mutable Identity inverse;
/** @brief Retain the system before borrowing its inverse. */
explicit Prepared(std::shared_ptr<const System> system) : owner(std::move(system)) {}
/** @brief Expose the matching residual and derivative owner. */
const auto& system() const { return owner; }
/** @brief Borrow the retained exact inverse. */
::mfem::Solver& preconditioner() const { return inverse; }
/** @brief Identity is a fixed linear operation. */
auto contract() const { return solver::preconditioner::ApplicationContract::fixed_linear; }
};
struct Problem : serif::tests::solver::NativeProblem {
using System = ::System;
using Model = solver::NoModel;
int evaluations{0};
int preparations{0};
/** @brief This algebraic regression has no physical model. */
std::shared_ptr<const Model> model_owner() const { return {}; }
/** @brief Publish immutable snapshots for the identity residual. */
std::expected<std::shared_ptr<const System>, std::string> evaluate(const State& state) {
++evaluations;
return std::make_shared<const System>(state.values()[0]);
}
/** @brief Count preparation independently of residual evaluation. */
std::expected<std::shared_ptr<const Prepared>, std::string> prepare(const State& state) {
++preparations;
return std::make_shared<const Prepared>(*evaluate(state));
}
/** @brief Preserve a numerical rejection's diagnostic. */
std::string failure_message(const std::string& error) const { return error; }
/** @brief The scalar fixture reports only numerical failures. */
bool recoverable(const std::string&) const { return true; }
};
struct Scripted {
std::function<void(::mfem::Vector&)> correction;
/** @brief Supply a known correction to test orchestration rather than Krylov algebra. */
solver::linear::Report solve(const ::mfem::Operator&, ::mfem::Solver&, solver::preconditioner::ApplicationContract,
const ::mfem::Vector&, ::mfem::Vector& output, solver::linear::Control) const {
correction(output);
return {.status = solver::linear::Status::converged, .iterations = 3};
}
};
}
TEST_CASE("Newton numerical options and default metrics retain their migrated contracts", "[unit][solver][nonlinear][parity]") {
solver::Newton options{.relative_tolerance = 0.25, .absolute_tolerance = 0.5};
CHECK(options.convergence_threshold(4.0) == 1.0);
options.absolute_tolerance = 2.0;
CHECK(options.convergence_threshold(4.0) == 2.0);
CHECK_THROWS_AS(options.convergence_threshold(-1.0), std::logic_error);
CHECK_THROWS_AS(options.convergence_threshold(std::numeric_limits<double>::infinity()), std::logic_error);
for (const auto invalid : {solver::Newton{.relative_tolerance = -1.0},
solver::Newton{.absolute_tolerance = std::numeric_limits<double>::infinity()}, solver::Newton{.maximum_iterations = -1},
solver::Newton{.linear_solve = {.maximum_iterations = 0}}}) {
CHECK_THROWS_AS(invalid.validate(), std::logic_error);
}
for (const auto invalid : {solver::Backtracking{.initial_step = 0.0}, solver::Backtracking{.contraction = 1.0},
solver::Backtracking{.fraction_to_boundary = 1.0}, solver::Backtracking{.sufficient_decrease = 0.0},
solver::Backtracking{.minimum_step = 2.0}, solver::Backtracking{.maximum_trials = 0}}) {
CHECK_THROWS_AS(invalid.validate(), std::logic_error);
}
const std::array residual{3.0, 4.0};
const auto metric = solver::NormalizedResidualMetric{}(residual);
CHECK(metric.residual_norm == 5.0);
CHECK(metric.merit == 12.5);
CHECK_FALSE(solver::MetricEvaluation{.residual_norm = -1.0}.valid());
CHECK_FALSE(solver::MetricEvaluation{.merit = std::numeric_limits<double>::quiet_NaN()}.valid());
}
TEST_CASE("A zero Newton work budget evaluates its checkpoint without preparing any inverse", "[unit][solver][nonlinear][parity]") {
Problem problem;
const auto result = solver::solve_newton(problem, State{{2.0}}, solver::SolverOptions{.method = {.maximum_iterations = 0}});
CHECK(result.status() == solver::SolveStatus::maximum_iterations);
CHECK(result.has_state());
CHECK(result.state().values()[0] == 2.0);
CHECK(problem.evaluations == 1);
CHECK(problem.preparations == 0);
CHECK(result.diagnostics().attempted_iterations == 0);
CHECK(result.diagnostics().line_search_trials == 0);
}
TEST_CASE("Newton events and counters distinguish exhausted globalization from an accepted final iteration", "[unit][solver][nonlinear][parity]") {
for (const bool decreasing : {false, true}) {
Problem problem;
std::vector<solver::EventKind> events;
const auto observer = [&](const solver::SolveEvent& event, const State& state, const auto&) {
events.push_back(event.kind);
if (event.kind == solver::EventKind::finished) {
CHECK(event.diagnostics.attempted_iterations == 1);
CHECK(event.diagnostics.accepted_iterations == static_cast<int>(decreasing));
CHECK(event.diagnostics.total_linear_iterations == 3);
CHECK(state.values()[0] == (decreasing ? 0.5 : 1.0));
}
};
const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{
.method = {.relative_tolerance = 0.0, .absolute_tolerance = 0.0, .maximum_iterations = 1},
.linear_solver = Scripted{[decreasing](auto& output) { output[0] = decreasing ? -0.5 : 0.0; }},
.line_search = {.maximum_trials = 1}}, observer);
CHECK(result.status() == (decreasing ? solver::SolveStatus::maximum_iterations : solver::SolveStatus::globalization_failure));
CHECK(result.diagnostics().line_search_trials == 1);
CHECK(result.diagnostics().rejected_trials == static_cast<int>(!decreasing));
std::vector expected{solver::EventKind::initial, solver::EventKind::before_iteration};
if (decreasing) {
expected.push_back(solver::EventKind::before_accept);
expected.push_back(solver::EventKind::accepted);
} else {
expected.push_back(solver::EventKind::trial_rejected);
}
expected.push_back(solver::EventKind::finished);
CHECK(events == expected);
}
}
TEST_CASE("A trial metric exception preserves published checkpoints and a fresh solve starts independently", "[unit][solver][nonlinear][parity]") {
Problem problem;
const State original{{1.0}};
std::shared_ptr<const System> checkpoint;
int metrics = 0;
const auto metric = [&](std::span<const double> residual) {
serif::utils::errors::serif_ensure<serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT, std::runtime_error>(
++metrics != 2, "injected trial metric failure");
return solver::NormalizedResidualMetric{}(residual);
};
const auto observer = [&](const solver::SolveEvent& event, const State&, const auto& system) {
if (event.kind == solver::EventKind::initial) { checkpoint = system; }
};
CHECK_THROWS_AS(solver::solve_newton(problem, original, solver::SolverOptions{.metric = metric}, observer), std::runtime_error);
REQUIRE(checkpoint);
CHECK(checkpoint->residual()[0] == 1.0);
CHECK(original.values()[0] == 1.0);
const auto recovered = solver::solve_newton(problem, original);
CHECK(recovered.converged());
CHECK(recovered.state().values()[0] == 0.0);
CHECK(checkpoint->residual()[0] == 1.0);
}
TEST_CASE("A nonfinite Newton correction cannot become the next solve's incoming guess", "[unit][solver][nonlinear][parity]") {
Problem problem;
int calls = 0;
auto options = solver::SolverOptions{.linear_solver = Scripted{[&](auto& output) {
CHECK(output[0] == 0.0);
output[0] = ++calls == 1 ? std::numeric_limits<double>::quiet_NaN() : -1.0;
}}};
CHECK(solver::solve_newton(problem, State{{1.0}}, options).status() == solver::SolveStatus::linear_solve_failure);
CHECK(solver::solve_newton(problem, State{{1.0}}, options).converged());
CHECK(calls == 2);
}

362
tests/solver/seed.cpp Normal file
View File

@@ -0,0 +1,362 @@
#include <algorithm>
#include <cmath>
#include <numbers>
#include <limits>
#include "serif/backend/mfem/discretization/dof_selection.hpp"
#include "serif/backend/mfem/model/equilibrium.hpp"
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "serif/solver/seed/polytrope.hpp"
#include "serif/backend/mfem/solver/seed/state.hpp"
#include "serif/tests/solver/stellar_fixture.hpp"
namespace {
namespace seed = serif::solver::seed;
namespace dimensions = serif::dimensions;
namespace fixture = serif::tests::solver;
}
TEST_CASE("Polytropic seeds retain the EOS and solve the requested mass scale", "[unit][solver][seed]") {
const auto star = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.05);
const auto radial = seed::make_polytropic_seed(star, {.integration = {.step = 0.002}});
REQUIRE(radial);
CHECK(radial->scales.mass.value() == Catch::Approx(1.0).epsilon(2.0e-12));
CHECK(radial->scales.radius.value() == Catch::Approx(1.0).epsilon(2.0e-9));
CHECK(radial->scales.central_density.value() == Catch::Approx(std::numbers::pi / 4.0).epsilon(3.0e-9));
CHECK(radial->scales.central_enthalpy.value() == Catch::Approx(1.0).epsilon(3.0e-9));
CHECK(*radial->scales.polytropic_constant == Catch::Approx(2.0 / std::numbers::pi).epsilon(3.0e-12));
const double inertia = 2.0 * (std::numbers::pi * std::numbers::pi - 6.0) / (3.0 * std::numbers::pi * std::numbers::pi);
CHECK(radial->moment_of_inertia == Catch::Approx(inertia).epsilon(3.0e-8));
CHECK(radial->angular_velocity == Catch::Approx(0.05 / inertia).epsilon(3.0e-8));
const auto changed_mass = seed::make_polytropic_seed(serif::eos::models::Polytrope{1.5, 0.4}, dimensions::MassValue{2.0});
REQUIRE(changed_mass);
CHECK(changed_mass->scales.mass.value() == Catch::Approx(2.0).epsilon(3.0e-12));
CHECK(*changed_mass->scales.polytropic_constant == Catch::Approx(0.4).epsilon(3.0e-12));
}
TEST_CASE("Polytropic seeds diagnose missing homology scales and incompatible physical models", "[unit][solver][seed]") {
const serif::eos::models::Polytrope n3{3.0, 1.0};
CHECK_FALSE(seed::make_polytropic_seed(n3, dimensions::MassValue{1.0}));
CHECK(seed::make_polytropic_seed(n3, dimensions::MassValue{1.0}, {.central_density = dimensions::DensityValue{2.0}}));
const auto dimensionless = serif::utils::laneemden::solve(3.0);
REQUIRE(dimensionless);
const auto scales = serif::utils::laneemden::scale_from_polytropic_constant(*dimensionless, 1.0, dimensions::DensityValue{2.0},
dimensions::QuantityValue<dimensions::GravitationalConstant>{1.0});
REQUIRE(scales);
CHECK(seed::make_polytropic_seed(n3, scales->mass, {.central_density = dimensions::DensityValue{2.0}}));
CHECK_FALSE(seed::make_polytropic_seed(serif::eos::models::Polytrope{1.0, 1.0}, dimensions::MassValue{-1.0}));
CHECK_FALSE(seed::make_polytropic_seed(serif::eos::models::Polytrope{1.0, 1.0}, dimensions::MassValue{1.0},
{.integration = {.maximum_radius = 1.0}}));
}
TEST_CASE("Spherical seed projection initializes every moving stellar coordinate by physical identity", "[integration][solver][seed]") {
const auto star = fixture::make_model();
const auto radial = seed::make_polytropic_seed(star);
REQUIRE(radial);
const auto resources = fixture::make_resources(radial->scales.radius.value());
const auto discretization = fixture::make_discretization(resources, 2);
const auto state = seed::backend::mfem::make_polytropic_state(star, discretization, *radial);
if (!state) {
INFO(state.error());
}
REQUIRE(state);
CHECK(std::ranges::all_of(state->values(), [](double value) { return std::isfinite(value); }));
CHECK(state->template get<fixture::Density>().size() == static_cast<std::size_t>(resources->density.size()));
CHECK(state->template get<fixture::Potential>().size() == static_cast<std::size_t>(resources->potential.size()));
CHECK(state->template get<fixture::Shape>().size() == 9);
CHECK(std::ranges::all_of(state->template get<fixture::Shape>(), [](double value) { return value == 0.0; }));
CHECK(state->template get<fixture::Constant>()[0] == Catch::Approx(-1.0).epsilon(3.0e-7));
CHECK(state->template get<fixture::Omega>()[0] == 0.0);
CHECK(std::ranges::min(state->template get<fixture::Density>()) >= 0.0);
CHECK(std::ranges::max(state->template get<fixture::Density>()) > 0.7);
const auto surface_rows = serif::discretization::backend::mfem::select_boundary_dofs<serif::discretization::domain::StellarSurfaceBoundary>(resources->density);
REQUIRE(surface_rows.size() > 0);
for (const int dof : surface_rows.reduced_dofs()) {
CHECK(state->template get<fixture::Density>()[dof] == 0.0);
}
}
TEST_CASE("Central seed density remains an initial guess independent of the model's mass prescription", "[unit][solver][seed]") {
const auto base = fixture::make_model(3.0, 0.25);
const auto model = serif::model::make_stellar_model(base.equation_of_state(), base.equations(),
serif::model::FixedTotalMass{dimensions::MassValue{1.0}},
base.specification<serif::model::FixedAngularMomentum>());
const auto from_model = seed::make_polytropic_seed(model, {.central_density = dimensions::DensityValue{2.0}});
REQUIRE(from_model);
CHECK(from_model->scales.central_density.value() == 2.0);
CHECK(from_model->scales.central_enthalpy.value() == Catch::Approx(std::cbrt(2.0)));
const seed::PolytropeOptions options{.central_density = dimensions::DensityValue{1.7}};
const auto explicit_seed = seed::make_polytropic_seed(model, options);
const auto other_mass = seed::make_polytropic_seed(base.equation_of_state(), dimensions::MassValue{9.0}, options);
REQUIRE(explicit_seed);
REQUIRE(other_mass);
CHECK(explicit_seed->scales.central_density.value() == 1.7);
CHECK(model.specification<serif::model::FixedTotalMass>().target().value() == 1.0);
CHECK(explicit_seed->scales.radius == other_mass->scales.radius);
CHECK(explicit_seed->scales.mass == other_mass->scales.mass);
double previous_density = 2.0;
double previous_enthalpy = from_model->scales.central_enthalpy.value();
for (int index = 0; index < 64; ++index) {
const double fraction = static_cast<double>(index) / 63.0;
const auto physical = serif::utils::laneemden::evaluate_physical(from_model->solution, from_model->scales,
dimensions::LengthValue{fraction * from_model->scales.radius.value()});
const auto first = serif::utils::laneemden::evaluate_physical(explicit_seed->solution, explicit_seed->scales,
dimensions::LengthValue{fraction * explicit_seed->scales.radius.value()});
const auto second = serif::utils::laneemden::evaluate_physical(other_mass->solution, other_mass->scales,
dimensions::LengthValue{fraction * other_mass->scales.radius.value()});
REQUIRE(physical);
REQUIRE(first);
REQUIRE(second);
CHECK(first->density == second->density);
CHECK(first->enthalpy == second->enthalpy);
CHECK(physical->density.value() >= 0.0);
CHECK(physical->enthalpy.value() >= 0.0);
CHECK(physical->density.value() <= previous_density);
CHECK(physical->enthalpy.value() <= previous_enthalpy);
previous_density = physical->density.value();
previous_enthalpy = physical->enthalpy.value();
}
CHECK(previous_density == 0.0);
CHECK(previous_enthalpy == 0.0);
}
TEST_CASE("Seed generation rejects invalid density scales and stars without a finite surface", "[unit][solver][seed]") {
const auto model = fixture::make_model();
for (const double density : {0.0, -1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_FALSE(seed::make_polytropic_seed(model, {.central_density = dimensions::DensityValue{density}}));
}
CHECK_FALSE(seed::make_polytropic_seed(serif::eos::models::Polytrope{5.0, 0.25}, dimensions::MassValue{1.0},
{.central_density = dimensions::DensityValue{1.0}}));
}
TEST_CASE("Radial seed projection rejects incompatible surfaces and invalid radius tolerances", "[integration][solver][seed]") {
const auto model = fixture::make_model();
const auto radial = seed::make_polytropic_seed(model);
REQUIRE(radial);
const auto resources = fixture::make_resources(radial->scales.radius.value());
const auto finite_elements = fixture::make_discretization(resources, 0);
for (const double tolerance : {-1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_FALSE(seed::backend::mfem::make_polytropic_state(model, finite_elements, *radial, {.surface_radius_relative_tolerance = tolerance}));
}
const auto pressured = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.0, 1.0, 0.01);
CHECK_FALSE(seed::backend::mfem::make_polytropic_state(pressured, finite_elements, *radial));
const auto wrong_reference = fixture::make_resources(1.1 * radial->scales.radius.value());
CHECK_FALSE(seed::backend::mfem::make_polytropic_state(model, fixture::make_discretization(wrong_reference, 0), *radial));
}
TEST_CASE("Projected radial stars satisfy mass and angular momentum constraints with exact surface and infinity rows", "[integration][solver][seed]") {
namespace model = serif::model;
namespace backend = model::backend::mfem;
namespace dependencies = serif::utils::dependencies;
using Kind = serif::discretization::blocks::RepresentedValueKind;
const auto star = fixture::make_model();
const auto radial = seed::make_polytropic_seed(star);
REQUIRE(radial);
const auto resources = fixture::make_resources(radial->scales.radius.value(), 1, 4);
const auto finite_elements = fixture::make_discretization(resources, 0);
const auto state = seed::backend::mfem::make_polytropic_state(star, finite_elements, *radial);
if (!state) {
INFO(state.error());
}
REQUIRE(state);
using State = std::remove_cvref_t<decltype(*state)>;
static_assert(dependencies::key_count_v<typename State::Layout::Values> == 5);
for (const auto values : {state->template get<fixture::Density>(), state->template get<fixture::Potential>()}) {
CHECK(std::ranges::any_of(values, [](double value) { return value != 0.0; }));
}
auto evaluator = backend::bind_stellar_model(star, finite_elements, *state);
const auto evaluated = evaluator.evaluate(*state);
REQUIRE(evaluated);
const auto residual = (*evaluated)->residual();
CHECK(std::ranges::all_of(residual, [](double value) { return std::isfinite(value); }));
CHECK(std::abs(residual[state->layout().template offset<Kind::residual, fixture::Constant>()]) < 5.0e-4);
CHECK(std::abs(residual[state->layout().template offset<Kind::residual, fixture::Omega>()]) < 1.0e-10);
const auto surface_rows = serif::discretization::backend::mfem::select_boundary_dofs<serif::discretization::domain::StellarSurfaceBoundary>(resources->density);
const auto offset = state->layout().template offset<Kind::residual, fixture::Density>();
for (const int dof : surface_rows.reduced_dofs()) {
CHECK(state->template get<fixture::Density>()[dof] == 0.0);
CHECK(residual[offset + dof] == 0.0);
}
const auto infinity_rows = serif::discretization::backend::mfem::select_boundary_dofs<serif::discretization::domain::InfinitySurfaceBoundary>(resources->potential);
REQUIRE(infinity_rows.size() > 0);
const auto potential_offset = state->layout().template offset<Kind::residual, fixture::Potential>();
for (const int dof : infinity_rows.reduced_dofs()) {
CHECK(state->template get<fixture::Potential>()[dof] == 0.0);
CHECK(residual[potential_offset + dof] == 0.0);
}
}
TEST_CASE("Explicit radial fitting preserves thermodynamics and gives the stretched density consistent gravity", "[unit][solver][seed][fitting]") {
const auto star = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.05);
const auto radial = seed::make_polytropic_seed(star, {.integration = {.step = 0.002}});
REQUIRE(radial);
const seed::RadialSeed original = *radial;
for (const double stretch : {0.5, 1.0, 2.0}) {
const auto fitted = seed::fit_radial_seed(*radial, dimensions::LengthValue{stretch * radial->scales.radius.value()});
REQUIRE(fitted);
CHECK(fitted->mass.value() == Catch::Approx(radial->scales.mass.value() * std::pow(stretch, 3)));
CHECK(fitted->moment_of_inertia == Catch::Approx(radial->moment_of_inertia * std::pow(stretch, 5)));
CHECK(fitted->angular_velocity * fitted->moment_of_inertia == Catch::Approx(0.05));
CHECK(fitted->source.scales.radial_scale == original.scales.radial_scale);
CHECK(fitted->source.scales.radius == original.scales.radius);
CHECK(fitted->source.scales.mass == original.scales.mass);
CHECK(fitted->source.scales.polytropic_constant == original.scales.polytropic_constant);
for (const double fraction : {0.0, 0.1, 0.4, 0.9, 1.0, 2.0}) {
const auto source = seed::evaluate_radial_seed(*radial, dimensions::LengthValue{fraction * radial->scales.radius.value()});
const auto sample = seed::evaluate_radial_seed(*fitted, dimensions::LengthValue{fraction * fitted->radius.value()});
REQUIRE(source);
REQUIRE(sample);
CHECK(sample->density.value() == Catch::Approx(source->density.value()).margin(1.0e-14));
CHECK(sample->enthalpy.value() == Catch::Approx(source->enthalpy.value()).margin(1.0e-14));
CHECK(sample->pressure.value() == Catch::Approx(source->pressure.value()).margin(1.0e-14));
CHECK(sample->enclosed_mass.value() == Catch::Approx(source->enclosed_mass.value() * std::pow(stretch, 3)).margin(1.0e-14));
CHECK(sample->potential.value() == Catch::Approx(source->potential.value() * stretch * stretch));
CHECK(sample->gravity.value() == Catch::Approx(source->gravity.value() * stretch).margin(1.0e-14));
if (fraction > 0.0) {
const double radius = fraction * fitted->radius.value();
CHECK(sample->gravity.value() == Catch::Approx(radial->scales.gravitational_constant.value() * sample->enclosed_mass.value() / (radius * radius)).epsilon(1.0e-12));
}
}
// Independent spherical quadrature checks the mass and inertia assigned to the stretched density.
constexpr int intervals = 2000;
const double width = fitted->radius.value() / intervals;
double mass = 0.0;
double inertia = 0.0;
for (int index = 0; index <= intervals; ++index) {
const double radius = width * index;
const auto sample = seed::evaluate_radial_seed(*fitted, dimensions::LengthValue{radius});
REQUIRE(sample);
const double weight = (index == 0 || index == intervals) ? 1.0 : (index % 2 == 0 ? 2.0 : 4.0);
mass += weight * sample->density.value() * radius * radius;
inertia += weight * sample->density.value() * std::pow(radius, 4);
}
mass *= width * 4.0 * std::numbers::pi / 3.0;
inertia *= width * 8.0 * std::numbers::pi / 9.0;
CHECK(mass == Catch::Approx(fitted->mass.value()).epsilon(2.0e-8));
CHECK(inertia == Catch::Approx(fitted->moment_of_inertia).epsilon(2.0e-8));
}
REQUIRE(radial->solution.samples.size() == original.solution.samples.size());
for (std::size_t index = 0; index < original.solution.samples.size(); ++index) {
CHECK(radial->solution.samples[index].xi == original.solution.samples[index].xi);
CHECK(radial->solution.samples[index].theta == original.solution.samples[index].theta);
CHECK(radial->solution.samples[index].derivative == original.solution.samples[index].derivative);
}
CHECK(radial->scales.radius == original.scales.radius);
CHECK(radial->scales.central_density == original.scales.central_density);
CHECK(radial->scales.central_enthalpy == original.scales.central_enthalpy);
CHECK(radial->angular_velocity == original.angular_velocity);
CHECK(star.equation_of_state().polytropic_constant() == 2.0 / std::numbers::pi);
CHECK(star.specification<serif::model::FixedTotalMass>().target().value() == 1.0);
CHECK(star.specification<serif::model::FixedAngularMomentum>().target().value() == 0.05);
// These consistent units keep the final physical quantities finite even though the standalone stretch^5 is not.
for (const double stretch : {1.0e-70, 1.0e70}) {
const double source_radius = stretch > 1.0 ? 1.0e-10 : 1.0e10;
const double source_mass = stretch > 1.0 ? 1.0e-270 : 1.0e270;
const double gravity_constant = stretch > 1.0 ? 1.0e260 : 1.0e-260;
const double constant = 2.0 * gravity_constant * source_radius * source_radius / std::numbers::pi;
auto extreme = seed::make_polytropic_seed(serif::eos::models::Polytrope{1.0, constant}, dimensions::MassValue{source_mass},
{.gravitational_constant = dimensions::QuantityValue<dimensions::GravitationalConstant>{gravity_constant},
.central_density = dimensions::DensityValue{std::numbers::pi * source_mass / (4.0 * source_radius * source_radius * source_radius)}});
REQUIRE(extreme);
extreme->angular_velocity = 1.0 / extreme->moment_of_inertia;
const auto fitted = seed::fit_radial_seed(*extreme, dimensions::LengthValue{stretch * extreme->scales.radius.value()});
REQUIRE(fitted);
CHECK(fitted->mass.value() == Catch::Approx(extreme->scales.mass.value() * stretch * stretch * stretch).epsilon(1.0e-12));
CHECK(fitted->moment_of_inertia == Catch::Approx(extreme->moment_of_inertia * stretch * stretch * stretch * stretch * stretch).epsilon(1.0e-12));
CHECK(fitted->angular_velocity * fitted->moment_of_inertia == Catch::Approx(1.0).epsilon(1.0e-12));
const auto surface = seed::evaluate_radial_seed(*fitted, fitted->radius);
const auto interior = seed::evaluate_radial_seed(*fitted, dimensions::LengthValue{0.5 * fitted->radius.value()});
REQUIRE(surface);
REQUIRE(interior);
CHECK(std::isfinite(surface->gravity.value()));
CHECK(std::isfinite(interior->potential.value()));
}
}
TEST_CASE("Radial fitting rejects invalid profiles and unrepresentable physical scales", "[unit][solver][seed][fitting]") {
const auto radial = seed::make_polytropic_seed(fixture::make_model());
REQUIRE(radial);
for (const double radius : {0.0, -1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN(),
std::numeric_limits<double>::max(), std::numeric_limits<double>::denorm_min()}) {
CHECK_FALSE(seed::fit_radial_seed(*radial, dimensions::LengthValue{radius}));
}
auto invalid = *radial;
invalid.solution.samples[2].xi = invalid.solution.samples[1].xi;
CHECK_FALSE(seed::fit_radial_seed(invalid, dimensions::LengthValue{1.0}));
invalid = *radial;
invalid.solution.samples[2].theta = std::numeric_limits<double>::quiet_NaN();
CHECK_FALSE(seed::fit_radial_seed(invalid, dimensions::LengthValue{1.0}));
invalid = *radial;
invalid.moment_of_inertia = 0.0;
CHECK_FALSE(seed::fit_radial_seed(invalid, dimensions::LengthValue{1.0}));
invalid = *radial;
invalid.angular_velocity = std::numeric_limits<double>::infinity();
CHECK_FALSE(seed::fit_radial_seed(invalid, dimensions::LengthValue{1.0}));
invalid = *radial;
invalid.center[0] = std::numeric_limits<double>::quiet_NaN();
CHECK_FALSE(seed::fit_radial_seed(invalid, dimensions::LengthValue{1.0}));
const auto fitted = seed::fit_radial_seed(*radial, dimensions::LengthValue{0.5});
REQUIRE(fitted);
for (const double radius : {-1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_FALSE(seed::evaluate_radial_seed(*fitted, dimensions::LengthValue{radius}));
}
CHECK(seed::evaluate_radial_seed(*fitted, dimensions::LengthValue{std::numeric_limits<double>::max()}));
for (const double invalid_scale : {0.0, -1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
auto malformed = *fitted;
malformed.mass = dimensions::MassValue{invalid_scale};
CHECK_FALSE(seed::evaluate_radial_seed(malformed, dimensions::LengthValue{2.0}));
malformed = *fitted;
malformed.source.scales.radius = dimensions::LengthValue{invalid_scale};
CHECK_FALSE(seed::evaluate_radial_seed(malformed, dimensions::LengthValue{2.0}));
malformed = *fitted;
malformed.source.scales.gravitational_constant = dimensions::QuantityValue<dimensions::GravitationalConstant>{invalid_scale};
CHECK_FALSE(seed::evaluate_radial_seed(malformed, dimensions::LengthValue{2.0}));
}
}
TEST_CASE("Reference radius fitting is explicit and rejects nonspherical surfaces", "[integration][solver][seed][fitting]") {
const auto star = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.05);
const auto radial = seed::make_polytropic_seed(star);
REQUIRE(radial);
const auto resources = fixture::make_resources(2.0 * radial->scales.radius.value());
const auto discretization = fixture::make_discretization(resources, 0);
CHECK_FALSE(seed::backend::mfem::make_polytropic_state(star, discretization, *radial));
const auto fitted = seed::backend::mfem::fit_radial_seed(discretization, *radial);
if (!fitted) {
INFO(fitted.error());
}
REQUIRE(fitted);
CHECK(fitted->radius.value() == Catch::Approx(2.0 * radial->scales.radius.value()).epsilon(5.0e-4));
const auto state = seed::backend::mfem::make_polytropic_state(star, discretization, *fitted);
if (!state) {
INFO(state.error());
}
REQUIRE(state);
CHECK(std::ranges::all_of(state->values(), [](double value) { return std::isfinite(value); }));
CHECK(state->template get<fixture::Constant>()[0] == Catch::Approx(-radial->scales.gravitational_constant.value() * fitted->mass.value() / fitted->radius.value()));
CHECK(state->template get<fixture::Omega>()[0] == fitted->angular_velocity);
CHECK(std::ranges::all_of(state->template get<fixture::Shape>(), [](double value) { return value == 0.0; }));
const auto surface_rows = serif::discretization::backend::mfem::select_boundary_dofs<serif::discretization::domain::StellarSurfaceBoundary>(resources->density);
REQUIRE(surface_rows.size() > 0);
for (const int dof : surface_rows.reduced_dofs()) {
CHECK(state->template get<fixture::Density>()[dof] == 0.0);
}
for (const double tolerance : {-1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
CHECK_FALSE(seed::backend::mfem::fit_radial_seed(discretization, *radial, tolerance));
}
const auto ellipsoid = fixture::make_resources();
/** @brief Both physical and logical meshes describe the same intentionally nonspherical reference. */
const auto stretch_axis = [](const ::mfem::Vector& point, ::mfem::Vector& mapped) {
mapped = point;
mapped[0] *= 1.2;
};
ellipsoid->mesh.mesh->Transform(stretch_axis);
ellipsoid->mesh.reference_mesh->Transform(stretch_axis);
const auto nonspherical = seed::backend::mfem::fit_radial_seed(fixture::make_discretization(ellipsoid, 0), *radial);
REQUIRE_FALSE(nonspherical);
CHECK(nonspherical.error().find("spherical") != std::string::npos);
CHECK(radial->scales.radius.value() == Catch::Approx(1.0).epsilon(2.0e-7));
}

173
tests/solver/strategies.cpp Normal file
View File

@@ -0,0 +1,173 @@
#include "serif/tests/equations/test_equations.hpp"
#include "serif/tests/solver/native_problem.hpp"
#include <cmath>
#include <expected>
#include <algorithm>
#include <memory>
#include <string>
#include <vector>
#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>
#include "mfem/linalg/densemat.hpp"
#include "serif/backend/mfem/model/preconditioned_system.hpp"
#include "serif/discretization/blocks/fields.hpp"
#include "serif/model/state.hpp"
#include "serif/backend/mfem/solver/preconditioner/inverse/dense.hpp"
#include "serif/dimensions/type_alias.hpp"
#include "serif/solver/nonlinear/newton.hpp"
namespace {
namespace solver = serif::solver;
namespace model = serif::model;
namespace blocks = serif::discretization::blocks;
namespace backend = model::backend::mfem;
namespace keys = serif::utils::dependencies;
using Layout = blocks::SystemLayout<keys::KeyList<blocks::gravity::potential, blocks::density::mass, ::serif::tests::equations::Enthalpy>,
keys::KeyList<blocks::density::mass, ::serif::tests::equations::Enthalpy, blocks::gravity::potential>>;
using State = model::StellarState<Layout>;
using System = backend::NormalizedSystem;
using Prepared = backend::PreconditionedSystem<System>;
/** @brief A curved gravity manifold with reordered rows and nonunit coordinates. */
struct CurvedProblem : serif::tests::solver::NativeProblem {
using System = ::System;
using Layout = ::Layout;
using Model = solver::NoModel;
Layout coordinates{.state_sizes = {1, 1, 1}, .residual_sizes = {1, 1, 1}};
int preparations{0};
/** @brief The controlled problem has no descriptive stellar physics owner. */
[[nodiscard]] std::shared_ptr<const Model> model_owner() const { return {}; }
/** @brief Expose distinct physical value and residual identity orderings. */
[[nodiscard]] const Layout& layout() const { return coordinates; }
/** @brief Form the full derivative of the nonlinear residual. */
[[nodiscard]] std::shared_ptr<const ::mfem::DenseMatrix> jacobian(const State& state) const {
const double x = state.values()[1];
auto matrix = std::make_shared<::mfem::DenseMatrix>(3);
*matrix = 0.0;
(*matrix)(0, 0) = 1.0; (*matrix)(0, 1) = 2.0 * x; (*matrix)(0, 2) = 1.0;
(*matrix)(1, 1) = -2.0 * x; (*matrix)(1, 2) = 1.0;
(*matrix)(2, 0) = 2.0; (*matrix)(2, 1) = -2.0 * std::cos(x);
return matrix;
}
/** @brief Preserve the nonlinear residual while publishing a frozen physical and normalized system. */
[[nodiscard]] std::expected<std::shared_ptr<const System>, std::string> evaluate(const State& state) {
const double x = state.values()[1];
const double potential = state.values()[0];
const double gradient = state.values()[2];
if (!std::isfinite(x) || x <= 0.0) { return std::unexpected("outside test domain"); }
const double target = 0.5 + std::sin(0.5);
const std::vector<double> residual{x * x + gradient + potential - target, gradient - x * x, 2.0 * (potential - std::sin(x))};
const auto physical = backend::make_physical_system(3, 3,
{{.row_offset = 0, .values = residual}},
{{.row_offset = 0, .column_offset = 0, .block = serif::operators::backend::mfem::linearized_block(jacobian(state))}});
const auto normalization = solver::normalization::make_diagonal_normalization({2.0, 3.0, 5.0}, {7.0, 11.0, 13.0});
const auto scales = solver::normalization::prepare_stellar_reference_scales(serif::dimensions::MassValue{1.0}, serif::dimensions::LengthValue{1.0});
return std::make_shared<const System>(physical, *normalization, *scales);
}
/** @brief Give the full Newton solve an exact physical inverse. */
[[nodiscard]] std::expected<std::shared_ptr<const Prepared>, std::string> prepare(const State& state) {
++preparations;
const auto system = evaluate(state);
if (!system) { return std::unexpected(system.error()); }
auto inverse = solver::preconditioner::backend::mfem::try_dense_inverse(*jacobian(state));
if (!inverse) { return std::unexpected(inverse.error().message); }
return std::make_shared<const Prepared>(*system, std::move(*inverse));
}
/** @brief Keep test-domain rejection distinguishable from accepted results. */
[[nodiscard]] static bool recoverable(const std::string&) { return true; }
/** @brief Forward the original failure diagnostic. */
[[nodiscard]] static std::string failure_message(const std::string& failure) { return failure; }
};
}
namespace {
/** @brief Isolate the policy gate from equation convergence and linear work. */
struct RejectedInitial : solver::StandardNewton {
/** @brief Reject the initial physical candidate even when its residual is exactly zero. */
[[nodiscard]] solver::TrialAssessment admissible(CurvedProblem&, const State&) const { return {.usable = false, .message = "Test policy rejects this state."}; }
};
}
TEST_CASE("A zero residual cannot bypass the initial physical admissibility policy", "[solver][strategies]") {
CurvedProblem problem;
const State equilibrium(problem.layout(), {std::sin(0.5), 0.5, 0.25});
const auto evaluation = problem.evaluate(equilibrium);
REQUIRE(evaluation);
CHECK(std::ranges::all_of((*evaluation)->residual(), [](double value) { return value == 0.0; }));
const auto result = solver::solve_newton(problem, equilibrium, solver::SolverOptions{.strategies = RejectedInitial{}});
CHECK(result.status() == solver::SolveStatus::initial_state_rejected);
CHECK_FALSE(result.has_state());
CHECK_FALSE(result.converged());
CHECK(problem.preparations == 0);
}
namespace {
using Border = blocks::rotation::angular_velocity;
using BorderedLayout = blocks::SystemLayout<keys::KeyList<blocks::gravity::potential, blocks::density::mass, Border, ::serif::tests::equations::Enthalpy>,
keys::KeyList<Border, blocks::density::mass, ::serif::tests::equations::Enthalpy, blocks::gravity::potential>>;
using BorderedState = model::StellarState<BorderedLayout>;
/** @brief An independently ordered generated border must remain in the retained Newton equation. */
struct BorderedProblem : serif::tests::solver::NativeProblem {
using System = ::System;
using Layout = BorderedLayout;
using Model = solver::NoModel;
Layout coordinates{.state_sizes = {1, 1, 1, 1}, .residual_sizes = {1, 1, 1, 1}};
CurvedProblem fields;
/** @brief Expose the complete layout including its generated scalar coordinate. */
const Layout& layout() const { return coordinates; }
/** @brief Algebraic regression models have no descriptive physical owner. */
std::shared_ptr<const Model> model_owner() const { return {}; }
/** @brief Remove only the generated scalar when evaluating the independent three-field equations. */
State field_state(const BorderedState& state) const { return State(fields.layout(), {state.values()[0], state.values()[1], state.values()[3]}); }
/** @brief Add the independent scalar constraint while retaining different row and column orderings. */
std::shared_ptr<const ::mfem::DenseMatrix> jacobian(const BorderedState& state) const {
const auto physical = fields.jacobian(field_state(state));
auto result = std::make_shared<::mfem::DenseMatrix>(4);
*result = 0.0;
(*result)(0, 2) = 1.0;
constexpr int columns[]{0, 1, 3};
for (int row = 0; row < 3; ++row) {
for (int column = 0; column < 3; ++column) { (*result)(row + 1, columns[column]) = (*physical)(row, column); }
}
return result;
}
/** @brief Prepare immutable physical and normalized rows including the scalar target. */
std::expected<std::shared_ptr<const System>, std::string> evaluate(const BorderedState& state) {
auto evaluation = fields.evaluate(field_state(state));
if (!evaluation) { return std::unexpected(evaluation.error()); }
const auto original = (*evaluation)->physical()->residual();
const auto physical = backend::make_physical_system(4, 4,
{{.row_offset = 0, .values = {state.values()[2] - 0.2, original[0], original[1], original[2]}}},
{{.row_offset = 0, .column_offset = 0, .block = serif::operators::backend::mfem::linearized_block(jacobian(state))}});
const auto normalization = solver::normalization::make_diagonal_normalization({2.0, 3.0, 17.0, 5.0}, {19.0, 7.0, 11.0, 13.0});
return std::make_shared<const System>(physical, *normalization, (*evaluation)->reference_scales());
}
/** @brief Apply an exact full inverse. */
std::expected<std::shared_ptr<const Prepared>, std::string> prepare(const BorderedState& state) {
auto evaluation = evaluate(state);
if (!evaluation) { return std::unexpected(evaluation.error()); }
auto inverse = solver::preconditioner::backend::mfem::try_dense_inverse(*jacobian(state));
if (!inverse) { return std::unexpected(inverse.error().message); }
return std::make_shared<const Prepared>(*evaluation, std::move(*inverse));
}
/** @brief Preserve a numerical rejection's original message. */
static std::string failure_message(const std::string& failure) { return failure; }
/** @brief Only synthetic domain rejection is recoverable in this regression. */
static bool recoverable(const std::string&) { return true; }
};
}
TEST_CASE("Newton retains independently ordered optional scalar borders", "[solver][strategies]") {
BorderedProblem problem;
const auto result = solver::solve_newton(problem, BorderedState(problem.layout(), {0.0, 0.8, -0.7, 0.0}),
solver::SolverOptions{.method = {.relative_tolerance = 0.0, .absolute_tolerance = 1.0e-9}});
INFO(result.message());
REQUIRE(result.converged());
CHECK(result.state().values()[0] == Catch::Approx(std::sin(0.5)).margin(1.0e-8));
CHECK(result.state().values()[1] == Catch::Approx(0.5).margin(1.0e-8));
CHECK(result.state().values()[2] == Catch::Approx(0.2).margin(1.0e-8));
CHECK(result.state().values()[3] == Catch::Approx(0.25).margin(1.0e-8));
}

154
tests/solver/virial.cpp Normal file
View File

@@ -0,0 +1,154 @@
#include <limits>
#include <memory>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "mfem/mesh/mesh.hpp"
#include "serif/tests/solver/virial.hpp"
namespace {
namespace diagnostics = serif::tests::solver;
namespace quantities = serif::dimensions;
namespace eos = serif::eos;
/** @brief A pressure-only EOS demonstrates that diagnostics need no enthalpy or polytropic parameters. */
struct PressureOnlyEOS {
using Relations = eos::relations::RelationCatalog<eos::relations::PressureFromDensity>;
enum class Mode { regular, negative, nonfinite, domain_failure, finite_surface };
Mode mode{Mode::regular};
/** @brief Supply an exactly integrable pressure or a deliberate validation failure. */
[[nodiscard]] quantities::PressureValue evaluate(eos::relations::PressureFromDensity, const quantities::DensityValue density) const {
if (mode == Mode::finite_surface) { return quantities::PressureValue{1.0 + density.value() * density.value()}; }
if (density.value() == 0.0) { return quantities::PressureValue{0.0}; }
if (mode == Mode::domain_failure) { throw eos::EOSEvaluationError{"Deliberate pressure domain rejection."}; }
if (mode == Mode::negative) { return quantities::PressureValue{-1.0}; }
if (mode == Mode::nonfinite) { return quantities::PressureValue{std::numeric_limits<double>::quiet_NaN()}; }
return quantities::PressureValue{density.value() * density.value()};
}
};
/** @brief Put the complete unit-cube boundary in the fixture's stellar-surface attribute. */
[[nodiscard]] mfem::Mesh material_cube() {
auto mesh = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON);
for (int boundary = 0; boundary < mesh.GetNBE(); ++boundary) { mesh.GetBdrElement(boundary)->SetAttribute(1); }
mesh.SetAttributes();
return mesh;
}
/** @brief Use exact tensor-polynomial density on a translated physical cube with a genuine vacuum trace. */
struct VirialFixture {
mfem::Mesh mesh{material_cube()};
mfem::H1_FECollection collection{diagnostics::Density::FunctionalSpace::order, 3};
mfem::H1_FECollection potential_collection{diagnostics::Potential::FunctionalSpace::order, 3};
mfem::FiniteElementSpace scalar{&mesh, &collection};
mfem::FiniteElementSpace potential_space{&mesh, &potential_collection};
mfem::FiniteElementSpace vector{&mesh, &collection, 3, mfem::Ordering::byVDIM};
serif::discretization::backend::mfem::TermBinding<diagnostics::Density, diagnostics::Schema> binding{scalar};
mfem::GridFunction density{&scalar}, potential{&potential_space}, compactification{&scalar}, displacement{&vector};
serif::mapping::DomainMapper mapper{{}, std::make_unique<serif::mapping::compactification::KelvinCompactification>(
serif::utils::user_options::KelvinCompactificationOptions{})};
serif::mapping::GridFunctionMappingEvaluator geometry{mapper, displacement, compactification};
/** @brief Project rho=216 product[x_i(1-x_i)] and translate the physical domain by (2,-1,1/2). */
VirialFixture() {
mfem::FunctionCoefficient profile([](const mfem::Vector& point) {
double value = 216.0;
for (int axis = 0; axis < 3; ++axis) { value *= point[axis] * (1.0 - point[axis]); }
return value;
});
density.ProjectCoefficient(profile);
const auto boundary = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::StellarSurfaceBoundary>(binding);
for (const int dof : boundary.reduced_dofs()) { density[dof] = 0.0; }
potential = -2.0;
compactification = 0.0;
mfem::VectorFunctionCoefficient shift(3, [](const mfem::Vector&, mfem::Vector& value) {
value.SetSize(3);
value[0] = 2.0; value[1] = -1.0; value[2] = 0.5;
});
displacement.ProjectCoefficient(shift);
}
};
}
TEST_CASE("Vacuum virial integrates tilted physical rotation about a displaced axis", "[solver][virial][unit]") {
VirialFixture fixture;
const serif::equations::RigidRotation rotation{{0.2, 0.4, -0.4}, {-1.0, 0.25, 0.75}};
const auto result = diagnostics::measure_vacuum_virial(fixture.binding, fixture.density, fixture.potential,
fixture.geometry, PressureOnlyEOS{}, rotation);
REQUIRE(result);
REQUIRE(result->boundary_dofs > 0);
// Each normalized coordinate has mean 1/2 and variance 1/20. With the physical translation,
// mean minus axis origin is (3.5,-.75,.25); T=1/2[|omega|^2(2/20+|d|^2)-(omega.d)^2]=2.2905.
for (const auto& sample : result->samples) {
CHECK_THAT(sample.mass, Catch::Matchers::WithinAbs(1.0, 2.0e-13));
CHECK_THAT(sample.volume, Catch::Matchers::WithinAbs(1.0, 2.0e-13));
CHECK_THAT(sample.kinetic_energy, Catch::Matchers::WithinAbs(2.2905, 2.0e-12));
CHECK_THAT(sample.pressure_integral, Catch::Matchers::WithinAbs(1.728, 2.0e-12));
CHECK_THAT(sample.potential_energy, Catch::Matchers::WithinAbs(-1.0, 2.0e-13));
CHECK_THAT(sample.gravity_work, Catch::Matchers::WithinAbs(0.0, 2.0e-12));
CHECK_THAT(sample.energy_virial(), Catch::Matchers::WithinAbs(8.765, 2.0e-11));
CHECK_THAT(sample.force_virial(), Catch::Matchers::WithinAbs(9.765, 2.0e-11));
}
CHECK(result->quadrature_allowance < 1.0e-11);
CHECK_FALSE(result->passes()); // This analytically integrable candidate is deliberately not an equilibrium.
}
TEST_CASE("Vacuum virial rejects unsupported thermodynamics and an uncertified surface", "[solver][virial][unit]") {
VirialFixture fixture;
PressureOnlyEOS equation_of_state;
SECTION("Negative pressure") { equation_of_state.mode = PressureOnlyEOS::Mode::negative; }
SECTION("Nonfinite pressure") { equation_of_state.mode = PressureOnlyEOS::Mode::nonfinite; }
SECTION("EOS domain rejection") { equation_of_state.mode = PressureOnlyEOS::Mode::domain_failure; }
SECTION("Finite pressure at zero density") { equation_of_state.mode = PressureOnlyEOS::Mode::finite_surface; }
SECTION("Even a tiny nonzero density trace is not silently clipped") {
const auto boundary = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::StellarSurfaceBoundary>(fixture.binding);
fixture.density[boundary.reduced_dofs()[0]] = 1.0e-17;
}
SECTION("Negative represented interior density") { fixture.density *= -1.0; }
SECTION("Zero binding energy has no usable normalization") { fixture.potential = 0.0; }
SECTION("Missing stellar boundary cannot certify a vacuum trace") {
for (int face = 0; face < fixture.mesh.GetNBE(); ++face) { fixture.mesh.GetBdrElement(face)->SetAttribute(2); }
fixture.mesh.SetAttributes();
}
const auto result = diagnostics::measure_vacuum_virial(fixture.binding, fixture.density, fixture.potential,
fixture.geometry, equation_of_state, serif::equations::RigidRotation{{0.0, 0.0, 0.0}});
REQUIRE_FALSE(result);
CHECK_FALSE(result.error().empty());
}
TEST_CASE("Vacuum virial certifies the entire infinity potential trace", "[solver][virial][unit]") {
VirialFixture fixture;
serif::discretization::backend::mfem::TermBinding<diagnostics::Potential, diagnostics::Schema> potential_binding{fixture.potential_space};
fixture.potential = 0.0;
CHECK_FALSE(diagnostics::has_infinity_zero_gauge(potential_binding, fixture.potential));
fixture.mesh.GetBdrElement(0)->SetAttribute(2);
fixture.mesh.SetAttributes();
CHECK(diagnostics::has_infinity_zero_gauge(potential_binding, fixture.potential));
fixture.potential = -2.0;
CHECK_FALSE(diagnostics::has_infinity_zero_gauge(potential_binding, fixture.potential));
fixture.potential = 0.0;
const auto boundary = serif::discretization::backend::mfem::select_boundary_dofs<
serif::discretization::domain::InfinitySurfaceBoundary>(potential_binding);
fixture.potential[boundary.reduced_dofs()[boundary.size() / 2]] = 1.0e-17;
CHECK_FALSE(diagnostics::has_infinity_zero_gauge(potential_binding, fixture.potential));
}
TEST_CASE("Vacuum virial acceptance reserves room for integration uncertainty", "[solver][virial][unit]") {
diagnostics::VacuumVirialAssessment assessment{};
auto& final = assessment.samples.back();
final.potential_energy = -1.0;
final.pressure_integral = 1.0 / 3.0;
assessment.quadrature_allowance = 1.0e-8;
CHECK(assessment.passes());
final.pressure_integral = (1.0 + 9.95e-7) / 3.0;
CHECK_FALSE(assessment.passes()); // A point estimate below 1e-6 does not leave enough integration margin.
final.pressure_integral = 1.0 / 3.0;
assessment.quadrature_allowance = 1.01e-7;
CHECK_FALSE(assessment.passes()); // The separately bounded quadrature requirement also applies at exact virial balance.
assessment.quadrature_allowance = 0.0;
final.potential_energy = 0.0;
CHECK_FALSE(assessment.passes());
}