Files
MeanField_SERiF/tests/deformation/composition.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

557 lines
29 KiB
C++

// The nonlinear surface and bilinear extension retain MeanField's independent chain-rule fixture.
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <memory>
#include <numeric>
#include <span>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "serif/geometry/deformation/composition.hpp"
#include "serif/geometry/deformation/linearization.hpp"
namespace
{
namespace deformation = serif::geometry::deformation;
namespace spaces = deformation::spaces;
using Catch::Matchers::WithinAbs;
/** @brief Reject fixture misuse as an error value so failure recovery exercises the production composition. */
[[nodiscard]] deformation::DeformationStatus validate_output(const std::span<double> output, const std::size_t expected_size)
{
if (output.size() != expected_size)
{
return std::unexpected(deformation::DeformationFailure{.reason = deformation::DeformationFailureReason::invalid_size, .message = "The analytic deformation fixture received an incompatible output."});
}
return {};
}
/** @brief Provide independently known quadratic maps with two inputs and a variable number of outputs. */
template <typename Input, typename Output, std::size_t OutputSize>
struct QuadraticMap
{
using InputSpace = Input;
using OutputSpace = Output;
static constexpr bool is_linear = false;
struct Workspace
{
std::size_t actions{0};
};
// Each row defines a*x + b*y + c*x*x + d*x*y + e*y*y.
std::array<std::array<double, 5>, OutputSize> coefficients;
/** @brief Keep the analytic parameter space small enough to inspect by hand. */
[[nodiscard]] std::size_t input_size() const noexcept
{
return 2;
}
/** @brief Allow the extension to have more outputs than surface coordinates. */
[[nodiscard]] std::size_t output_size() const noexcept
{
return OutputSize;
}
/** @brief Make caller-owned action state visible in recovery tests. */
[[nodiscard]] Workspace make_workspace() const
{
return {};
}
/** @brief Evaluate the independent quadratic definition, including both pure and mixed terms. */
[[nodiscard]] deformation::DeformationStatus build_displacement(const std::span<const double> state, const std::span<double> output, Workspace& workspace) const
{
if (const auto status = deformation::validate_deformation_values(state, input_size()); !status)
{
return status;
}
if (const auto status = validate_output(output, output_size()); !status)
{
return status;
}
++workspace.actions;
for (std::size_t row = 0; row < OutputSize; ++row)
{
const std::array<double, 5>& values = coefficients[row];
output[row] = values[0] * state[0] + values[1] * state[1] + values[2] * state[0] * state[0] + values[3] * state[0] * state[1] + values[4] * state[1] * state[1];
}
return deformation::validate_deformation_values(output, output_size());
}
/** @brief Apply the analytic gradient of each quadratic row. */
[[nodiscard]] deformation::DeformationStatus apply_jacobian(const std::span<const double> state, const std::span<const double> direction, const std::span<double> output, Workspace& workspace) const
{
if (const auto status = deformation::validate_deformation_values(state, input_size()); !status)
{
return status;
}
if (const auto status = deformation::validate_deformation_values(direction, input_size()); !status)
{
return status;
}
if (const auto status = validate_output(output, output_size()); !status)
{
return status;
}
++workspace.actions;
for (std::size_t row = 0; row < OutputSize; ++row)
{
const std::array<double, 2> row_gradient = gradient(row, state);
output[row] = row_gradient[0] * direction[0] + row_gradient[1] * direction[1];
}
return deformation::validate_deformation_values(output, output_size());
}
/** @brief Reverse the exact same mathematical derivative without constructing a matrix. */
[[nodiscard]] deformation::DeformationStatus apply_jacobian_transpose(const std::span<const double> state, const std::span<const double> dual, const std::span<double> output, Workspace& workspace) const
{
if (const auto status = deformation::validate_deformation_values(state, input_size()); !status)
{
return status;
}
if (const auto status = deformation::validate_deformation_values(dual, output_size()); !status)
{
return status;
}
if (const auto status = validate_output(output, input_size()); !status)
{
return status;
}
++workspace.actions;
std::ranges::fill(output, 0.0);
for (std::size_t row = 0; row < OutputSize; ++row)
{
const std::array<double, 2> row_gradient = gradient(row, state);
output[0] += row_gradient[0] * dual[row];
output[1] += row_gradient[1] * dual[row];
}
return deformation::validate_deformation_values(output, input_size());
}
/** @brief Contract the constant quadratic Hessians with direction and fixed output dual. */
[[nodiscard]] deformation::DeformationStatus apply_pullback_derivative(const std::span<const double> state, const std::span<const double> direction, const std::span<const double> dual, const std::span<double> output, Workspace& workspace) const
{
if (const auto status = deformation::validate_deformation_values(state, input_size()); !status)
{
return status;
}
if (const auto status = deformation::validate_deformation_values(direction, input_size()); !status)
{
return status;
}
if (const auto status = deformation::validate_deformation_values(dual, output_size()); !status)
{
return status;
}
if (const auto status = validate_output(output, input_size()); !status)
{
return status;
}
++workspace.actions;
std::ranges::fill(output, 0.0);
for (std::size_t row = 0; row < OutputSize; ++row)
{
const std::array<double, 5>& values = coefficients[row];
output[0] += (2.0 * values[2] * direction[0] + values[3] * direction[1]) * dual[row];
output[1] += (values[3] * direction[0] + 2.0 * values[4] * direction[1]) * dual[row];
}
return deformation::validate_deformation_values(output, input_size());
}
/** @brief Keep the fixture's analytic row derivative in one easily checked expression. */
[[nodiscard]] std::array<double, 2> gradient(const std::size_t row, const std::span<const double> state) const
{
const std::array<double, 5>& values = coefficients[row];
return {values[0] + 2.0 * values[2] * state[0] + values[3] * state[1], values[1] + values[3] * state[0] + 2.0 * values[4] * state[1]};
}
};
using NonlinearSurface = QuadraticMap<spaces::ShapeParameters, spaces::SurfaceDisplacement, 2>;
using NonlinearExtension = QuadraticMap<spaces::SurfaceDisplacement, spaces::VolumeDisplacement, 3>;
/** @brief Retain MeanField's surface definition (x*x + y, x*y). */
[[nodiscard]] std::shared_ptr<const NonlinearSurface> nonlinear_surface()
{
return std::make_shared<const NonlinearSurface>(NonlinearSurface{.coefficients = {{{0.0, 1.0, 1.0, 0.0, 0.0}, {0.0, 0.0, 0.0, 1.0, 0.0}}}});
}
/** @brief Retain the bilinear extension with distinct row coefficients from MeanField's nonlinear fixture. */
[[nodiscard]] std::shared_ptr<const NonlinearExtension> nonlinear_extension()
{
return std::make_shared<const NonlinearExtension>(NonlinearExtension{.coefficients = {{{0.01, -0.02, 0.0, 0.0005, 0.0}, {0.011, -0.018, 0.0, 0.001, 0.0}, {0.012, -0.016, 0.0, 0.0015, 0.0}}}});
}
/** @brief Supply the minimal linear-map contract with an independently known nonsymmetric matrix. */
template <typename Input, typename Output>
struct LinearMap
{
using InputSpace = Input;
using OutputSpace = Output;
using Workspace = deformation::EmptyDeformationWorkspace;
static constexpr bool is_linear = true;
std::size_t input_count{2};
/** @brief Permit an intentionally incompatible count for factory validation. */
[[nodiscard]] std::size_t input_size() const noexcept
{
return input_count;
}
/** @brief Fix two rows for the explicit matrix used in the test. */
[[nodiscard]] std::size_t output_size() const noexcept
{
return 2;
}
/** @brief Apply [[2, 1], [-1, 3]], with no derivative-specific implementation. */
[[nodiscard]] deformation::DeformationStatus apply(const std::span<const double> input, const std::span<double> output, Workspace = {}) const
{
if (const auto status = deformation::validate_deformation_values(input, input_size()); !status)
{
return status;
}
if (const auto status = validate_output(output, output_size()); !status)
{
return status;
}
output[0] = 2.0 * input[0] + input[1];
output[1] = -input[0] + 3.0 * input[1];
return deformation::validate_deformation_values(output, output_size());
}
/** @brief Use an analytic transpose to test the generic linear derivative branch. */
[[nodiscard]] deformation::DeformationStatus apply_transpose(const std::span<const double> input, const std::span<double> output, Workspace = {}) const
{
if (const auto status = deformation::validate_deformation_values(input, output_size()); !status)
{
return status;
}
if (const auto status = validate_output(output, input_size()); !status)
{
return status;
}
output[0] = 2.0 * input[0] - input[1];
output[1] = input[0] + 3.0 * input[1];
return deformation::validate_deformation_values(output, input_size());
}
};
using LinearSurface = LinearMap<spaces::ShapeParameters, spaces::SurfaceDisplacement>;
using LinearExtension = LinearMap<spaces::SurfaceDisplacement, spaces::VolumeDisplacement>;
/** @brief Build centered differences without borrowing any production derivative calculation. */
[[nodiscard]] std::array<double, 2> perturb(const std::array<double, 2>& state, const std::array<double, 2>& direction, const double step)
{
return {state[0] + step * direction[0], state[1] + step * direction[1]};
}
/** @brief Compare work without depending on a backend vector representation. */
[[nodiscard]] double inner_product(const std::span<const double> first, const std::span<const double> second)
{
REQUIRE(first.size() == second.size());
return std::inner_product(first.begin(), first.end(), second.begin(), 0.0);
}
/** @brief Give a nonlinear physical residual whose derivative contains cross-component couplings. */
[[nodiscard]] std::array<double, 3> volume_force(const std::span<const double> displacement)
{
return {1.0 + displacement[0] * displacement[0] + 0.5 * displacement[1], -0.2 + displacement[1] * displacement[1] - 0.3 * displacement[2], 0.7 + displacement[2] * displacement[2] + 0.1 * displacement[0]};
}
/** @brief Verify the surviving curvature term when either factor is linear. */
template <deformation::DeformationMap Map>
void check_mixed_pullback(const Map& map)
{
auto workspace = deformation::make_deformation_workspace(map);
const std::array state{0.37, -0.21};
const std::array direction{-0.42, 0.63};
constexpr double step = 2.0e-6;
const std::array plus = perturb(state, direction, step);
const std::array minus = perturb(state, direction, -step);
const std::vector<double> dual(map.output_size(), 0.7);
std::array<double, 2> derivative{};
std::array<double, 2> plus_transpose{};
std::array<double, 2> minus_transpose{};
REQUIRE(deformation::apply_pullback_derivative(map, state, direction, dual, derivative, workspace));
REQUIRE(deformation::apply_jacobian_transpose(map, plus, dual, plus_transpose, workspace));
REQUIRE(deformation::apply_jacobian_transpose(map, minus, dual, minus_transpose, workspace));
for (std::size_t parameter = 0; parameter < derivative.size(); ++parameter)
{
CHECK_THAT(derivative[parameter], WithinAbs((plus_transpose[parameter] - minus_transpose[parameter]) / (2.0 * step), 2.0e-10));
}
CHECK(std::abs(derivative[0]) + std::abs(derivative[1]) > 1.0e-5);
}
} // namespace
TEST_CASE("Nonlinear domain deformation preserves the complete chain rule and virtual work", "[unit][deformation][composition]")
{
const auto composed = deformation::compose_deformation(nonlinear_surface(), nonlinear_extension());
REQUIRE(composed);
auto workspace = deformation::make_deformation_workspace(*composed);
const std::array state{0.37, -0.21};
const std::array direction{-0.42, 0.63};
const std::array dual{0.9, -0.35, 0.24};
constexpr double step = 2.0e-6;
const std::array plus = perturb(state, direction, step);
const std::array minus = perturb(state, direction, -step);
const auto plus_displacement = deformation::evaluate_displacement(*composed, plus);
const auto minus_displacement = deformation::evaluate_displacement(*composed, minus);
REQUIRE(plus_displacement);
REQUIRE(minus_displacement);
std::array<double, 3> tangent{};
std::array<double, 2> transpose{};
std::array<double, 2> pullback{};
std::array<double, 2> plus_transpose{};
std::array<double, 2> minus_transpose{};
REQUIRE(deformation::apply_jacobian(*composed, state, direction, tangent, workspace));
REQUIRE(deformation::apply_jacobian_transpose(*composed, state, dual, transpose, workspace));
REQUIRE(deformation::apply_pullback_derivative(*composed, state, direction, dual, pullback, workspace));
REQUIRE(deformation::apply_jacobian_transpose(*composed, plus, dual, plus_transpose, workspace));
REQUIRE(deformation::apply_jacobian_transpose(*composed, minus, dual, minus_transpose, workspace));
for (std::size_t row = 0; row < tangent.size(); ++row)
{
CHECK_THAT(tangent[row], WithinAbs(((*plus_displacement)[row] - (*minus_displacement)[row]) / (2.0 * step), 2.0e-12));
}
CHECK_THAT(inner_product(tangent, dual), WithinAbs(inner_product(direction, transpose), 2.0e-15));
for (std::size_t parameter = 0; parameter < pullback.size(); ++parameter)
{
CHECK_THAT(pullback[parameter], WithinAbs((plus_transpose[parameter] - minus_transpose[parameter]) / (2.0 * step), 3.0e-12));
}
CHECK(std::abs(pullback[0]) > 1.0e-3);
CHECK(std::abs(pullback[1]) > 1.0e-3);
}
TEST_CASE("Nonlinear shape force derivative includes geometric and physical residual contributions", "[unit][deformation][composition]")
{
const auto composed = deformation::compose_deformation(nonlinear_surface(), nonlinear_extension());
REQUIRE(composed);
auto workspace = deformation::make_deformation_workspace(*composed);
const std::array state{0.37, -0.21};
const std::array direction{-0.42, 0.63};
const auto displacement = deformation::evaluate_displacement(*composed, state);
REQUIRE(displacement);
const std::array force = volume_force(*displacement);
std::array<double, 3> displacement_direction{};
REQUIRE(deformation::apply_jacobian(*composed, state, direction, displacement_direction, workspace));
const std::array force_direction{
2.0 * (*displacement)[0] * displacement_direction[0] + 0.5 * displacement_direction[1],
2.0 * (*displacement)[1] * displacement_direction[1] - 0.3 * displacement_direction[2],
2.0 * (*displacement)[2] * displacement_direction[2] + 0.1 * displacement_direction[0]};
std::array<double, 2> geometric_contribution{};
std::array<double, 2> physical_contribution{};
REQUIRE(deformation::apply_pullback_derivative(*composed, state, direction, force, geometric_contribution, workspace));
REQUIRE(deformation::apply_jacobian_transpose(*composed, state, force_direction, physical_contribution, workspace));
constexpr double step = 2.0e-6;
const std::array plus = perturb(state, direction, step);
const std::array minus = perturb(state, direction, -step);
const auto plus_displacement = deformation::evaluate_displacement(*composed, plus);
const auto minus_displacement = deformation::evaluate_displacement(*composed, minus);
REQUIRE(plus_displacement);
REQUIRE(minus_displacement);
std::array<double, 2> plus_force{};
std::array<double, 2> minus_force{};
REQUIRE(deformation::apply_jacobian_transpose(*composed, plus, volume_force(*plus_displacement), plus_force, workspace));
REQUIRE(deformation::apply_jacobian_transpose(*composed, minus, volume_force(*minus_displacement), minus_force, workspace));
for (std::size_t parameter = 0; parameter < plus_force.size(); ++parameter)
{
const double centered_difference = (plus_force[parameter] - minus_force[parameter]) / (2.0 * step);
CHECK_THAT(geometric_contribution[parameter] + physical_contribution[parameter], WithinAbs(centered_difference, 3.0e-12));
CHECK(std::abs(geometric_contribution[parameter]) > 1.0e-3);
CHECK(std::abs(physical_contribution[parameter]) > 1.0e-7);
}
}
TEST_CASE("Linear composition needs only forward and transpose actions and recovers after rejected scratch", "[unit][deformation][composition]")
{
const auto composed = deformation::compose_deformation(std::make_shared<const LinearSurface>(), std::make_shared<const LinearExtension>());
REQUIRE(composed);
auto workspace = deformation::make_deformation_workspace(*composed);
const std::array state{0.37, -0.21};
const std::array direction{-0.42, 0.63};
const std::array dual{0.9, -0.35};
std::array<double, 2> output{};
REQUIRE(deformation::build_displacement(*composed, state, output, workspace));
CHECK_THAT(output[0], WithinAbs(3.0 * state[0] + 5.0 * state[1], 1.0e-15));
CHECK_THAT(output[1], WithinAbs(-5.0 * state[0] + 8.0 * state[1], 1.0e-15));
REQUIRE(deformation::apply_pullback_derivative(*composed, state, direction, dual, output, workspace));
CHECK(output == std::array<double, 2>{0.0, 0.0});
const std::array enormous{std::numeric_limits<double>::max(), std::numeric_limits<double>::max()};
CHECK_FALSE(deformation::build_displacement(*composed, enormous, output, workspace));
REQUIRE(deformation::apply_jacobian_transpose(*composed, state, dual, output, workspace));
CHECK_THAT(output[0], WithinAbs(3.0 * dual[0] - 5.0 * dual[1], 1.0e-15));
CHECK_THAT(output[1], WithinAbs(5.0 * dual[0] + 8.0 * dual[1], 1.0e-15));
}
TEST_CASE("Mixed linear and nonlinear deformation factors retain their curvature", "[unit][deformation][composition]")
{
const auto nonlinear_surface_only = deformation::compose_deformation(nonlinear_surface(), std::make_shared<const LinearExtension>());
const auto nonlinear_extension_only = deformation::compose_deformation(std::make_shared<const LinearSurface>(), nonlinear_extension());
REQUIRE(nonlinear_surface_only);
REQUIRE(nonlinear_extension_only);
check_mixed_pullback(*nonlinear_surface_only);
check_mixed_pullback(*nonlinear_extension_only);
}
TEST_CASE("Composition rejects incompatible factors and invalid actions without preventing workspace reuse", "[unit][deformation][composition]")
{
const auto surface = nonlinear_surface();
CHECK_FALSE(deformation::compose_deformation(surface, std::make_shared<const LinearExtension>(LinearExtension{.input_count = 3})));
CHECK_FALSE(deformation::compose_deformation(std::shared_ptr<const NonlinearSurface>{}, nonlinear_extension()));
CHECK_FALSE(deformation::compose_deformation(surface, std::shared_ptr<const NonlinearExtension>{}));
const auto composed = deformation::compose_deformation(surface, nonlinear_extension());
REQUIRE(composed);
auto workspace = deformation::make_deformation_workspace(*composed);
const std::array state{0.37, -0.21};
const std::array direction{-0.42, 0.63};
const std::array dual{0.9, -0.35, 0.24};
const std::array invalid{std::numeric_limits<double>::quiet_NaN(), 0.0};
std::array<double, 3> volume{};
std::array<double, 2> output{};
std::array<double, 1> wrong{};
CHECK_FALSE(deformation::build_displacement(*composed, wrong, volume, workspace));
CHECK_FALSE(deformation::apply_jacobian(*composed, state, wrong, volume, workspace));
CHECK_FALSE(deformation::apply_jacobian_transpose(*composed, state, wrong, output, workspace));
CHECK_FALSE(deformation::apply_pullback_derivative(*composed, state, direction, dual, wrong, workspace));
CHECK_FALSE(deformation::apply_pullback_derivative(*composed, invalid, direction, dual, output, workspace));
REQUIRE(deformation::build_displacement(*composed, state, volume, workspace));
REQUIRE(deformation::apply_pullback_derivative(*composed, state, direction, dual, output, workspace));
CHECK(workspace.surface_workspace.actions > 0);
CHECK(workspace.extension_workspace.actions > 0);
}
TEST_CASE("A deformation linearization retains its accepted shape independently of subsequent trials", "[unit][deformation][composition]")
{
const auto surface = nonlinear_surface();
std::vector<double> trial{0.37, -0.21};
const deformation::DeformationLinearization<NonlinearSurface> accepted{.map = surface, .parameters = trial};
auto workspace = deformation::make_deformation_workspace(*surface);
const std::array direction{-0.42, 0.63};
const std::array dual{0.9, -0.35};
std::array<double, 2> tangent{};
std::array<double, 2> transpose{};
std::array<double, 2> pullback{};
trial = {8.0, 9.0};
REQUIRE(deformation::apply_jacobian(accepted, direction, tangent, workspace));
REQUIRE(deformation::apply_jacobian_transpose(accepted, dual, transpose, workspace));
REQUIRE(deformation::apply_pullback_derivative(accepted, direction, dual, pullback, workspace));
CHECK_THAT(tangent[0], WithinAbs(2.0 * 0.37 * direction[0] + direction[1], 1.0e-15));
CHECK_THAT(tangent[1], WithinAbs(-0.21 * direction[0] + 0.37 * direction[1], 1.0e-15));
CHECK_THAT(transpose[0], WithinAbs(2.0 * 0.37 * dual[0] - 0.21 * dual[1], 1.0e-15));
CHECK_THAT(transpose[1], WithinAbs(dual[0] + 0.37 * dual[1], 1.0e-15));
CHECK_THAT(pullback[0], WithinAbs(2.0 * direction[0] * dual[0] + direction[1] * dual[1], 1.0e-15));
CHECK_THAT(pullback[1], WithinAbs(direction[0] * dual[1], 1.0e-15));
}
TEST_CASE("Nonlinear composed actions reject overlapping state direction and dual before changing any data", "[unit][deformation][composition]")
{
const auto composed = deformation::compose_deformation(nonlinear_surface(), nonlinear_extension());
REQUIRE(composed);
auto workspace = deformation::make_deformation_workspace(*composed);
const std::array state{0.37, -0.21};
const std::array direction{-0.42, 0.63};
const std::array dual{0.9, -0.35, 0.24};
// Exercise direct map methods as well as the generic interface, with exact and partial overlap.
for (const bool direct : {false, true})
{
for (int action = 0; action < 4; ++action)
{
for (int aliased_input = 0; aliased_input < 3; ++aliased_input)
{
if ((aliased_input == 1 && action != 1 && action != 3) || (aliased_input == 2 && action < 2))
{
continue;
}
for (const int offset : {0, 1})
{
CAPTURE(direct, action, aliased_input, offset);
std::array<double, 6> storage{0.37, -0.21, 0.24, 5.0, 7.0, 9.0};
const std::array original = storage;
std::span<const double> input_state = state;
std::span<const double> input_direction = direction;
std::span<const double> input_dual = dual;
if (aliased_input == 0)
{
input_state = std::span<const double>(storage).first(2);
}
else if (aliased_input == 1)
{
input_direction = std::span<const double>(storage).first(2);
}
else
{
input_dual = std::span<const double>(storage).first(3);
}
const std::size_t output_size = action < 2 ? 3 : 2;
const std::span<double> output = std::span<double>(storage).subspan(offset, output_size);
deformation::DeformationStatus status;
if (action == 0)
{
status = direct ? composed->build_displacement(input_state, output, workspace) : deformation::build_displacement(*composed, input_state, output, workspace);
}
else if (action == 1)
{
status = direct ? composed->apply_jacobian(input_state, input_direction, output, workspace) : deformation::apply_jacobian(*composed, input_state, input_direction, output, workspace);
}
else if (action == 2)
{
status = direct ? composed->apply_jacobian_transpose(input_state, input_dual, output, workspace) : deformation::apply_jacobian_transpose(*composed, input_state, input_dual, output, workspace);
}
else
{
status = direct ? composed->apply_pullback_derivative(input_state, input_direction, input_dual, output, workspace) : deformation::apply_pullback_derivative(*composed, input_state, input_direction, input_dual, output, workspace);
}
REQUIRE_FALSE(status);
CHECK(status.error().reason == deformation::DeformationFailureReason::invalid_configuration);
CHECK(storage == original);
CHECK(workspace.surface_workspace.actions == 0);
CHECK(workspace.extension_workspace.actions == 0);
}
}
}
}
std::array<double, 2> recovered{}, reference{};
auto independent_workspace = composed->make_workspace();
REQUIRE(deformation::apply_pullback_derivative(*composed, state, direction, dual, recovered, workspace));
REQUIRE(composed->apply_pullback_derivative(state, direction, dual, reference, independent_workspace));
CHECK(recovered == reference);
}
TEST_CASE("Generic actions protect custom nonlinear maps and linear zero derivatives from aliases", "[unit][deformation][composition]")
{
const auto nonlinear = nonlinear_surface();
auto nonlinear_workspace = deformation::make_deformation_workspace(*nonlinear);
const std::array state{0.37, -0.21};
const std::array direction{-0.42, 0.63};
std::array<double, 2> storage{0.9, -0.35};
const std::array original = storage;
CHECK_FALSE(deformation::build_displacement(*nonlinear, storage, storage, nonlinear_workspace));
CHECK_FALSE(deformation::apply_jacobian(*nonlinear, state, storage, storage, nonlinear_workspace));
CHECK_FALSE(deformation::apply_jacobian_transpose(*nonlinear, state, storage, storage, nonlinear_workspace));
CHECK_FALSE(deformation::apply_pullback_derivative(*nonlinear, state, direction, storage, storage, nonlinear_workspace));
CHECK(storage == original);
CHECK(nonlinear_workspace.actions == 0);
const LinearSurface linear;
auto linear_workspace = deformation::make_deformation_workspace(linear);
CHECK_FALSE(deformation::apply_pullback_derivative(linear, state, direction, storage, storage, linear_workspace));
CHECK(storage == original);
// Adjacent slices in one allocation are disjoint and remain a valid allocation-free calling pattern.
std::array<double, 4> adjacent{0.37, -0.21, 0.0, 0.0};
REQUIRE(deformation::build_displacement(*nonlinear, std::span<const double>(adjacent).first(2), std::span<double>(adjacent).last(2), nonlinear_workspace));
CHECK_THAT(adjacent[2], WithinAbs(state[0] * state[0] + state[1], 1.0e-15));
CHECK_THAT(adjacent[3], WithinAbs(state[0] * state[1], 1.0e-15));
}