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.
198 lines
9.7 KiB
C++
198 lines
9.7 KiB
C++
#include <cmath>
|
|
#include <expected>
|
|
#include <functional>
|
|
#include <limits>
|
|
#include <memory>
|
|
#include <span>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include <catch2/catch_approx.hpp>
|
|
#include <catch2/catch_test_macros.hpp>
|
|
#include "serif/solver/nonlinear/newton.hpp"
|
|
|
|
namespace {
|
|
namespace solver = serif::solver;
|
|
|
|
/** @brief Keep accepted and trial coordinates independently owned. */
|
|
struct State {
|
|
std::vector<double> coefficients;
|
|
/** @brief Edit a candidate's physical coordinate. */
|
|
std::span<double> values() { return coefficients; }
|
|
/** @brief Inspect an immutable accepted state. */
|
|
std::span<const double> 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<const double> normalized, std::span<double> physical) const {
|
|
physical[0] = normalized[0] / 2.0;
|
|
}
|
|
};
|
|
|
|
/** @brief Freeze the residual and derivative belonging to one physical candidate. */
|
|
struct System {
|
|
std::vector<double> residual_values;
|
|
double derivative;
|
|
/** @brief Supply the complete normalized residual to the generic merit function. */
|
|
const std::vector<double>& 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<double>;
|
|
using System = ::System;
|
|
using Model = solver::NoModel;
|
|
std::function<std::pair<double, double>(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<const Model> model_owner() const { return {}; }
|
|
/** @brief Negative trial states are an ordinary recoverable numerical rejection. */
|
|
std::expected<std::shared_ptr<const System>, 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<const System>(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<std::vector<double>> guesses{std::make_shared<std::vector<double>>()};
|
|
std::function<void(int, std::vector<double>&)> 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<double>& rhs,
|
|
std::vector<double>& correction, const auto&) const {
|
|
guesses->push_back(correction[0]);
|
|
correction[0] = rhs[0] / prepared->derivative;
|
|
if (scripted_correction) {
|
|
scripted_correction(static_cast<int>(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<const System> 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<double>& 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<double>& correction) {
|
|
correction[0] = std::numeric_limits<double>::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<double>& 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<const System>&) { ++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<serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT>(
|
|
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);
|
|
}
|