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

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);
}
}