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