#include "serif/tests/solver/native_problem.hpp" #include #include #include #include #include #include #include #include #include #include #include #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 coefficients; /** @brief Edit the sole physical unknown. */ std::span values() { return coefficients; } /** @brief Inspect immutable accepted physical coordinates. */ std::span values() const { return coefficients; } }; /** @brief Freeze a nonlinear residual and derivative in deliberately nontrivial coordinates. */ struct System { std::vector 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& 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 owner; mutable Inverse inverse; /** @brief Retain the system before borrowing its derivative. */ explicit Prepared(std::shared_ptr 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(double)> equation = [](const double x) { return std::pair{x * x - 2.0, 2.0 * x}; }; std::function preparation_allowed = [](double) { return true; }; int evaluations{0}; bool numerical_rejections{true}; /** @brief Algebraic tests have no descriptive stellar model. */ std::shared_ptr model_owner() const { return {}; } /** @brief Reject nonfinite or negative candidates before publication. */ std::expected, 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(residual, derivative); } /** @brief Inject inverse preparation failure without destroying accepted residual handles. */ std::expected, 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(*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::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 geometry_limit( const State&, std::span 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::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> guesses{std::make_shared>()}; std::function 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(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 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 checkpoint; int preparations = 0; int accepted = 0; problem.preparation_allowed = [&](double x) { ++preparations; serif::utils::errors::serif_ensure( 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 accepted_steps; std::shared_ptr 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) { return solver::MetricEvaluation{.residual_norm = std::numeric_limits::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 residual) { if (++calls == 2) { return solver::MetricEvaluation{.merit = std::numeric_limits::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::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(failure_phase)); Problem problem; problem.equation = [](double value) { return std::pair{value, 1.0}; }; const State original{{1.0}}; std::shared_ptr checkpoint; bool reached_failure = false; auto observer = [&, token = std::make_unique(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( "injected observer local cause at phase {}", static_cast(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::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 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(!proactive)); CHECK(result.diagnostics().rejected_trials == static_cast(!proactive)); CHECK(result.diagnostics().geometry_limited_iterations == static_cast(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); } }