Files
MeanField_SERiF/tests/solver/backend_independent.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

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