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.
562 lines
30 KiB
C++
562 lines
30 KiB
C++
#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);
|
|
}
|
|
}
|