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

View File

@@ -0,0 +1,197 @@
#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);
}