// The nonlinear surface and bilinear extension retain MeanField's independent chain-rule fixture. #include #include #include #include #include #include #include #include #include #include #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 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 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, 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 state, const std::span 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& 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 state, const std::span direction, const std::span 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 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 state, const std::span dual, const std::span 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 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 state, const std::span direction, const std::span dual, const std::span 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& 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 gradient(const std::size_t row, const std::span state) const { const std::array& 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; using NonlinearExtension = QuadraticMap; /** @brief Retain MeanField's surface definition (x*x + y, x*y). */ [[nodiscard]] std::shared_ptr nonlinear_surface() { return std::make_shared(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 nonlinear_extension() { return std::make_shared(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 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 input, const std::span 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 input, const std::span 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; using LinearExtension = LinearMap; /** @brief Build centered differences without borrowing any production derivative calculation. */ [[nodiscard]] std::array perturb(const std::array& state, const std::array& 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 first, const std::span 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 volume_force(const std::span 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 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 dual(map.output_size(), 0.7); std::array derivative{}; std::array plus_transpose{}; std::array 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 tangent{}; std::array transpose{}; std::array pullback{}; std::array plus_transpose{}; std::array 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 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 geometric_contribution{}; std::array 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 plus_force{}; std::array 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(), std::make_shared()); 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 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{0.0, 0.0}); const std::array enormous{std::numeric_limits::max(), std::numeric_limits::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 auto nonlinear_extension_only = deformation::compose_deformation(std::make_shared(), 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(LinearExtension{.input_count = 3}))); CHECK_FALSE(deformation::compose_deformation(std::shared_ptr{}, nonlinear_extension())); CHECK_FALSE(deformation::compose_deformation(surface, std::shared_ptr{})); 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::quiet_NaN(), 0.0}; std::array volume{}; std::array output{}; std::array 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 trial{0.37, -0.21}; const deformation::DeformationLinearization 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 tangent{}; std::array transpose{}; std::array 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 storage{0.37, -0.21, 0.24, 5.0, 7.0, 9.0}; const std::array original = storage; std::span input_state = state; std::span input_direction = direction; std::span input_dual = dual; if (aliased_input == 0) { input_state = std::span(storage).first(2); } else if (aliased_input == 1) { input_direction = std::span(storage).first(2); } else { input_dual = std::span(storage).first(3); } const std::size_t output_size = action < 2 ? 3 : 2; const std::span output = std::span(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 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 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 adjacent{0.37, -0.21, 0.0, 0.0}; REQUIRE(deformation::build_displacement(*nonlinear, std::span(adjacent).first(2), std::span(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)); }