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

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