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:
103
tests/utils/dependencies/context.cpp
Normal file
103
tests/utils/dependencies/context.cpp
Normal file
@@ -0,0 +1,103 @@
|
||||
#include "serif/utils/dependencies/dependencies.hpp"
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
namespace dependencies = serif::utils::dependencies;
|
||||
|
||||
struct Owned {};
|
||||
struct Borrowed {};
|
||||
struct Result {};
|
||||
}
|
||||
|
||||
TEST_CASE("Dependency contexts distinguish ownership and borrowed roles", "[unit][utils][dependencies]") {
|
||||
int value = 3;
|
||||
auto context = dependencies::make_dependency_context(dependencies::own<Owned>(value), dependencies::borrow<Borrowed>(value));
|
||||
auto selection = context.select(dependencies::KeyList<Borrowed>{});
|
||||
|
||||
value = 8;
|
||||
CHECK(context.get<Owned>() == 3);
|
||||
CHECK(context.get<Borrowed>() == 8);
|
||||
CHECK(std::addressof(selection.get<Borrowed>()) == std::addressof(value));
|
||||
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<Borrowed>>([](int input) { return 2 * input; }));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), selection);
|
||||
REQUIRE(cache.try_prepare(selection));
|
||||
CHECK(cache.get<Result>() == 16);
|
||||
|
||||
value = 9;
|
||||
CHECK(cache.get<Result>() == 16); // An accepted result remains frozen until explicitly refreshed.
|
||||
REQUIRE(cache.try_prepare(selection));
|
||||
CHECK(cache.get<Result>() == 18);
|
||||
}
|
||||
|
||||
TEST_CASE("Replacing a borrowed source invalidates its identity even at equal value", "[unit][utils][dependencies]") {
|
||||
int original = 4;
|
||||
int replacement = 4;
|
||||
int calls = 0;
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Borrowed>(original));
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<Borrowed>>([&calls](int input) { ++calls; return input; }));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
context = dependencies::make_dependency_context(dependencies::borrow<Borrowed>(replacement));
|
||||
auto report = cache.try_prepare(context);
|
||||
REQUIRE(report);
|
||||
CHECK(report->source_changed<Borrowed>());
|
||||
CHECK(calls == 2);
|
||||
|
||||
original = 7;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(calls == 2);
|
||||
CHECK(cache.get<Result>() == 4);
|
||||
}
|
||||
|
||||
TEST_CASE("ComputationCache dependency results can move without leaving readable moved-from state", "[unit][utils][dependencies]") {
|
||||
const auto context = dependencies::make_dependency_context();
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<>>([coefficient = std::make_unique<int>(7)] { return std::make_unique<int>(*coefficient); }));
|
||||
auto original = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(original.try_prepare(context));
|
||||
|
||||
auto moved = std::move(original);
|
||||
CHECK_FALSE(original.ready());
|
||||
CHECK_FALSE(original.contains<Result>());
|
||||
CHECK_THROWS_AS(original.get<Result>(), std::logic_error);
|
||||
REQUIRE(moved.contains<Result>());
|
||||
CHECK(*moved.get<Result>() == 7);
|
||||
const auto report = moved.try_prepare(context);
|
||||
REQUIRE(report);
|
||||
CHECK(report->reused_count() == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("Combining disjoint contexts preserves owned values borrowed identities and selected tracking policies", "[unit][utils][dependencies]")
|
||||
{
|
||||
int value = 4;
|
||||
const auto borrowed = dependencies::make_dependency_context(dependencies::borrow<Borrowed>(value, dependencies::track_by([](const int input)
|
||||
{
|
||||
return input / 10;
|
||||
})));
|
||||
const auto context = dependencies::combine_dependency_contexts(
|
||||
dependencies::make_dependency_context(dependencies::own<Owned>(3)), borrowed.select(dependencies::KeyList<Borrowed>{}));
|
||||
CHECK(context.get<Owned>() == 3);
|
||||
CHECK(&context.get<Borrowed>() == &value);
|
||||
STATIC_CHECK(std::same_as<typename decltype(context)::Keys, dependencies::KeyList<Owned, Borrowed>>);
|
||||
|
||||
auto cache = dependencies::make_computation_cache(context, dependencies::make_computation<Result, dependencies::InputList<Owned, Borrowed>>([](const int owned, const int input)
|
||||
{
|
||||
return owned + input / 10;
|
||||
}));
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Result>() == 3);
|
||||
value = 8;
|
||||
const auto unchanged = cache.try_prepare(context);
|
||||
REQUIRE(unchanged);
|
||||
CHECK(unchanged->reused_count() == 1);
|
||||
value = 12;
|
||||
const auto changed = cache.try_prepare(context);
|
||||
REQUIRE(changed);
|
||||
CHECK(changed->source_changed<Borrowed>());
|
||||
CHECK(cache.get<Result>() == 4);
|
||||
}
|
||||
189
tests/utils/dependencies/mixed_errors.cpp
Normal file
189
tests/utils/dependencies/mixed_errors.cpp
Normal file
@@ -0,0 +1,189 @@
|
||||
#include <expected>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "serif/utils/dependencies/computation_cache.hpp"
|
||||
|
||||
namespace {
|
||||
namespace dependencies = serif::utils::dependencies;
|
||||
|
||||
struct Source {};
|
||||
struct CheckedInput {};
|
||||
struct PhysicsResult {};
|
||||
struct Output {};
|
||||
|
||||
/** @brief Preserve the original invalid argument without converting all failures into text. */
|
||||
struct InputFailure {
|
||||
int value;
|
||||
std::string message;
|
||||
};
|
||||
|
||||
/** @brief Preserve downstream numerical diagnostics independently of input validation. */
|
||||
struct PhysicsFailure {
|
||||
double value;
|
||||
int sample;
|
||||
std::string message;
|
||||
};
|
||||
|
||||
/** @brief A cache error may own diagnostic data that cannot be copied. */
|
||||
struct OwnedFailure {
|
||||
std::unique_ptr<int> value;
|
||||
};
|
||||
}
|
||||
|
||||
TEST_CASE("Explicit error unions preserve each computation's structured failure and transactional recovery", "[unit][utils][dependencies]") {
|
||||
using Failure = std::variant<InputFailure, PhysicsFailure>;
|
||||
|
||||
int input = 2;
|
||||
int input_calls = 0;
|
||||
int physics_calls = 0;
|
||||
int output_calls = 0;
|
||||
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(input));
|
||||
const auto checked = dependencies::make_computation<CheckedInput, dependencies::InputList<Source>>(
|
||||
[&](int value) -> std::expected<double, InputFailure> {
|
||||
++input_calls;
|
||||
if (value < 0) {
|
||||
return std::unexpected(InputFailure{.value = value, .message = "The input must be nonnegative."});
|
||||
}
|
||||
return 2.0 * value;
|
||||
}
|
||||
);
|
||||
const auto physics = dependencies::make_computation<PhysicsResult, dependencies::InputList<CheckedInput>>(
|
||||
[&](double value) -> std::expected<double, PhysicsFailure> {
|
||||
++physics_calls;
|
||||
if (value > 10.0) {
|
||||
return std::unexpected(PhysicsFailure{.value = value, .sample = 3, .message = "The physical state exceeds the admissible range."});
|
||||
}
|
||||
return value + 0.5;
|
||||
}
|
||||
);
|
||||
const auto output = dependencies::make_computation<Output, dependencies::InputList<PhysicsResult>>(
|
||||
[&](double value) {
|
||||
++output_calls;
|
||||
return 3.0 * value;
|
||||
}
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache<Failure>(context, checked, physics, output);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Output>() == 13.5);
|
||||
CHECK(cache.preparation_count() == 1);
|
||||
|
||||
input = -1;
|
||||
const auto rejected_input = cache.try_prepare(context);
|
||||
REQUIRE_FALSE(rejected_input);
|
||||
CHECK(rejected_input.error().computation_index == 0);
|
||||
const InputFailure* input_failure = std::get_if<InputFailure>(&rejected_input.error().error);
|
||||
REQUIRE(input_failure != nullptr);
|
||||
CHECK(input_failure->value == -1);
|
||||
CHECK(input_failure->message == "The input must be nonnegative.");
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK(cache.preparation_count() == 1);
|
||||
CHECK(input_calls == 2);
|
||||
CHECK(physics_calls == 1);
|
||||
CHECK(output_calls == 1);
|
||||
CHECK_THROWS_AS(cache.get<CheckedInput>(), std::logic_error);
|
||||
CHECK_THROWS_AS(cache.get<PhysicsResult>(), std::logic_error);
|
||||
CHECK_THROWS_AS(cache.get<Output>(), std::logic_error);
|
||||
|
||||
input = 3;
|
||||
const auto recovered_input = cache.try_prepare(context);
|
||||
REQUIRE(recovered_input);
|
||||
CHECK(recovered_input->computed_count() == 3);
|
||||
CHECK(cache.get<Output>() == 19.5);
|
||||
CHECK(cache.preparation_count() == 2);
|
||||
|
||||
input = 6;
|
||||
const auto rejected_physics = cache.try_prepare(context);
|
||||
REQUIRE_FALSE(rejected_physics);
|
||||
CHECK(rejected_physics.error().computation_index == 1);
|
||||
const PhysicsFailure* physics_failure = std::get_if<PhysicsFailure>(&rejected_physics.error().error);
|
||||
REQUIRE(physics_failure != nullptr);
|
||||
CHECK(physics_failure->value == 12.0);
|
||||
CHECK(physics_failure->sample == 3);
|
||||
CHECK(physics_failure->message == "The physical state exceeds the admissible range.");
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK(cache.preparation_count() == 2);
|
||||
CHECK(input_calls == 4);
|
||||
CHECK(physics_calls == 3);
|
||||
CHECK(output_calls == 2);
|
||||
CHECK_THROWS_AS(cache.get<CheckedInput>(), std::logic_error);
|
||||
CHECK_THROWS_AS(cache.get<Output>(), std::logic_error);
|
||||
|
||||
// The rejected candidate must not replace the last accepted source or intermediate result.
|
||||
input = 3;
|
||||
const auto recovered_physics = cache.try_prepare(context);
|
||||
REQUIRE(recovered_physics);
|
||||
CHECK(recovered_physics->computed_count() == 0);
|
||||
CHECK(cache.get<CheckedInput>() == 6.0);
|
||||
CHECK(cache.get<PhysicsResult>() == 6.5);
|
||||
CHECK(cache.get<Output>() == 19.5);
|
||||
CHECK(input_calls == 4);
|
||||
CHECK(physics_calls == 3);
|
||||
CHECK(output_calls == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("Requested results avoid unrelated errors in a graph with an explicit failure union", "[unit][utils][dependencies]") {
|
||||
using Failure = std::variant<InputFailure, PhysicsFailure>;
|
||||
|
||||
int input = 7;
|
||||
int physics_calls = 0;
|
||||
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(input));
|
||||
const auto checked = dependencies::make_computation<CheckedInput, dependencies::InputList<Source>>(
|
||||
[](int value) -> std::expected<int, InputFailure> {
|
||||
return value;
|
||||
}
|
||||
);
|
||||
const auto physics = dependencies::make_computation<PhysicsResult, dependencies::InputList<CheckedInput>>(
|
||||
[&](int value) -> std::expected<int, PhysicsFailure> {
|
||||
++physics_calls;
|
||||
return std::unexpected(PhysicsFailure{.value = static_cast<double>(value), .sample = 2, .message = "This downstream result is deliberately unavailable."});
|
||||
}
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache<Failure>(context, checked, physics);
|
||||
const auto requested = cache.try_prepare(context, dependencies::RequestedResults<CheckedInput>{});
|
||||
REQUIRE(requested);
|
||||
CHECK(requested->computed_count() == 1);
|
||||
CHECK(requested->status<PhysicsResult>() == dependencies::PreparationStatus::not_required);
|
||||
CHECK(physics_calls == 0);
|
||||
CHECK(cache.get<CheckedInput>() == 7);
|
||||
CHECK_FALSE(cache.contains<PhysicsResult>());
|
||||
|
||||
const auto rejected = cache.try_prepare(context);
|
||||
REQUIRE_FALSE(rejected);
|
||||
CHECK(std::holds_alternative<PhysicsFailure>(rejected.error().error));
|
||||
CHECK_FALSE(cache.ready());
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<CheckedInput>{}));
|
||||
CHECK(cache.get<CheckedInput>() == 7);
|
||||
CHECK(physics_calls == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("Explicit failure unions transfer move-only diagnostic payloads without copying", "[unit][utils][dependencies]") {
|
||||
using Failure = std::variant<InputFailure, OwnedFailure>;
|
||||
|
||||
int input = -4;
|
||||
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(input));
|
||||
const auto computation = dependencies::make_computation<Output, dependencies::InputList<Source>>(
|
||||
[](int value) -> std::expected<int, OwnedFailure> {
|
||||
if (value < 0) {
|
||||
return std::unexpected(OwnedFailure{.value = std::make_unique<int>(value)});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache<Failure>(context, computation);
|
||||
const auto rejected = cache.try_prepare(context);
|
||||
REQUIRE_FALSE(rejected);
|
||||
const OwnedFailure* failure = std::get_if<OwnedFailure>(&rejected.error().error);
|
||||
REQUIRE(failure != nullptr);
|
||||
REQUIRE(failure->value != nullptr);
|
||||
CHECK(*failure->value == -4);
|
||||
CHECK_FALSE(cache.ready());
|
||||
input = 4;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Output>() == 4);
|
||||
CHECK(*failure->value == -4);
|
||||
}
|
||||
378
tests/utils/dependencies/observers.cpp
Normal file
378
tests/utils/dependencies/observers.cpp
Normal file
@@ -0,0 +1,378 @@
|
||||
#include "serif/utils/dependencies/dependencies.hpp"
|
||||
#include "serif/utils/dependencies/timing.hpp"
|
||||
#include "serif/utils/error/errors.hpp"
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <expected>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
namespace dependencies = serif::utils::dependencies;
|
||||
using Outcome = dependencies::NodeOutcome;
|
||||
|
||||
struct Source {};
|
||||
struct Offset {};
|
||||
struct Shared {};
|
||||
struct Total {};
|
||||
struct Unrequested {};
|
||||
|
||||
/// Simulate exceptional application failures through the library's error entry point.
|
||||
void fail_observed_work() {
|
||||
serif::utils::errors::serif_error<std::runtime_error, serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT>("observed work failed");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Graph observers follow nested node scopes and declared input order only during execution", "[unit][utils][dependencies][observers]") {
|
||||
int source = 2;
|
||||
int offset = 3;
|
||||
std::vector<int> events;
|
||||
events.reserve(128);
|
||||
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source), dependencies::borrow<Offset>(offset));
|
||||
auto graph = dependencies::make_computation_graph(
|
||||
dependencies::make_computation<Total, dependencies::InputList<Offset, Shared>>([&events](int shift, int value) {
|
||||
events.push_back(70);
|
||||
return value + shift;
|
||||
}),
|
||||
dependencies::make_computation<Shared, dependencies::InputList<Source>>([&events](int value) {
|
||||
events.push_back(30);
|
||||
return 2 * value;
|
||||
}),
|
||||
dependencies::make_computation<Unrequested, dependencies::InputList<Source>>([&events](int value) {
|
||||
events.push_back(99);
|
||||
return value;
|
||||
})
|
||||
);
|
||||
auto observers = dependencies::make_observers(
|
||||
dependencies::observe_node<Shared>([&events] {
|
||||
events.push_back(10);
|
||||
return std::make_unique<int>(40);
|
||||
}, [&events](std::unique_ptr<int>& state, Outcome outcome) noexcept {
|
||||
events.push_back(*state);
|
||||
CHECK(outcome == Outcome::completed);
|
||||
}),
|
||||
dependencies::observe_node<Shared>([&events] {
|
||||
events.push_back(11);
|
||||
return 41;
|
||||
}, [&events](int& state, Outcome outcome) noexcept {
|
||||
events.push_back(state);
|
||||
CHECK(outcome == Outcome::completed);
|
||||
}),
|
||||
dependencies::observe_node<Total>([&events] {
|
||||
events.push_back(50);
|
||||
return 80;
|
||||
}, [&events](int& state, Outcome outcome) noexcept {
|
||||
events.push_back(state);
|
||||
CHECK(outcome == Outcome::completed);
|
||||
}),
|
||||
dependencies::observe_edge<Shared, Total>([&events](const int&) { events.push_back(61); }),
|
||||
dependencies::observe_edge<Offset, Total>([&events](const int&) { events.push_back(60); }),
|
||||
dependencies::observe_edge<Source, Shared>([&events](const int&) { events.push_back(20); }),
|
||||
dependencies::observe_edge<Source, Shared>([&events](const int&) { events.push_back(21); })
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers));
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Total>{}));
|
||||
CHECK(events == std::vector<int>{10, 11, 20, 21, 30, 41, 40, 50, 60, 61, 70, 80});
|
||||
CHECK(cache.get<Total>() == 7);
|
||||
const auto first = cache.share<Shared>();
|
||||
|
||||
events.clear();
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Total>{}));
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<>{}));
|
||||
CHECK(events.empty());
|
||||
|
||||
source = 4;
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Shared>{}));
|
||||
CHECK(events == std::vector<int>{10, 11, 20, 21, 30, 41, 40});
|
||||
CHECK_FALSE(cache.contains<Total>());
|
||||
CHECK(*first == 4);
|
||||
events.clear();
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Total>{}));
|
||||
CHECK(events == std::vector<int>{50, 60, 61, 70, 80});
|
||||
CHECK(cache.get<Total>() == 11);
|
||||
|
||||
events.clear();
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Total>{}, dependencies::PreparationMode::rebuild));
|
||||
CHECK(events == std::vector<int>{10, 11, 20, 21, 30, 41, 40, 50, 60, 61, 70, 80});
|
||||
events.clear();
|
||||
cache.invalidate();
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Shared>{}));
|
||||
CHECK(events == std::vector<int>{10, 11, 20, 21, 30, 41, 40});
|
||||
}
|
||||
|
||||
TEST_CASE("Node outcomes describe attempted computation while cache publication remains transactional", "[unit][utils][dependencies][observers]") {
|
||||
int source = 2;
|
||||
std::vector<std::pair<int, Outcome>> outcomes;
|
||||
outcomes.reserve(32);
|
||||
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
|
||||
auto graph = dependencies::make_computation_graph(
|
||||
dependencies::make_computation<Shared, dependencies::InputList<Source>>([](int value) { return value; }),
|
||||
dependencies::make_computation<Total, dependencies::InputList<Shared>>([](int value) -> std::expected<int, std::string> {
|
||||
if (value == -2) {
|
||||
fail_observed_work();
|
||||
}
|
||||
if (value < 0) {
|
||||
return std::unexpected("negative input");
|
||||
}
|
||||
return 2 * value;
|
||||
}),
|
||||
dependencies::make_computation<Unrequested, dependencies::InputList<Total>>([](int value) { return value + 1; })
|
||||
);
|
||||
auto observers = dependencies::make_observers(
|
||||
dependencies::observe_node<Shared>([] { return 1; }, [&outcomes](int& state, Outcome outcome) noexcept { outcomes.emplace_back(state, outcome); }),
|
||||
dependencies::observe_node<Total>([] { return 2; }, [&outcomes](int& state, Outcome outcome) noexcept { outcomes.emplace_back(state, outcome); }),
|
||||
dependencies::observe_node<Unrequested>([] { return 3; }, [&outcomes](int& state, Outcome outcome) noexcept { outcomes.emplace_back(state, outcome); })
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers));
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
const auto accepted = cache.share<Shared>();
|
||||
CHECK(outcomes == std::vector<std::pair<int, Outcome>>{{1, Outcome::completed}, {2, Outcome::completed}, {3, Outcome::completed}});
|
||||
outcomes.clear();
|
||||
|
||||
SECTION("A typed rejection completes earlier nodes but does not publish them") {
|
||||
source = -1;
|
||||
const auto rejected = cache.try_prepare(context);
|
||||
REQUIRE_FALSE(rejected);
|
||||
CHECK(rejected.error().error == "negative input");
|
||||
CHECK(outcomes == std::vector<std::pair<int, Outcome>>{{1, Outcome::completed}, {2, Outcome::rejected}});
|
||||
}
|
||||
SECTION("An escaping exception closes the active node and propagates") {
|
||||
source = -2;
|
||||
CHECK_THROWS_AS(cache.try_prepare(context), std::runtime_error);
|
||||
CHECK(outcomes == std::vector<std::pair<int, Outcome>>{{1, Outcome::completed}, {2, Outcome::exception}});
|
||||
}
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK_FALSE(cache.contains<Shared>());
|
||||
CHECK(*accepted == 2);
|
||||
|
||||
outcomes.clear();
|
||||
source = 2;
|
||||
const auto recovered = cache.try_prepare(context);
|
||||
REQUIRE(recovered);
|
||||
CHECK(recovered->reused_count() == 3);
|
||||
CHECK(cache.share<Shared>() == accepted);
|
||||
CHECK(outcomes.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Observer exceptions close only scopes whose before callback completed", "[unit][utils][dependencies][observers]") {
|
||||
bool fail_before = false;
|
||||
int computations = 0;
|
||||
std::vector<int> events;
|
||||
events.reserve(16);
|
||||
const auto context = dependencies::make_dependency_context(dependencies::own<Source>(2));
|
||||
auto graph = dependencies::make_computation_graph(
|
||||
dependencies::make_computation<Total, dependencies::InputList<Source>>([&computations](int value) {
|
||||
++computations;
|
||||
return value;
|
||||
})
|
||||
);
|
||||
auto observers = dependencies::make_observers(
|
||||
dependencies::observe_node<Total>([&events] {
|
||||
events.push_back(1);
|
||||
return std::make_unique<int>(-1);
|
||||
}, [&events](std::unique_ptr<int>& state, Outcome outcome) noexcept {
|
||||
events.push_back(*state);
|
||||
CHECK(outcome == Outcome::exception);
|
||||
}),
|
||||
dependencies::observe_node<Total>([&events, &fail_before] {
|
||||
events.push_back(2);
|
||||
if (fail_before) {
|
||||
fail_observed_work();
|
||||
}
|
||||
return -2;
|
||||
}, [&events](int& state, Outcome outcome) noexcept {
|
||||
events.push_back(state);
|
||||
CHECK(outcome == Outcome::exception);
|
||||
}),
|
||||
dependencies::observe_edge<Source, Total>([&events](const int&) {
|
||||
events.push_back(3);
|
||||
fail_observed_work();
|
||||
})
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers));
|
||||
SECTION("A failed before callback has no matching after callback") {
|
||||
fail_before = true;
|
||||
CHECK_THROWS_AS(cache.try_prepare(context), std::runtime_error);
|
||||
CHECK(events == std::vector<int>{1, 2, -1});
|
||||
}
|
||||
SECTION("An edge failure unwinds every entered node scope") {
|
||||
CHECK_THROWS_AS(cache.try_prepare(context), std::runtime_error);
|
||||
CHECK(events == std::vector<int>{1, 2, 3, -2, -1});
|
||||
}
|
||||
CHECK(computations == 0);
|
||||
CHECK_FALSE(cache.ready());
|
||||
}
|
||||
|
||||
TEST_CASE("Observers follow moved caches and read owning edges without copying move-only results", "[unit][utils][dependencies][observers]") {
|
||||
using Value = std::unique_ptr<int>;
|
||||
using Inputs = dependencies::InputList<Source>;
|
||||
using Snapshot = std::shared_ptr<const Value>;
|
||||
int source = 4;
|
||||
const Value* observed = nullptr;
|
||||
std::vector<std::pair<int, Outcome>> outcomes;
|
||||
outcomes.reserve(16);
|
||||
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
|
||||
auto graph = dependencies::make_computation_graph(
|
||||
dependencies::make_computation<Shared, Inputs>([](int value) -> std::expected<Value, std::string> {
|
||||
if (value < 0) {
|
||||
return std::unexpected("negative input");
|
||||
}
|
||||
return std::make_unique<int>(value);
|
||||
}),
|
||||
dependencies::make_owning_computation<Total, dependencies::InputList<Shared>>([](Snapshot value) { return value; })
|
||||
);
|
||||
auto observers = dependencies::make_observers(
|
||||
dependencies::observe_node<Shared>([calls = std::make_unique<int>(0)] { return ++*calls; },
|
||||
[&outcomes](int& invocation, Outcome outcome) noexcept { outcomes.emplace_back(invocation, outcome); }),
|
||||
dependencies::observe_edge<Shared, Total>([&observed](const Value& value) { observed = &value; })
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers));
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
const Snapshot first = cache.share<Shared>();
|
||||
CHECK(observed == first.get());
|
||||
CHECK(cache.get<Total>() == first);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(outcomes == std::vector<std::pair<int, Outcome>>{{1, Outcome::completed}});
|
||||
|
||||
auto moved = std::move(cache);
|
||||
CHECK_FALSE(cache.ready());
|
||||
source = 9;
|
||||
REQUIRE(moved.try_prepare(context));
|
||||
const Snapshot accepted = moved.share<Shared>();
|
||||
CHECK(observed == accepted.get());
|
||||
CHECK(**first == 4);
|
||||
CHECK(**accepted == 9);
|
||||
|
||||
source = -1;
|
||||
REQUIRE_FALSE(moved.try_prepare(context));
|
||||
CHECK(observed == accepted.get()); // The owning consumer and its edge never ran for the rejected candidate.
|
||||
source = 9;
|
||||
REQUIRE(moved.try_prepare(context));
|
||||
CHECK(moved.share<Shared>() == accepted);
|
||||
REQUIRE(moved.try_prepare(context, dependencies::RequestedResults<Total>{}, dependencies::PreparationMode::rebuild));
|
||||
CHECK(observed == moved.share<Shared>().get());
|
||||
CHECK(outcomes == std::vector<std::pair<int, Outcome>>{{1, Outcome::completed}, {2, Outcome::completed}, {3, Outcome::rejected}, {4, Outcome::completed}});
|
||||
}
|
||||
|
||||
TEST_CASE("A generic node observer composes with selected scopes without observing cache hits", "[unit][utils][dependencies][observers][timing]") {
|
||||
int source = 3;
|
||||
std::vector<int> events;
|
||||
events.reserve(32);
|
||||
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
|
||||
auto graph = dependencies::make_computation_graph(
|
||||
dependencies::make_computation<Shared, dependencies::InputList<Source>>([](int value) { return value * 2; }),
|
||||
dependencies::make_computation<Total, dependencies::InputList<Shared>>([](int value) { return value + 1; }));
|
||||
auto observers = dependencies::make_observers(
|
||||
dependencies::observe_nodes([&events]<typename Key>(std::type_identity<Key>) {
|
||||
const int identity = std::same_as<Key, Shared> ? 1 : 2;
|
||||
events.push_back(identity);
|
||||
return identity;
|
||||
}, [&events](int& identity, Outcome outcome) noexcept {
|
||||
CHECK(outcome == Outcome::completed);
|
||||
events.push_back(-identity);
|
||||
}),
|
||||
dependencies::observe_node<Shared>([&events] { events.push_back(3); return 3; },
|
||||
[&events](int& identity, Outcome) noexcept { events.push_back(-identity); }));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers));
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(events == std::vector<int>{1, 3, -3, -1, 2, -2});
|
||||
CHECK(cache.get<Total>() == 7);
|
||||
events.clear();
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<>{}));
|
||||
CHECK(events.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Graph timing counts attempted work and preserves rollback with a deterministic clock", "[unit][utils][dependencies][observers][timing]") {
|
||||
using TimePoint = std::chrono::steady_clock::time_point;
|
||||
TimePoint now{};
|
||||
int clock_reads = 0;
|
||||
int source = 2;
|
||||
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
|
||||
auto graph = dependencies::make_computation_graph(
|
||||
dependencies::make_computation<Total, dependencies::InputList<Shared>>([&now](int value) -> std::expected<int, std::string> {
|
||||
now += std::chrono::seconds{3};
|
||||
if (value == -2) { fail_observed_work(); }
|
||||
if (value < 0) { return std::unexpected("negative input"); }
|
||||
return 2 * value;
|
||||
}),
|
||||
dependencies::make_computation<Shared, dependencies::InputList<Source>>([&now](int value) {
|
||||
now += std::chrono::seconds{2};
|
||||
return value;
|
||||
}),
|
||||
dependencies::make_computation<Unrequested, dependencies::InputList<Source>>([&now](int value) {
|
||||
now += std::chrono::seconds{7};
|
||||
return value;
|
||||
}));
|
||||
dependencies::GraphTiming timing;
|
||||
const auto factory = dependencies::time_graph_nodes(timing, [&]() noexcept { ++clock_reads; return now; });
|
||||
auto observers = factory(std::type_identity<decltype(graph)>{});
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers));
|
||||
REQUIRE(timing.runs().size() == 1);
|
||||
const auto& nodes = timing.runs()[0]->nodes;
|
||||
REQUIRE(nodes.size() == 3);
|
||||
CHECK(nodes[0].index == 0);
|
||||
CHECK(nodes[1].index == 1);
|
||||
CHECK(nodes[0].key_name != nodes[1].key_name);
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Total>{}));
|
||||
const auto accepted = cache.share<Shared>();
|
||||
CHECK(nodes[0].seconds == 3.0);
|
||||
CHECK(nodes[1].seconds == 2.0);
|
||||
CHECK(nodes[0].calls == 1);
|
||||
CHECK(nodes[0].completed == 1);
|
||||
CHECK(nodes[2].calls == 0);
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Total>{}));
|
||||
CHECK(clock_reads == 4);
|
||||
|
||||
source = -1;
|
||||
const auto rejected = cache.try_prepare(context, dependencies::RequestedResults<Total>{});
|
||||
REQUIRE_FALSE(rejected);
|
||||
CHECK(rejected.error().computation_index == 0);
|
||||
CHECK(nodes[0].rejected == 1);
|
||||
CHECK(nodes[1].completed == 2);
|
||||
CHECK(*accepted == 2);
|
||||
source = 2;
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Total>{}));
|
||||
CHECK(clock_reads == 8);
|
||||
CHECK(cache.share<Shared>() == accepted);
|
||||
|
||||
auto moved = std::move(cache);
|
||||
source = -2;
|
||||
CHECK_THROWS_AS(moved.try_prepare(context, dependencies::RequestedResults<Total>{}), std::runtime_error);
|
||||
CHECK(nodes[0].exceptions == 1);
|
||||
CHECK(nodes[0].calls == 3);
|
||||
CHECK(nodes[0].seconds == 9.0);
|
||||
CHECK(nodes[0].maximum_seconds == 3.0);
|
||||
CHECK(nodes[1].completed == 3);
|
||||
CHECK(nodes[2].calls == 0);
|
||||
CHECK(clock_reads == 12);
|
||||
|
||||
source = 2;
|
||||
REQUIRE(moved.try_prepare(context, dependencies::RequestedResults<Total>{}));
|
||||
CHECK(clock_reads == 12);
|
||||
REQUIRE(moved.try_prepare(context, dependencies::RequestedResults<Unrequested>{}));
|
||||
CHECK(nodes[2].calls == 1);
|
||||
CHECK(nodes[2].seconds == 7.0);
|
||||
CHECK(clock_reads == 14);
|
||||
}
|
||||
|
||||
TEST_CASE("Timing observers own their records after the collector is released", "[unit][utils][dependencies][observers][timing]") {
|
||||
const auto context = dependencies::make_dependency_context();
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Total, dependencies::InputList<>>([] { return 7; }));
|
||||
std::shared_ptr<const dependencies::GraphTimingRun> run;
|
||||
auto observers = [&] {
|
||||
dependencies::GraphTiming timing;
|
||||
auto result = dependencies::time_graph_nodes(timing)(std::type_identity<decltype(graph)>{});
|
||||
run = timing.runs()[0];
|
||||
return result;
|
||||
}();
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers));
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Total>() == 7);
|
||||
CHECK(run->nodes[0].calls == 1);
|
||||
CHECK(run->nodes[0].completed == 1);
|
||||
CHECK(run->nodes[0].seconds >= 0.0);
|
||||
}
|
||||
203
tests/utils/dependencies/ownership.cpp
Normal file
203
tests/utils/dependencies/ownership.cpp
Normal file
@@ -0,0 +1,203 @@
|
||||
#include <expected>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include "serif/utils/dependencies/dependencies.hpp"
|
||||
|
||||
namespace
|
||||
{
|
||||
namespace dependencies = serif::utils::dependencies;
|
||||
struct Source
|
||||
{
|
||||
};
|
||||
struct Result
|
||||
{
|
||||
};
|
||||
struct OtherResult
|
||||
{
|
||||
};
|
||||
struct FinalResult
|
||||
{
|
||||
};
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Shared dependency results survive refresh rejection invalidation and cache destruction", "[unit][utils][dependencies]")
|
||||
{
|
||||
std::shared_ptr<const std::vector<int>> first;
|
||||
std::shared_ptr<const std::vector<int>> second;
|
||||
std::weak_ptr<const std::vector<int>> released;
|
||||
{
|
||||
int value = 4;
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(value));
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<Source>>(
|
||||
[](const int& input) -> std::expected<std::vector<int>, std::string>
|
||||
{
|
||||
if (input < 0)
|
||||
{
|
||||
return std::unexpected("negative trial");
|
||||
}
|
||||
return std::vector<int>{input, input + 1};
|
||||
}));
|
||||
auto cache = dependencies::make_computation_cache<std::string>(std::move(graph), context);
|
||||
STATIC_CHECK(std::same_as<decltype(cache.share<Result>()), std::shared_ptr<const std::vector<int>>>);
|
||||
CHECK_THROWS(cache.share<Result>());
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
first = cache.share<Result>();
|
||||
CHECK((*first == std::vector<int>{4, 5}));
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.share<Result>() == first);
|
||||
value = 9;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
second = cache.share<Result>();
|
||||
CHECK(second != first);
|
||||
CHECK((*first == std::vector<int>{4, 5}));
|
||||
CHECK((*second == std::vector<int>{9, 10}));
|
||||
value = -1;
|
||||
CHECK_FALSE(cache.try_prepare(context));
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK_THROWS(cache.share<Result>());
|
||||
CHECK((*first == std::vector<int>{4, 5}));
|
||||
CHECK((*second == std::vector<int>{9, 10}));
|
||||
value = 9;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.share<Result>() == second);
|
||||
cache.invalidate();
|
||||
CHECK_THROWS(cache.share<Result>());
|
||||
CHECK((*second == std::vector<int>{9, 10}));
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
released = cache.share<Result>();
|
||||
CHECK_FALSE(released.expired());
|
||||
}
|
||||
CHECK(released.expired());
|
||||
CHECK((*first == std::vector<int>{4, 5}));
|
||||
CHECK((*second == std::vector<int>{9, 10}));
|
||||
released = first;
|
||||
first.reset();
|
||||
CHECK(released.expired());
|
||||
}
|
||||
|
||||
TEST_CASE("Only currently requested dependency results can be shared while retained snapshots stay valid", "[unit][utils][dependencies]")
|
||||
{
|
||||
const auto context = dependencies::make_dependency_context(dependencies::own<Source>(3));
|
||||
auto graph = dependencies::make_computation_graph(
|
||||
dependencies::make_computation<Result, dependencies::InputList<Source>>([](const int& value)
|
||||
{
|
||||
return value;
|
||||
}),
|
||||
dependencies::make_computation<OtherResult, dependencies::InputList<Source>>([](const int& value)
|
||||
{
|
||||
return 2 * value;
|
||||
}));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
const auto previous = cache.share<OtherResult>();
|
||||
REQUIRE(cache.try_prepare(context, dependencies::KeyList<Result>{}));
|
||||
CHECK_THROWS(cache.share<OtherResult>());
|
||||
CHECK(*previous == 6);
|
||||
CHECK(*cache.share<Result>() == 3);
|
||||
auto moved = std::move(cache);
|
||||
CHECK_THROWS(cache.share<Result>());
|
||||
CHECK(*moved.share<Result>() == 3);
|
||||
CHECK(*previous == 6);
|
||||
}
|
||||
|
||||
TEST_CASE("Owning computations retain the exact move-only prerequisite across cache generations and owner lifetimes", "[unit][utils][dependencies]")
|
||||
{
|
||||
using Snapshot = std::shared_ptr<const std::unique_ptr<int>>;
|
||||
Snapshot first;
|
||||
Snapshot second;
|
||||
std::weak_ptr<const std::unique_ptr<int>> unretained;
|
||||
{
|
||||
int value = 4;
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(value));
|
||||
auto graph = dependencies::make_computation_graph(
|
||||
dependencies::make_owning_computation<OtherResult, dependencies::InputList<Result>>([](Snapshot prerequisite)
|
||||
{
|
||||
return prerequisite;
|
||||
}),
|
||||
dependencies::make_computation<Result, dependencies::InputList<Source>>([](const int input)
|
||||
{
|
||||
return std::make_unique<int>(input);
|
||||
}),
|
||||
dependencies::make_computation<FinalResult, dependencies::InputList<OtherResult>>([](const Snapshot& input)
|
||||
{
|
||||
return **input + 1;
|
||||
}));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
first = cache.get<OtherResult>();
|
||||
CHECK(first == cache.share<Result>());
|
||||
CHECK(cache.get<FinalResult>() == 5);
|
||||
|
||||
const auto reused = cache.try_prepare(context);
|
||||
REQUIRE(reused);
|
||||
CHECK(reused->reused_count() == 3);
|
||||
CHECK(cache.get<OtherResult>() == first);
|
||||
|
||||
value = 9;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
second = cache.get<OtherResult>();
|
||||
CHECK(second == cache.share<Result>());
|
||||
CHECK(second != first);
|
||||
CHECK(**first == 4);
|
||||
CHECK(**second == 9);
|
||||
CHECK(cache.get<FinalResult>() == 10);
|
||||
|
||||
cache.invalidate();
|
||||
CHECK(**first == 4);
|
||||
CHECK(**second == 9);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
unretained = cache.share<Result>();
|
||||
CHECK_FALSE(unretained.expired());
|
||||
}
|
||||
CHECK(unretained.expired());
|
||||
CHECK(**first == 4);
|
||||
CHECK(**second == 9);
|
||||
}
|
||||
|
||||
TEST_CASE("Rejected owning computations release candidate prerequisites and preserve the accepted generation", "[unit][utils][dependencies]")
|
||||
{
|
||||
using Snapshot = std::shared_ptr<const std::unique_ptr<int>>;
|
||||
int value = 4;
|
||||
std::weak_ptr<const std::unique_ptr<int>> rejected;
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(value));
|
||||
auto graph = dependencies::make_computation_graph(
|
||||
dependencies::make_computation<Result, dependencies::InputList<Source>>([](const int input)
|
||||
{
|
||||
return std::make_unique<int>(input);
|
||||
}),
|
||||
dependencies::make_owning_computation<OtherResult, dependencies::InputList<Result>>([&rejected](Snapshot prerequisite) -> std::expected<Snapshot, std::string>
|
||||
{
|
||||
if (**prerequisite < 0)
|
||||
{
|
||||
rejected = prerequisite;
|
||||
return std::unexpected("negative candidate");
|
||||
}
|
||||
return prerequisite;
|
||||
}));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
const Snapshot accepted = cache.get<OtherResult>();
|
||||
|
||||
value = -1;
|
||||
const auto failure = cache.try_prepare(context);
|
||||
REQUIRE_FALSE(failure);
|
||||
CHECK(failure.error().error == "negative candidate");
|
||||
CHECK(rejected.expired());
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK(**accepted == 4);
|
||||
|
||||
value = 4;
|
||||
const auto recovered = cache.try_prepare(context);
|
||||
REQUIRE(recovered);
|
||||
CHECK(recovered->reused_count() == 2);
|
||||
CHECK(cache.get<OtherResult>() == accepted);
|
||||
|
||||
const auto rebuilt = cache.try_prepare(context, dependencies::KeyList<OtherResult>{}, dependencies::PreparationMode::rebuild);
|
||||
REQUIRE(rebuilt);
|
||||
CHECK(rebuilt->computed_count() == 2);
|
||||
CHECK(cache.get<OtherResult>() != accepted);
|
||||
CHECK(**accepted == 4);
|
||||
}
|
||||
260
tests/utils/dependencies/preparation.cpp
Normal file
260
tests/utils/dependencies/preparation.cpp
Normal file
@@ -0,0 +1,260 @@
|
||||
#include "serif/utils/dependencies/dependencies.hpp"
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <array>
|
||||
#include <expected>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
namespace dependencies = serif::utils::dependencies;
|
||||
|
||||
struct Source {};
|
||||
struct OtherSource {};
|
||||
struct Shared {};
|
||||
struct Left {};
|
||||
struct Right {};
|
||||
struct Total {};
|
||||
}
|
||||
|
||||
TEST_CASE("Named computations build a cache directly with general and custom keys", "[unit][utils][dependencies]") {
|
||||
namespace keys = dependencies::keys;
|
||||
struct Offset { double unused; };
|
||||
|
||||
std::array<double, 3> a{1.0, 2.0, 3.0};
|
||||
double scale = 2.0;
|
||||
double offset = 0.5;
|
||||
std::array<int, 2> calls{};
|
||||
const auto context = dependencies::make_dependency_context(
|
||||
dependencies::borrow<keys::Input>(a), dependencies::borrow<keys::Scale>(scale), dependencies::borrow<Offset>(offset)
|
||||
);
|
||||
const auto multiply = dependencies::make_computation<keys::Output, dependencies::InputList<keys::Input, keys::Scale>>(
|
||||
[&calls](const std::array<double, 3>& input, double factor) {
|
||||
++calls[0];
|
||||
return std::array<double, 3>{factor * input[0], factor * input[1], factor * input[2]};
|
||||
}
|
||||
);
|
||||
const auto sum = dependencies::make_computation<Total, dependencies::InputList<keys::Output, Offset>>(
|
||||
[&calls](const std::array<double, 3>& input, double shift) {
|
||||
++calls[1];
|
||||
return input[0] + input[1] + input[2] + shift;
|
||||
}
|
||||
);
|
||||
|
||||
// No explicit graph or ownership transfer is needed; prerequisites may be supplied last.
|
||||
auto cache = dependencies::make_computation_cache(context, sum, multiply);
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK(calls == std::array<int, 2>{0, 0});
|
||||
auto report = cache.try_prepare(context, dependencies::RequestedResults<Total>{});
|
||||
REQUIRE(report);
|
||||
CHECK(report->computed_count() == 2);
|
||||
CHECK(cache.get<keys::Output>() == std::array<double, 3>{2.0, 4.0, 6.0});
|
||||
CHECK(cache.get<Total>() == 12.5);
|
||||
CHECK(calls == std::array<int, 2>{1, 1});
|
||||
|
||||
report = cache.try_prepare(context);
|
||||
REQUIRE(report);
|
||||
CHECK(report->reused_count() == 2);
|
||||
CHECK(calls == std::array<int, 2>{1, 1});
|
||||
|
||||
offset = 1.5;
|
||||
report = cache.try_prepare(context);
|
||||
REQUIRE(report);
|
||||
CHECK(report->status<keys::Output>() == dependencies::PreparationStatus::reused);
|
||||
CHECK(report->status<Total>() == dependencies::PreparationStatus::computed);
|
||||
CHECK(cache.get<Total>() == 13.5);
|
||||
CHECK(calls == std::array<int, 2>{1, 2});
|
||||
|
||||
scale = 3.0;
|
||||
report = cache.try_prepare(context);
|
||||
REQUIRE(report);
|
||||
CHECK(report->computed_count() == 2);
|
||||
CHECK(cache.get<Total>() == 19.5);
|
||||
CHECK(calls == std::array<int, 2>{2, 3});
|
||||
}
|
||||
|
||||
TEST_CASE("Direct cache construction transfers computations owning noncopyable state", "[unit][utils][dependencies]") {
|
||||
const auto context = dependencies::make_dependency_context(dependencies::own<Source>(4));
|
||||
auto computation = dependencies::make_computation<Total, dependencies::InputList<Source>>(
|
||||
[offset = std::make_unique<int>(3)](int input) { return input + *offset; }
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache(context, std::move(computation));
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Total>() == 7);
|
||||
const auto report = cache.try_prepare(context);
|
||||
REQUIRE(report);
|
||||
CHECK(report->status<Total>() == dependencies::PreparationStatus::reused);
|
||||
}
|
||||
|
||||
TEST_CASE("A dependency diamond prepares shared work once in prerequisite order", "[unit][utils][dependencies]") {
|
||||
int source = 2;
|
||||
std::array<int, 4> calls{};
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
|
||||
auto graph = dependencies::make_computation_graph(
|
||||
dependencies::make_computation<Total, dependencies::InputList<Left, Right>>([&calls](int left, int right) { ++calls[3]; return left + right; }),
|
||||
dependencies::make_computation<Right, dependencies::InputList<Shared>>([&calls](int shared) { ++calls[2]; return 3 * shared; }),
|
||||
dependencies::make_computation<Shared, dependencies::InputList<Source>>([&calls](int input) { ++calls[0]; return 2 * input; }),
|
||||
dependencies::make_computation<Left, dependencies::InputList<Shared>>([&calls](int shared) { ++calls[1]; return shared + 1; })
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache(graph, context);
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK_THROWS_AS(cache.get<Total>(), std::logic_error);
|
||||
|
||||
auto report = cache.try_prepare(context, dependencies::RequestedResults<Total>{});
|
||||
REQUIRE(report);
|
||||
CHECK(report->computed_count() == 4);
|
||||
CHECK(report->source_changed<Source>());
|
||||
CHECK(cache.get<Total>() == 17);
|
||||
CHECK(calls == std::array<int, 4>{1, 1, 1, 1});
|
||||
|
||||
report = cache.try_prepare(context);
|
||||
REQUIRE(report);
|
||||
CHECK(report->reused_count() == 4);
|
||||
CHECK_FALSE(report->source_changed<Source>());
|
||||
CHECK(calls == std::array<int, 4>{1, 1, 1, 1});
|
||||
|
||||
source = 3;
|
||||
report = cache.try_prepare(context);
|
||||
REQUIRE(report);
|
||||
CHECK(report->computed_count() == 4);
|
||||
CHECK(cache.get<Total>() == 25);
|
||||
CHECK(calls == std::array<int, 4>{2, 2, 2, 2});
|
||||
|
||||
report = cache.try_prepare(context, dependencies::RequestedResults<Total>{}, dependencies::PreparationMode::rebuild);
|
||||
REQUIRE(report);
|
||||
CHECK(report->computed_count() == 4);
|
||||
CHECK(calls == std::array<int, 4>{3, 3, 3, 3});
|
||||
|
||||
cache.invalidate();
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK_THROWS_AS(cache.get<Left>(), std::logic_error);
|
||||
report = cache.try_prepare(context);
|
||||
REQUIRE(report);
|
||||
CHECK(report->computed_count() == 4);
|
||||
CHECK(calls == std::array<int, 4>{4, 4, 4, 4});
|
||||
}
|
||||
|
||||
TEST_CASE("Partial dependency requests retain caches and propagate later changes", "[unit][utils][dependencies]") {
|
||||
int source = 2;
|
||||
int other_source = 3;
|
||||
std::array<int, 3> calls{};
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source), dependencies::borrow<OtherSource>(other_source));
|
||||
auto graph = dependencies::make_computation_graph(
|
||||
dependencies::make_computation<Total, dependencies::InputList<Left, Right>>([&calls](int left, int right) { ++calls[2]; return left + right; }),
|
||||
dependencies::make_computation<Left, dependencies::InputList<Source>>([&calls](int input) { ++calls[0]; return 2 * input; }),
|
||||
dependencies::make_computation<Right, dependencies::InputList<OtherSource>>([&calls](int input) { ++calls[1]; return 2 * input; })
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Total>() == 10);
|
||||
|
||||
source = 4;
|
||||
auto report = cache.try_prepare(context, dependencies::RequestedResults<Left>{});
|
||||
REQUIRE(report);
|
||||
CHECK(calls == std::array<int, 3>{2, 1, 1});
|
||||
CHECK(report->status<Right>() == dependencies::PreparationStatus::not_required);
|
||||
CHECK_FALSE(cache.contains<Total>());
|
||||
CHECK_THROWS_AS(cache.get<Right>(), std::logic_error);
|
||||
|
||||
report = cache.try_prepare(context, dependencies::RequestedResults<Total>{});
|
||||
REQUIRE(report);
|
||||
CHECK(cache.get<Total>() == 14);
|
||||
CHECK(report->computed_count() == 1);
|
||||
CHECK(report->reused_count() == 2);
|
||||
CHECK(calls == std::array<int, 3>{2, 1, 2});
|
||||
|
||||
other_source = 5;
|
||||
report = cache.try_prepare(context, dependencies::RequestedResults<Left>{});
|
||||
REQUIRE(report);
|
||||
CHECK_FALSE(report->source_changed<OtherSource>()); // This source was outside the request and was not inspected.
|
||||
CHECK(report->reused_count() == 1);
|
||||
report = cache.try_prepare(context, dependencies::RequestedResults<Right>{});
|
||||
REQUIRE(report);
|
||||
CHECK(report->source_changed<OtherSource>());
|
||||
CHECK(cache.get<Right>() == 10);
|
||||
|
||||
report = cache.try_prepare(context);
|
||||
REQUIRE(report);
|
||||
CHECK(report->computed_count() == 1);
|
||||
CHECK(cache.get<Total>() == 18);
|
||||
CHECK(calls == std::array<int, 3>{2, 2, 3});
|
||||
|
||||
report = cache.try_prepare(context, dependencies::RequestedResults<>{});
|
||||
REQUIRE(report);
|
||||
CHECK(cache.ready());
|
||||
CHECK_FALSE(cache.contains<Total>());
|
||||
CHECK(report->computed_count() == 0);
|
||||
CHECK(report->reused_count() == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("Empty dependency graphs and constant calculations need no sources", "[unit][utils][dependencies]") {
|
||||
const auto context = dependencies::make_dependency_context();
|
||||
auto empty = dependencies::make_computation_cache(dependencies::make_computation_graph(), context);
|
||||
const auto empty_report = empty.try_prepare(context);
|
||||
REQUIRE(empty_report);
|
||||
CHECK(empty.ready());
|
||||
CHECK(empty_report->computed_count() == 0);
|
||||
|
||||
int calls = 0;
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Total, dependencies::InputList<>>([&calls] { ++calls; return 42; }));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(calls == 1);
|
||||
CHECK(cache.get<Total>() == 42);
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Total>{}, dependencies::PreparationMode::rebuild));
|
||||
CHECK(calls == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("Move-only results retain accepted generations across rebuilds and rejection", "[unit][utils][dependencies]") {
|
||||
using Value = std::unique_ptr<int>;
|
||||
int source = 4;
|
||||
int calls = 0;
|
||||
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
|
||||
const auto computation = dependencies::make_computation<Total, dependencies::InputList<Source>>(
|
||||
[&calls](int input) -> std::expected<Value, std::string> {
|
||||
++calls;
|
||||
if (input < 0) {
|
||||
return std::unexpected("negative input");
|
||||
}
|
||||
return std::make_unique<int>(input);
|
||||
}
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache(context, computation);
|
||||
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
const auto first = cache.share<Total>();
|
||||
CHECK(**first == 4);
|
||||
const auto reused = cache.try_prepare(context);
|
||||
REQUIRE(reused);
|
||||
CHECK(reused->status<Total>() == dependencies::PreparationStatus::reused);
|
||||
CHECK(cache.share<Total>() == first);
|
||||
CHECK(calls == 1);
|
||||
|
||||
const auto forced = cache.try_prepare(context, dependencies::RequestedResults<Total>{}, dependencies::PreparationMode::rebuild);
|
||||
REQUIRE(forced);
|
||||
CHECK(forced->status<Total>() == dependencies::PreparationStatus::computed);
|
||||
const auto accepted = cache.share<Total>();
|
||||
CHECK(accepted != first);
|
||||
CHECK(calls == 2);
|
||||
CHECK(**accepted == 4);
|
||||
CHECK(**first == 4);
|
||||
|
||||
source = -1;
|
||||
const auto rejected = cache.try_prepare(context);
|
||||
REQUIRE_FALSE(rejected);
|
||||
CHECK(rejected.error().error == "negative input");
|
||||
CHECK_FALSE(cache.contains<Total>());
|
||||
CHECK(calls == 3);
|
||||
CHECK(**accepted == 4);
|
||||
CHECK(**first == 4);
|
||||
|
||||
source = 4;
|
||||
const auto recovered = cache.try_prepare(context);
|
||||
REQUIRE(recovered);
|
||||
CHECK(recovered->status<Total>() == dependencies::PreparationStatus::reused);
|
||||
CHECK(cache.share<Total>() == accepted);
|
||||
CHECK(calls == 3);
|
||||
}
|
||||
62
tests/utils/dependencies/reachability.cpp
Normal file
62
tests/utils/dependencies/reachability.cpp
Normal file
@@ -0,0 +1,62 @@
|
||||
#include <array>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "serif/utils/dependencies/dependencies.hpp"
|
||||
|
||||
namespace
|
||||
{
|
||||
namespace dependencies = serif::utils::dependencies;
|
||||
struct Source
|
||||
{
|
||||
};
|
||||
template <int Index>
|
||||
struct Stage
|
||||
{
|
||||
};
|
||||
}
|
||||
|
||||
TEST_CASE("Requested dependency branches follow topology independently of declaration order", "[unit][utils][dependencies]")
|
||||
{
|
||||
int input = 2;
|
||||
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(input));
|
||||
std::array<int, 8> calls{};
|
||||
const auto branch = [&]<int Result, int Input>()
|
||||
{
|
||||
return dependencies::make_computation<Stage<Result>, dependencies::InputList<Stage<Input>>>([&](const int value)
|
||||
{
|
||||
++calls[Result];
|
||||
return value + Result;
|
||||
});
|
||||
};
|
||||
const auto graph = dependencies::make_computation_graph(
|
||||
branch.template operator()<4, 2>(),
|
||||
dependencies::make_computation<Stage<6>, dependencies::InputList<Stage<4>, Stage<5>>>([&](const int left, const int right)
|
||||
{
|
||||
++calls[6];
|
||||
return left + right;
|
||||
}),
|
||||
branch.template operator()<2, 1>(),
|
||||
branch.template operator()<7, 3>(),
|
||||
branch.template operator()<5, 3>(),
|
||||
branch.template operator()<1, 0>(),
|
||||
dependencies::make_computation<Stage<0>, dependencies::InputList<Source>>([&](const int value)
|
||||
{
|
||||
++calls[0];
|
||||
return value;
|
||||
}),
|
||||
branch.template operator()<3, 1>());
|
||||
auto cache = dependencies::make_computation_cache(graph, context);
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Stage<4>>{}));
|
||||
CHECK(cache.get<Stage<4>>() == 9);
|
||||
CHECK(calls == std::array{1, 1, 1, 0, 1, 0, 0, 0});
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Stage<6>>{}));
|
||||
CHECK(cache.get<Stage<6>>() == 20);
|
||||
CHECK(calls == std::array{1, 1, 1, 1, 1, 1, 1, 0});
|
||||
input = 5;
|
||||
REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<Stage<4>, Stage<7>>{}));
|
||||
CHECK(cache.get<Stage<4>>() == 12);
|
||||
CHECK(cache.get<Stage<7>>() == 16);
|
||||
CHECK(calls == std::array{2, 2, 2, 2, 2, 1, 1, 1});
|
||||
CHECK(cache.report().status<Stage<5>>() == dependencies::PreparationStatus::not_required);
|
||||
CHECK(cache.report().status<Stage<6>>() == dependencies::PreparationStatus::not_required);
|
||||
}
|
||||
193
tests/utils/dependencies/rejection.cpp
Normal file
193
tests/utils/dependencies/rejection.cpp
Normal file
@@ -0,0 +1,193 @@
|
||||
#include "serif/utils/error/errors.hpp"
|
||||
#include "serif/utils/dependencies/dependencies.hpp"
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers_string.hpp>
|
||||
#include <expected>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
namespace dependencies = serif::utils::dependencies;
|
||||
|
||||
struct Source {};
|
||||
struct Intermediate {};
|
||||
struct Result {};
|
||||
|
||||
struct ThrowingSnapshot {
|
||||
/// Simulate a policy failing while it tries to capture replacement state.
|
||||
int capture(const int& value) const {
|
||||
if (value < 0) {
|
||||
serif::utils::errors::serif_error<std::runtime_error, serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT>("source snapshot unavailable");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Compare only successfully captured states.
|
||||
bool matches(const int& captured, const int& value) const { return captured == value; }
|
||||
};
|
||||
}
|
||||
|
||||
TEST_CASE("Rejected dependency preparation publishes neither candidate results nor snapshots", "[unit][utils][dependencies]") {
|
||||
int source = 2;
|
||||
int intermediate_calls = 0;
|
||||
int result_calls = 0;
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
|
||||
auto graph = dependencies::make_computation_graph(
|
||||
dependencies::make_computation<Result, dependencies::InputList<Intermediate>>([&result_calls](int intermediate) -> std::expected<int, std::string> {
|
||||
++result_calls;
|
||||
if (intermediate < 0) { return std::unexpected("input must be nonnegative"); }
|
||||
return intermediate + 1;
|
||||
}),
|
||||
dependencies::make_computation<Intermediate, dependencies::InputList<Source>>([&intermediate_calls](int input) { ++intermediate_calls; return 2 * input; })
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Result>() == 5);
|
||||
|
||||
source = -3;
|
||||
auto rejected = cache.try_prepare(context);
|
||||
REQUIRE_FALSE(rejected);
|
||||
CHECK(rejected.error().computation_index == 0);
|
||||
CHECK(rejected.error().error == "input must be nonnegative");
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK_FALSE(cache.contains<Intermediate>());
|
||||
CHECK_THROWS_AS(cache.get<Result>(), std::logic_error);
|
||||
CHECK(cache.report().computed_count() == 0);
|
||||
CHECK(cache.report().reused_count() == 0);
|
||||
CHECK(intermediate_calls == 2);
|
||||
CHECK(result_calls == 2);
|
||||
|
||||
// Retrying rejected inputs must rebuild the prerequisite: its candidate was never committed.
|
||||
REQUIRE_FALSE(cache.try_prepare(context));
|
||||
CHECK(intermediate_calls == 3);
|
||||
CHECK(result_calls == 3);
|
||||
|
||||
// The last accepted cache is still usable when the source returns to its accepted state.
|
||||
source = 2;
|
||||
const auto recovered = cache.try_prepare(context);
|
||||
REQUIRE(recovered);
|
||||
CHECK(recovered->reused_count() == 2);
|
||||
CHECK(cache.get<Result>() == 5);
|
||||
CHECK(intermediate_calls == 3);
|
||||
CHECK(result_calls == 3);
|
||||
|
||||
source = 4;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Result>() == 9);
|
||||
CHECK(intermediate_calls == 4);
|
||||
CHECK(result_calls == 4);
|
||||
}
|
||||
|
||||
TEST_CASE("Dependency computations can return move-only typed errors", "[unit][utils][dependencies]") {
|
||||
using Error = std::unique_ptr<int>;
|
||||
int source = -1;
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<Source>>([](int value) -> std::expected<int, Error> {
|
||||
if (value < 0) { return std::unexpected(std::make_unique<int>(value)); }
|
||||
return value;
|
||||
}));
|
||||
auto cache = dependencies::make_computation_cache<Error>(std::move(graph), context);
|
||||
const auto rejected = cache.try_prepare(context);
|
||||
REQUIRE_FALSE(rejected);
|
||||
CHECK(*rejected.error().error == -1);
|
||||
source = 3;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Result>() == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("Exceptions leave dependency results unavailable without replacing the accepted cache", "[unit][utils][dependencies]") {
|
||||
int source = 2;
|
||||
int calls = 0;
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source, ThrowingSnapshot{}));
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<Source>>([&calls](int value) {
|
||||
++calls;
|
||||
if (value == 3) {
|
||||
serif::utils::errors::serif_error<std::runtime_error, serif::utils::errors::SERiFErrorCode::INVALID_ARGUMENT>("calculation failed");
|
||||
}
|
||||
return value;
|
||||
}));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(calls == 1);
|
||||
|
||||
SECTION("A source policy throws before evaluation") {
|
||||
source = -1;
|
||||
CHECK_THROWS_WITH(cache.try_prepare(context), Catch::Matchers::ContainsSubstring("source snapshot unavailable"));
|
||||
CHECK(calls == 1);
|
||||
}
|
||||
SECTION("The calculation throws after snapshot capture") {
|
||||
source = 3;
|
||||
CHECK_THROWS_WITH(cache.try_prepare(context), Catch::Matchers::ContainsSubstring("calculation failed"));
|
||||
CHECK(calls == 2);
|
||||
}
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK_THROWS_AS(cache.get<Result>(), std::logic_error);
|
||||
source = 2;
|
||||
const auto recovered = cache.try_prepare(context);
|
||||
REQUIRE(recovered);
|
||||
CHECK(recovered->reused_count() == 1);
|
||||
CHECK(cache.get<Result>() == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("Preparation counts distinguish successful requests from rejected attempts and survive cache moves", "[unit][utils][dependencies]") {
|
||||
int source = 2;
|
||||
int evaluations = 0;
|
||||
const auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source));
|
||||
const auto computation = dependencies::make_computation<Result, dependencies::InputList<Source>>(
|
||||
[&evaluations](int input) -> std::expected<int, std::string> {
|
||||
++evaluations;
|
||||
if (input < 0) {
|
||||
return std::unexpected("input must be nonnegative");
|
||||
}
|
||||
|
||||
return 2 * input;
|
||||
}
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache(context, computation);
|
||||
CHECK(cache.preparation_count() == 0);
|
||||
|
||||
const auto computed = cache.try_prepare(context);
|
||||
REQUIRE(computed);
|
||||
CHECK(computed->status<Result>() == dependencies::PreparationStatus::computed);
|
||||
CHECK(cache.preparation_count() == 1);
|
||||
CHECK(evaluations == 1);
|
||||
|
||||
const auto reused = cache.try_prepare(context);
|
||||
REQUIRE(reused);
|
||||
CHECK(reused->status<Result>() == dependencies::PreparationStatus::reused);
|
||||
CHECK(cache.preparation_count() == 2);
|
||||
CHECK(evaluations == 1);
|
||||
|
||||
source = -1;
|
||||
const auto rejected = cache.try_prepare(context);
|
||||
REQUIRE_FALSE(rejected);
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK(cache.preparation_count() == 2);
|
||||
CHECK(evaluations == 2);
|
||||
|
||||
cache.invalidate();
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK(cache.preparation_count() == 2);
|
||||
source = 2;
|
||||
const auto recovered = cache.try_prepare(context);
|
||||
REQUIRE(recovered);
|
||||
CHECK(recovered->status<Result>() == dependencies::PreparationStatus::computed);
|
||||
CHECK(cache.preparation_count() == 3);
|
||||
CHECK(evaluations == 3);
|
||||
|
||||
auto moved = std::move(cache);
|
||||
CHECK(moved.ready());
|
||||
CHECK(moved.preparation_count() == 3);
|
||||
CHECK(moved.get<Result>() == 4);
|
||||
CHECK_FALSE(cache.ready());
|
||||
CHECK(cache.preparation_count() == 0);
|
||||
|
||||
const auto moved_report = moved.try_prepare(context);
|
||||
REQUIRE(moved_report);
|
||||
CHECK(moved_report->status<Result>() == dependencies::PreparationStatus::reused);
|
||||
CHECK(moved.preparation_count() == 4);
|
||||
CHECK(evaluations == 3);
|
||||
}
|
||||
137
tests/utils/dependencies/snapshots.cpp
Normal file
137
tests/utils/dependencies/snapshots.cpp
Normal file
@@ -0,0 +1,137 @@
|
||||
#include "serif/utils/dependencies/dependencies.hpp"
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <memory>
|
||||
#include <numeric>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
namespace dependencies = serif::utils::dependencies;
|
||||
|
||||
struct Source {};
|
||||
struct Unused {};
|
||||
struct Result {};
|
||||
struct OtherResult {};
|
||||
struct Untrackable {};
|
||||
|
||||
struct View {
|
||||
const int& binding;
|
||||
const int& value;
|
||||
};
|
||||
struct ViewTracking {
|
||||
/// Snapshot content without retaining the temporary view.
|
||||
int capture(const View& view) const { return view.value; }
|
||||
/// Vector storage may move or be copied without changing its value.
|
||||
bool matches(const int& captured, const View& view) const { return captured == view.value; }
|
||||
/// The persistent role, rather than its temporary wrapper, defines identity.
|
||||
const void* identity(const View& view) const { return &view.binding; }
|
||||
};
|
||||
|
||||
struct RevisionedSource {
|
||||
std::unique_ptr<int> value;
|
||||
unsigned revision{0};
|
||||
};
|
||||
|
||||
struct OwnedSnapshot {
|
||||
int& captures;
|
||||
int& comparisons;
|
||||
|
||||
/// Exercise an owning snapshot that cannot itself be copied.
|
||||
std::unique_ptr<int> capture(const RevisionedSource& source) const {
|
||||
++captures;
|
||||
return std::make_unique<int>(*source.value);
|
||||
}
|
||||
|
||||
/// Compare the frozen content, independent of source revision counters.
|
||||
bool matches(const std::unique_ptr<int>& captured, const RevisionedSource& source) const {
|
||||
++comparisons;
|
||||
return *captured == *source.value;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
TEST_CASE("Dependency views can preserve underlying identity across temporary wrappers", "[unit][utils][dependencies]") {
|
||||
int binding = 1, other_binding = 1, value = 4, copied_value = 4;
|
||||
const auto context = dependencies::make_dependency_context(dependencies::own<Source>(View{binding, value}, ViewTracking{}));
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<Source>>([](const View& view) { return view.value; }));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
const auto copied = dependencies::make_dependency_context(dependencies::own<Source>(View{binding, copied_value}, ViewTracking{}));
|
||||
auto report = cache.try_prepare(copied);
|
||||
REQUIRE(report);
|
||||
CHECK(report->reused_count() == 1);
|
||||
|
||||
const auto rebound = dependencies::make_dependency_context(dependencies::own<Source>(View{other_binding, copied_value}, ViewTracking{}));
|
||||
report = cache.try_prepare(rebound);
|
||||
REQUIRE(report);
|
||||
CHECK(report->computed_count() == 1);
|
||||
copied_value = 8;
|
||||
REQUIRE(cache.try_prepare(rebound));
|
||||
CHECK(cache.get<Result>() == 8);
|
||||
}
|
||||
|
||||
TEST_CASE("Dependency value snapshots detect in-place container changes", "[unit][utils][dependencies]") {
|
||||
std::vector<int> values{2, 3};
|
||||
int calls = 0;
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(values), dependencies::own<Unused>(Untrackable{}));
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<Source>>([&calls](const std::vector<int>& input) { ++calls; return std::accumulate(input.begin(), input.end(), 0); }));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Result>() == 5);
|
||||
|
||||
values[1] = 7;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Result>() == 9);
|
||||
CHECK(calls == 2);
|
||||
values.push_back(4);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(cache.get<Result>() == 13);
|
||||
CHECK(calls == 3);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(calls == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("A dependency projection tracks noncopyable sources by revision", "[unit][utils][dependencies]") {
|
||||
RevisionedSource source{.value = std::make_unique<int>(2)};
|
||||
int calls = 0;
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source,
|
||||
dependencies::track_by([](const RevisionedSource& input) { return input.revision; })));
|
||||
auto graph = dependencies::make_computation_graph(dependencies::make_computation<Result, dependencies::InputList<Source>>([&calls](const RevisionedSource& input) { ++calls; return *input.value; }));
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(calls == 1);
|
||||
*source.value = 8;
|
||||
++source.revision;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(calls == 2);
|
||||
CHECK(cache.get<Result>() == 8);
|
||||
}
|
||||
|
||||
TEST_CASE("Required sources are inspected once and can use move-only snapshot state", "[unit][utils][dependencies]") {
|
||||
RevisionedSource source{.value = std::make_unique<int>(2)};
|
||||
int captures = 0;
|
||||
int comparisons = 0;
|
||||
auto context = dependencies::make_dependency_context(dependencies::borrow<Source>(source,
|
||||
OwnedSnapshot{.captures = captures, .comparisons = comparisons}));
|
||||
auto graph = dependencies::make_computation_graph(
|
||||
dependencies::make_computation<Result, dependencies::InputList<Source>>([](const RevisionedSource& input) { return *input.value; }),
|
||||
dependencies::make_computation<OtherResult, dependencies::InputList<Source>>([](const RevisionedSource& input) { return 2 * *input.value; })
|
||||
);
|
||||
auto cache = dependencies::make_computation_cache(std::move(graph), context);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(captures == 1);
|
||||
CHECK(comparisons == 0);
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(captures == 1);
|
||||
CHECK(comparisons == 1);
|
||||
|
||||
*source.value = 9;
|
||||
REQUIRE(cache.try_prepare(context));
|
||||
CHECK(captures == 2);
|
||||
CHECK(comparisons == 2);
|
||||
CHECK(cache.get<Result>() == 9);
|
||||
CHECK(cache.get<OtherResult>() == 18);
|
||||
}
|
||||
71
tests/utils/laneemden/formatting.cpp
Normal file
71
tests/utils/laneemden/formatting.cpp
Normal file
@@ -0,0 +1,71 @@
|
||||
#include <array>
|
||||
#include <format>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers_string.hpp>
|
||||
|
||||
#include "serif/utils/laneemden/solution.hpp"
|
||||
|
||||
namespace {
|
||||
namespace laneemden = serif::utils::laneemden;
|
||||
constexpr laneemden::Sample sample{.xi = 2.0, .theta = 0.5, .derivative = -0.25};
|
||||
|
||||
/** @brief Use distinct profile and surface values so selection forwarding is observable. */
|
||||
[[nodiscard]] laneemden::Solution example_solution() {
|
||||
return {
|
||||
.n = 1.0,
|
||||
.samples = {{.xi = 0.0, .theta = 1.0, .derivative = 0.0}, sample},
|
||||
.surface = laneemden::Sample{.xi = 3.0, .theta = 0.0, .derivative = -0.5}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Lane Emden sample formatting selects complete or individual values", "[laneemden][formatting]") {
|
||||
CHECK(std::format("{}", sample) == "(ξ=2, θ=0.5, dθ/dξ=-0.25)");
|
||||
CHECK(std::format("{:}", sample) == std::format("{}", sample));
|
||||
CHECK(std::format("{:all}", sample) == std::format("{}", sample));
|
||||
CHECK(std::format("{:xi}", sample) == "2");
|
||||
CHECK(std::format("{:theta}", sample) == "0.5");
|
||||
CHECK(std::format("{:derivative}", sample) == "-0.25");
|
||||
CHECK(std::format("{1:theta} / {0:xi}", sample, sample) == "0.5 / 2");
|
||||
CHECK(std::format("{{{:xi}}}", sample) == "{2}");
|
||||
}
|
||||
|
||||
TEST_CASE("Lane Emden solution formatting forwards selection to its profile and surface", "[laneemden][formatting]") {
|
||||
const auto solution = example_solution();
|
||||
CHECK(std::format("{}", solution) == "Solution(n=1, samples=[(ξ=0, θ=1, dθ/dξ=0), (ξ=2, θ=0.5, dθ/dξ=-0.25)], surface=(ξ=3, θ=0, dθ/dξ=-0.5))");
|
||||
CHECK(std::format("{:}", solution) == std::format("{}", solution));
|
||||
CHECK(std::format("{:all}", solution) == std::format("{}", solution));
|
||||
CHECK(std::format("{:xi}", solution) == "Solution(n=1, samples=[0, 2], surface=3)");
|
||||
CHECK(std::format("{:theta}", solution) == "Solution(n=1, samples=[1, 0.5], surface=0)");
|
||||
CHECK(std::format("{:derivative}", solution) == "Solution(n=1, samples=[0, -0.25], surface=-0.5)");
|
||||
const laneemden::Solution empty{.n = 5.0, .samples = {}, .surface = {}};
|
||||
CHECK(std::format("{}", empty) == "Solution(n=5, samples=[], surface=none)");
|
||||
CHECK(std::format("{:theta}", empty) == "Solution(n=5, samples=[], surface=none)");
|
||||
}
|
||||
|
||||
TEST_CASE("Lane Emden formatters preserve surrounding output and independent selectors", "[laneemden][formatting]") {
|
||||
const auto solution = example_solution();
|
||||
const std::string expected = "prefix Solution(n=1, samples=[1, 0.5], surface=0) | 2 | Solution(n=1, samples=[0, -0.25], surface=-0.5) suffix";
|
||||
std::string appended = "prefix ";
|
||||
std::format_to(std::back_inserter(appended), "{:theta} | {:xi} | {:derivative} suffix", solution, sample, solution);
|
||||
CHECK(appended == expected);
|
||||
std::array<char, 512> buffer{};
|
||||
const auto end = std::format_to(buffer.data(), "prefix {:theta} | {:xi} | {:derivative} suffix", solution, sample, solution);
|
||||
CHECK(std::string(buffer.data(), end) == expected);
|
||||
}
|
||||
|
||||
TEST_CASE("Lane Emden formatters reject unsupported selectors through the central error helper", "[laneemden][formatting]") {
|
||||
const auto solution = example_solution();
|
||||
for (const std::string specifier : {"radius", "xi,theta", "theta ", ".2f", "theta:.2f"}) {
|
||||
CAPTURE(specifier);
|
||||
const std::string format = "{:" + specifier + "}";
|
||||
CHECK_THROWS_AS(std::vformat(format, std::make_format_args(sample)), std::format_error);
|
||||
CHECK_THROWS_AS(std::vformat(format, std::make_format_args(solution)), std::format_error);
|
||||
CHECK_THROWS_WITH(std::vformat(format, std::make_format_args(sample)), Catch::Matchers::ContainsSubstring("Unknown Lane–Emden sample selector"));
|
||||
}
|
||||
CHECK_THROWS_AS(std::vformat("{:xi", std::make_format_args(sample)), std::format_error);
|
||||
CHECK_THROWS_AS(std::vformat("{:theta", std::make_format_args(solution)), std::format_error);
|
||||
}
|
||||
152
tests/utils/laneemden/scaling.cpp
Normal file
152
tests/utils/laneemden/scaling.cpp
Normal file
@@ -0,0 +1,152 @@
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <numbers>
|
||||
|
||||
#include <catch2/catch_approx.hpp>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "serif/utils/laneemden/laneemden.hpp"
|
||||
|
||||
namespace {
|
||||
namespace laneemden = serif::utils::laneemden;
|
||||
namespace dimensions = serif::dimensions;
|
||||
using Mass = dimensions::QuantityValue<dimensions::Mass>;
|
||||
using Density = dimensions::QuantityValue<dimensions::MassDensity>;
|
||||
using Length = dimensions::QuantityValue<dimensions::Length>;
|
||||
using Enthalpy = dimensions::QuantityValue<dimensions::SpecificEnthalpy>;
|
||||
using GravityConstant = dimensions::QuantityValue<dimensions::GravitationalConstant>;
|
||||
}
|
||||
|
||||
TEST_CASE("Lane–Emden scaling prescriptions describe the same physical polytrope", "[laneemden][scaling]") {
|
||||
for (double n : {0.5, 1.0, 1.5, 3.0}) {
|
||||
const auto solution = laneemden::solve(n);
|
||||
REQUIRE(solution);
|
||||
const auto mass_scales = laneemden::scale_from_mass(*solution, Mass{2.0}, Density{3.0}, GravityConstant{0.7});
|
||||
REQUIRE(mass_scales);
|
||||
CHECK(mass_scales->mass.value() == Catch::Approx(2.0).epsilon(1e-14));
|
||||
REQUIRE(mass_scales->polytropic_constant);
|
||||
const auto enthalpy_scales = laneemden::scale_from_enthalpy(*solution, mass_scales->central_enthalpy, Density{3.0}, GravityConstant{0.7});
|
||||
const auto eos_scales = laneemden::scale_from_polytropic_constant(*solution, *mass_scales->polytropic_constant, Density{3.0}, GravityConstant{0.7});
|
||||
REQUIRE(enthalpy_scales);
|
||||
REQUIRE(eos_scales);
|
||||
CHECK(enthalpy_scales->radius.value() == Catch::Approx(mass_scales->radius.value()).epsilon(1e-14));
|
||||
CHECK(eos_scales->radius.value() == Catch::Approx(mass_scales->radius.value()).epsilon(1e-14));
|
||||
CHECK(eos_scales->mass.value() == Catch::Approx(2.0).epsilon(1e-14));
|
||||
for (double fraction : {0.0, 0.1, 0.5, 0.9}) {
|
||||
const auto fields = laneemden::evaluate_physical(*solution, *mass_scales, Length{fraction * mass_scales->radius.value()});
|
||||
REQUIRE(fields);
|
||||
CHECK(fields->pressure.value() == Catch::Approx(*mass_scales->polytropic_constant * std::pow(fields->density.value(), 1.0 + 1.0 / n)).epsilon(2e-13));
|
||||
CHECK(fields->enthalpy.value() == Catch::Approx((n + 1.0) * fields->pressure.value() / fields->density.value()).epsilon(2e-13));
|
||||
CHECK(fields->potential.value() + fields->enthalpy.value() == Catch::Approx(-0.7 * 2.0 / mass_scales->radius.value()).epsilon(2e-13));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("The n equals three EOS mass is independent of central density", "[laneemden][scaling]") {
|
||||
const auto solution = laneemden::solve(3.0);
|
||||
REQUIRE(solution);
|
||||
const auto first = laneemden::scale_from_polytropic_constant(*solution, 2.0, Density{1.0}, GravityConstant{0.7});
|
||||
const auto second = laneemden::scale_from_polytropic_constant(*solution, 2.0, Density{8.0}, GravityConstant{0.7});
|
||||
REQUIRE(first);
|
||||
REQUIRE(second);
|
||||
CHECK(second->mass.value() == Catch::Approx(first->mass.value()).epsilon(1e-14));
|
||||
CHECK(second->radius.value() == Catch::Approx(0.5 * first->radius.value()).epsilon(1e-14));
|
||||
CHECK(second->polytropic_constant == first->polytropic_constant);
|
||||
}
|
||||
|
||||
TEST_CASE("Physical incompressible polytropes recover uniform sphere fields", "[laneemden][scaling][analytic]") {
|
||||
const auto solution = laneemden::solve(0.0);
|
||||
REQUIRE(solution);
|
||||
const auto scales = laneemden::scale_from_mass(*solution, Mass{2.0}, Density{3.0}, GravityConstant{0.7});
|
||||
REQUIRE(scales);
|
||||
CHECK_FALSE(scales->polytropic_constant);
|
||||
const double surface_radius = std::cbrt(3.0 * 2.0 / (4.0 * std::numbers::pi * 3.0));
|
||||
CHECK(scales->radius.value() == Catch::Approx(surface_radius).epsilon(2e-11));
|
||||
for (double fraction : {0.0, 0.01, 0.2, 0.6, 0.99}) {
|
||||
const double radius = fraction * scales->radius.value();
|
||||
const auto fields = laneemden::evaluate_physical(*solution, *scales, Length{radius});
|
||||
REQUIRE(fields);
|
||||
CHECK(fields->density.value() == 3.0);
|
||||
CHECK(fields->enclosed_mass.value() == Catch::Approx(2.0 * fraction * fraction * fraction).margin(1e-10));
|
||||
CHECK(fields->gravity.value() == Catch::Approx(4.0 * std::numbers::pi * 0.7 * radius).margin(1e-10));
|
||||
const double enthalpy = 2.0 * std::numbers::pi * 0.7 * (surface_radius * surface_radius - radius * radius);
|
||||
CHECK(fields->enthalpy.value() == Catch::Approx(enthalpy).margin(1e-10));
|
||||
CHECK(fields->pressure.value() == Catch::Approx(3.0 * enthalpy).margin(3e-10));
|
||||
const double potential = -0.7 * 2.0 * (3.0 - fraction * fraction) / (2.0 * surface_radius);
|
||||
CHECK(fields->potential.value() == Catch::Approx(potential).margin(1e-10));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Physical profiles match the vacuum exterior continuously", "[laneemden][scaling]") {
|
||||
const auto solution = laneemden::solve(1.5);
|
||||
REQUIRE(solution);
|
||||
const auto scales = laneemden::scale_from_mass(*solution, Mass{2.0}, Density{3.0}, GravityConstant{0.7});
|
||||
REQUIRE(scales);
|
||||
for (double fraction : {1.0, 1.5, 100.0}) {
|
||||
const double radius = fraction * scales->radius.value();
|
||||
const auto fields = laneemden::evaluate_physical(*solution, *scales, Length{radius});
|
||||
REQUIRE(fields);
|
||||
CHECK(fields->density.value() == 0.0);
|
||||
CHECK(fields->pressure.value() == 0.0);
|
||||
CHECK(fields->enthalpy.value() == 0.0);
|
||||
CHECK(fields->enclosed_mass.value() == Catch::Approx(2.0));
|
||||
CHECK(fields->potential.value() == Catch::Approx(-1.4 / radius));
|
||||
CHECK(fields->gravity.value() == Catch::Approx(1.4 / (radius * radius)));
|
||||
}
|
||||
const auto inside = laneemden::evaluate_physical(*solution, *scales, Length{scales->radius.value() * (1.0 - 1e-8)});
|
||||
const auto surface = laneemden::evaluate_physical(*solution, *scales, scales->radius);
|
||||
REQUIRE(inside);
|
||||
REQUIRE(surface);
|
||||
CHECK(inside->potential.value() == Catch::Approx(surface->potential.value()).epsilon(3e-8));
|
||||
CHECK(inside->gravity.value() == Catch::Approx(surface->gravity.value()).epsilon(3e-8));
|
||||
CHECK(inside->enclosed_mass.value() == Catch::Approx(surface->enclosed_mass.value()).epsilon(3e-8));
|
||||
}
|
||||
|
||||
TEST_CASE("Physical scaling is covariant between metre kilogram and centimetre gram units", "[laneemden][scaling][units]") {
|
||||
const auto solution = laneemden::solve(3.0);
|
||||
REQUIRE(solution);
|
||||
const auto metres = laneemden::scale_from_mass(*solution, Mass{2e30}, Density{1e5}, GravityConstant{6.6743e-11});
|
||||
const auto centimetres = laneemden::scale_from_mass(*solution, Mass{2e33}, Density{1e2}, GravityConstant{6.6743e-8});
|
||||
REQUIRE(metres);
|
||||
REQUIRE(centimetres);
|
||||
CHECK(centimetres->radius.value() == Catch::Approx(100.0 * metres->radius.value()).epsilon(1e-14));
|
||||
CHECK(centimetres->central_enthalpy.value() == Catch::Approx(1e4 * metres->central_enthalpy.value()).epsilon(1e-14));
|
||||
for (double fraction : {0.0, 0.3, 0.9, 1.0, 3.0}) {
|
||||
const auto first = laneemden::evaluate_physical(*solution, *metres, metres->radius * fraction);
|
||||
const auto second = laneemden::evaluate_physical(*solution, *centimetres, centimetres->radius * fraction);
|
||||
REQUIRE(first);
|
||||
REQUIRE(second);
|
||||
CHECK(second->density.value() == Catch::Approx(1e-3 * first->density.value()).margin(1e-12));
|
||||
CHECK(second->pressure.value() == Catch::Approx(10.0 * first->pressure.value()).epsilon(1e-13));
|
||||
CHECK(second->enthalpy.value() == Catch::Approx(1e4 * first->enthalpy.value()).epsilon(1e-13));
|
||||
CHECK(second->potential.value() == Catch::Approx(1e4 * first->potential.value()).epsilon(1e-13));
|
||||
CHECK(second->enclosed_mass.value() == Catch::Approx(1e3 * first->enclosed_mass.value()).epsilon(1e-13));
|
||||
CHECK(second->gravity.value() == Catch::Approx(100.0 * first->gravity.value()).epsilon(1e-13));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Physical scaling rejects invalid data and finite interval profiles without surfaces", "[laneemden][scaling][rejection]") {
|
||||
const auto solution = laneemden::solve(1.0);
|
||||
const auto infinite = laneemden::solve(5.0, {.maximum_radius = 2.0});
|
||||
REQUIRE(solution);
|
||||
REQUIRE(infinite);
|
||||
CHECK_FALSE(laneemden::scale_from_mass(*infinite, Mass{1.0}, Density{1.0}, GravityConstant{1.0}));
|
||||
CHECK_FALSE(laneemden::scale_from_enthalpy(*infinite, Enthalpy{1.0}, Density{1.0}, GravityConstant{1.0}));
|
||||
for (double invalid : {0.0, -1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
|
||||
CHECK_FALSE(laneemden::scale_from_mass(*solution, Mass{invalid}, Density{1.0}, GravityConstant{1.0}));
|
||||
CHECK_FALSE(laneemden::scale_from_mass(*solution, Mass{1.0}, Density{invalid}, GravityConstant{1.0}));
|
||||
CHECK_FALSE(laneemden::scale_from_mass(*solution, Mass{1.0}, Density{1.0}, GravityConstant{invalid}));
|
||||
CHECK_FALSE(laneemden::scale_from_enthalpy(*solution, Enthalpy{invalid}, Density{1.0}, GravityConstant{1.0}));
|
||||
CHECK_FALSE(laneemden::scale_from_polytropic_constant(*solution, invalid, Density{1.0}, GravityConstant{1.0}));
|
||||
}
|
||||
const auto incompressible = laneemden::solve(0.0);
|
||||
REQUIRE(incompressible);
|
||||
CHECK_FALSE(laneemden::scale_from_polytropic_constant(*incompressible, 1.0, Density{1.0}, GravityConstant{1.0}));
|
||||
const auto scales = laneemden::scale_from_mass(*solution, Mass{1.0}, Density{1.0}, GravityConstant{1.0});
|
||||
REQUIRE(scales);
|
||||
for (double invalid : {-1.0, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()}) {
|
||||
CHECK_FALSE(laneemden::evaluate_physical(*solution, *scales, Length{invalid}));
|
||||
}
|
||||
CHECK_FALSE(laneemden::evaluate_physical(*incompressible, *scales, Length{0.0}));
|
||||
CHECK_FALSE(laneemden::scale_from_enthalpy(*solution, Enthalpy{1e308}, Density{1e-300}, GravityConstant{1e-300}));
|
||||
}
|
||||
155
tests/utils/laneemden/solution.cpp
Normal file
155
tests/utils/laneemden/solution.cpp
Normal file
@@ -0,0 +1,155 @@
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <numbers>
|
||||
|
||||
#include <catch2/catch_approx.hpp>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "serif/utils/laneemden/solution.hpp"
|
||||
|
||||
namespace {
|
||||
namespace laneemden = serif::utils::laneemden;
|
||||
|
||||
/** @brief The three analytic regular-center Lane–Emden profiles and their derivatives. */
|
||||
laneemden::Sample analytic(double n, double xi) {
|
||||
if (n == 0.0) {
|
||||
return {.xi = xi, .theta = 1.0 - xi * xi / 6.0, .derivative = -xi / 3.0};
|
||||
}
|
||||
if (n == 1.0) {
|
||||
if (xi == 0.0) {
|
||||
return {.xi = 0.0, .theta = 1.0, .derivative = 0.0};
|
||||
}
|
||||
return {.xi = xi, .theta = std::sin(xi) / xi, .derivative = (xi * std::cos(xi) - std::sin(xi)) / (xi * xi)};
|
||||
}
|
||||
const double denominator = 1.0 + xi * xi / 3.0;
|
||||
return {.xi = xi, .theta = 1.0 / std::sqrt(denominator), .derivative = -xi / (3.0 * std::pow(denominator, 1.5))};
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Lane–Emden matches all regular-center analytic solutions", "[laneemden][analytic]") {
|
||||
for (double n : {0.0, 1.0, 5.0}) {
|
||||
CAPTURE(n);
|
||||
const auto solution = laneemden::solve(n, {.maximum_radius = 20.0});
|
||||
REQUIRE(solution);
|
||||
REQUIRE(solution->samples.front().xi == 0.0);
|
||||
REQUIRE(solution->samples.front().theta == 1.0);
|
||||
REQUIRE(solution->samples.front().derivative == 0.0);
|
||||
const double endpoint = solution->samples.back().xi;
|
||||
for (int index = 0; index <= 400; ++index) {
|
||||
const double xi = endpoint * static_cast<double>(index) / 400.0;
|
||||
const auto actual = laneemden::evaluate(*solution, xi);
|
||||
REQUIRE(actual);
|
||||
const laneemden::Sample exact = analytic(n, xi);
|
||||
CHECK(actual->theta == Catch::Approx(exact.theta).margin(3e-10));
|
||||
CHECK(actual->derivative == Catch::Approx(exact.derivative).margin(3e-9));
|
||||
}
|
||||
if (n < 5.0) {
|
||||
REQUIRE(solution->surface);
|
||||
const double exact_radius = n == 0.0 ? std::sqrt(6.0) : std::numbers::pi;
|
||||
CHECK(solution->surface->xi == Catch::Approx(exact_radius).margin(2e-9));
|
||||
CHECK(solution->surface->derivative == Catch::Approx(analytic(n, exact_radius).derivative).margin(2e-9));
|
||||
CHECK(solution->samples.back().theta == 0.0);
|
||||
} else {
|
||||
CHECK_FALSE(solution->surface);
|
||||
CHECK(endpoint == 20.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Lane–Emden advances the coupled system at fourth order", "[laneemden][convergence]") {
|
||||
double previous_error = 0.0;
|
||||
for (double step : {0.08, 0.04, 0.02, 0.01}) {
|
||||
const auto solution = laneemden::solve(1.0, {.step = step, .maximum_radius = 2.0});
|
||||
REQUIRE(solution);
|
||||
const double error = std::abs(solution->samples.back().theta - analytic(1.0, 2.0).theta);
|
||||
if (previous_error > 0.0) {
|
||||
CHECK(previous_error / error > 14.0);
|
||||
CHECK(previous_error / error < 18.0);
|
||||
}
|
||||
previous_error = error;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Lane–Emden finds stellar surfaces for fractional and centrally concentrated profiles", "[laneemden][surface]") {
|
||||
struct Reference final {
|
||||
double n;
|
||||
double radius;
|
||||
double mass;
|
||||
double mass_tolerance;
|
||||
};
|
||||
// Standard first-zero values provide an independent endpoint check beyond the analytic indices.
|
||||
for (const Reference reference : {
|
||||
Reference{.n = 0.5, .radius = 2.752698054065, .mass = 3.78865118488, .mass_tolerance = 2e-8},
|
||||
Reference{.n = 1.5, .radius = 3.653753736219, .mass = 2.714055120109, .mass_tolerance = 2e-9},
|
||||
Reference{.n = 3.0, .radius = 6.896848619377, .mass = 2.018235950966, .mass_tolerance = 2e-9}}) {
|
||||
CAPTURE(reference.n);
|
||||
const auto solution = laneemden::solve(reference.n, {.step = 0.0005});
|
||||
REQUIRE(solution);
|
||||
REQUIRE(solution->surface);
|
||||
const laneemden::Sample surface = *solution->surface;
|
||||
CHECK(surface.xi == Catch::Approx(reference.radius).margin(2e-9));
|
||||
CHECK(-surface.xi * surface.xi * surface.derivative == Catch::Approx(reference.mass).margin(reference.mass_tolerance));
|
||||
for (std::size_t index = 1; index < solution->samples.size(); ++index) {
|
||||
const laneemden::Sample &previous = solution->samples[index - 1];
|
||||
const laneemden::Sample ¤t = solution->samples[index];
|
||||
REQUIRE(current.xi > previous.xi);
|
||||
REQUIRE(current.theta >= 0.0);
|
||||
REQUIRE(current.theta < previous.theta);
|
||||
REQUIRE(std::isfinite(current.derivative));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Fractional-index surface values converge without taking nonreal powers", "[laneemden][convergence]") {
|
||||
double previous_error = 0.0;
|
||||
for (double step : {0.04, 0.02, 0.01, 0.005}) {
|
||||
const auto solution = laneemden::solve(0.5, {.step = step});
|
||||
REQUIRE(solution);
|
||||
REQUIRE(solution->surface);
|
||||
const laneemden::Sample surface = *solution->surface;
|
||||
const double error = std::abs(-surface.xi * surface.xi * surface.derivative - 3.78865118488);
|
||||
if (previous_error > 0.0) {
|
||||
CHECK(error < 0.45 * previous_error);
|
||||
}
|
||||
previous_error = error;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Lane–Emden distinguishes finite interval profiles from failures", "[laneemden]") {
|
||||
for (double n : {1.0, 5.0, 6.0}) {
|
||||
const auto solution = laneemden::solve(n, {.maximum_radius = 1.0});
|
||||
REQUIRE(solution);
|
||||
CHECK_FALSE(solution->surface);
|
||||
CHECK(solution->samples.back().xi == 1.0);
|
||||
CHECK(solution->samples.back().theta > 0.0);
|
||||
}
|
||||
const auto exhausted = laneemden::solve(3.0, {.maximum_steps = 2});
|
||||
CHECK_FALSE(exhausted);
|
||||
const auto tiny_interval = laneemden::solve(1.0, {.maximum_radius = 1e-6});
|
||||
REQUIRE(tiny_interval);
|
||||
CHECK(tiny_interval->samples.size() == 2);
|
||||
CHECK(tiny_interval->samples.back().derivative == Catch::Approx(-1e-6 / 3.0).margin(1e-18));
|
||||
}
|
||||
|
||||
TEST_CASE("Lane–Emden rejects invalid controls and evaluation radii", "[laneemden][rejection]") {
|
||||
const double infinity = std::numeric_limits<double>::infinity();
|
||||
const double nan = std::numeric_limits<double>::quiet_NaN();
|
||||
for (double invalid : {-1.0, infinity, nan}) {
|
||||
CHECK_FALSE(laneemden::solve(invalid));
|
||||
}
|
||||
for (double invalid : {0.0, -1.0, infinity, nan}) {
|
||||
CHECK_FALSE(laneemden::solve(1.0, {.step = invalid}));
|
||||
CHECK_FALSE(laneemden::solve(1.0, {.maximum_radius = invalid}));
|
||||
CHECK_FALSE(laneemden::solve(1.0, {.surface_tolerance = invalid}));
|
||||
}
|
||||
CHECK_FALSE(laneemden::solve(1.0, {.maximum_steps = 0}));
|
||||
CHECK_FALSE(laneemden::solve(1.0, {.surface_tolerance = 0.01}));
|
||||
CHECK_FALSE(laneemden::solve(1.0, {.central_series_terms = 5}));
|
||||
CHECK_FALSE(laneemden::solve(1.0, {.central_series_terms = 65}));
|
||||
const auto solution = laneemden::solve(1.0);
|
||||
REQUIRE(solution);
|
||||
for (double invalid : {-1.0, infinity, nan, 4.0}) {
|
||||
CHECK_FALSE(laneemden::evaluate(*solution, invalid));
|
||||
}
|
||||
CHECK_FALSE(laneemden::evaluate(laneemden::Solution{.n = 1.0, .samples = {}, .surface = {}}, 0.0));
|
||||
}
|
||||
36
tests/utils/meson.build
Normal file
36
tests/utils/meson.build
Normal file
@@ -0,0 +1,36 @@
|
||||
dependency_test_sources = files(
|
||||
'dependencies/ownership.cpp',
|
||||
'dependencies/context.cpp',
|
||||
'dependencies/preparation.cpp',
|
||||
'dependencies/snapshots.cpp',
|
||||
'dependencies/rejection.cpp',
|
||||
'dependencies/mixed_errors.cpp',
|
||||
'dependencies/reachability.cpp',
|
||||
'dependencies/observers.cpp',
|
||||
)
|
||||
|
||||
# The dependency machinery must remain usable without linking MFEM or SERiF.
|
||||
serif_dependency_tests = executable(
|
||||
'serif_dependency_tests',
|
||||
dependency_test_sources,
|
||||
include_directories: include_dir,
|
||||
dependencies: [catch2_dep, magic_enum_dep, nameof_dep],
|
||||
)
|
||||
test('serif_dependency_tests', serif_dependency_tests, suite: 'unit', timeout: 30)
|
||||
|
||||
radial_test_sources = files(
|
||||
'laneemden/solution.cpp',
|
||||
'laneemden/formatting.cpp',
|
||||
'laneemden/scaling.cpp',
|
||||
'projection/radial.cpp',
|
||||
'projection/nonconforming.cpp',
|
||||
)
|
||||
|
||||
serif_radial_tests = executable(
|
||||
'serif_radial_tests',
|
||||
radial_test_sources,
|
||||
dependencies: [serif_dep, catch2_dep, stroid_dep],
|
||||
include_directories: include_directories('../include'),
|
||||
build_rpath: mfem_runtime_prefix == '' ? '' : mfem_runtime_prefix / 'lib',
|
||||
)
|
||||
test('serif_radial_tests', serif_radial_tests, suite: 'unit', timeout: 60)
|
||||
103
tests/utils/projection/nonconforming.cpp
Normal file
103
tests/utils/projection/nonconforming.cpp
Normal file
@@ -0,0 +1,103 @@
|
||||
#include <cmath>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "mfem/fem/coefficient.hpp"
|
||||
#include "mfem/mesh/mesh.hpp"
|
||||
#include "serif/backend/mfem/projection/radial.hpp"
|
||||
|
||||
namespace {
|
||||
namespace projection = serif::utils::projection::backend::mfem;
|
||||
|
||||
/** @brief Refine one of two adjacent cells so the shared edge has constrained child coefficients. */
|
||||
[[nodiscard]] mfem::Mesh nonconforming_mesh() {
|
||||
mfem::Mesh mesh = mfem::Mesh::MakeCartesian2D(2, 1, mfem::Element::QUADRILATERAL, true, 2.0, 1.0);
|
||||
mesh.EnsureNCMesh();
|
||||
mfem::Array<int> selected(1);
|
||||
selected[0] = 0;
|
||||
mesh.GeneralRefinement(selected);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
/** @brief Independently measure the distance from the true finite-element subspace through its constraint matrices. */
|
||||
[[nodiscard]] double constraint_defect(const mfem::GridFunction& field) {
|
||||
const mfem::FiniteElementSpace& space = *field.FESpace();
|
||||
const mfem::SparseMatrix* restriction = space.GetConformingRestriction();
|
||||
const mfem::SparseMatrix* prolongation = space.GetConformingProlongation();
|
||||
REQUIRE(restriction != nullptr);
|
||||
REQUIRE(prolongation != nullptr);
|
||||
REQUIRE(space.GetTrueVSize() < space.GetVSize());
|
||||
mfem::Vector true_values(restriction->Height()), constrained(space.GetVSize());
|
||||
restriction->Mult(field, true_values);
|
||||
prolongation->Mult(true_values, constrained);
|
||||
constrained -= field;
|
||||
return constrained.Norml2();
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Radial scalar projection satisfies hanging-node constraints after nonlinear sampling", "[projection][nonconforming]") {
|
||||
mfem::Mesh mesh = nonconforming_mesh();
|
||||
mfem::H1_FECollection collection(1, 2);
|
||||
mfem::FiniteElementSpace space(&mesh, &collection);
|
||||
mfem::FunctionCoefficient coefficient([](const mfem::Vector& position) {
|
||||
return position * position;
|
||||
});
|
||||
mfem::GridFunction native(&space);
|
||||
native.ProjectCoefficient(coefficient);
|
||||
REQUIRE(constraint_defect(native) > 0.1);
|
||||
const auto projected = projection::project_scalar(space, [](double radius) {
|
||||
return radius * radius;
|
||||
});
|
||||
REQUIRE(projected);
|
||||
CHECK(constraint_defect(*projected) < 1.0e-13);
|
||||
}
|
||||
|
||||
TEST_CASE("Radial vector projection satisfies hanging-node and component constraints", "[projection][nonconforming]") {
|
||||
mfem::Mesh mesh = nonconforming_mesh();
|
||||
mfem::H1_FECollection vector_collection(1, 2);
|
||||
mfem::VectorFunctionCoefficient coefficient(2, [](const mfem::Vector& position, mfem::Vector& value) {
|
||||
value = position;
|
||||
value *= position * position;
|
||||
});
|
||||
const auto check = [&](mfem::FiniteElementSpace& space) {
|
||||
mfem::GridFunction native(&space);
|
||||
native.ProjectCoefficient(coefficient);
|
||||
REQUIRE(constraint_defect(native) > 1.0e-3);
|
||||
const auto projected = projection::project_vector(space, [](double radius) {
|
||||
return radius * radius * radius;
|
||||
});
|
||||
REQUIRE(projected);
|
||||
CHECK(constraint_defect(*projected) < 1.0e-13);
|
||||
};
|
||||
for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM}) {
|
||||
mfem::FiniteElementSpace vector(&mesh, &vector_collection, 2, ordering);
|
||||
check(vector);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Constrained radial interpolation retains representable scalar fields and exact vector divergence", "[projection][nonconforming][math]") {
|
||||
mfem::Mesh mesh = nonconforming_mesh();
|
||||
mfem::H1_FECollection scalar_collection(2, 2);
|
||||
mfem::H1_FECollection vector_collection(1, 2);
|
||||
mfem::FiniteElementSpace scalar_space(&mesh, &scalar_collection), flux_space(&mesh, &vector_collection, 2);
|
||||
const auto scalar = projection::project_scalar(scalar_space, [](double radius) {
|
||||
return radius * radius;
|
||||
});
|
||||
const auto flux = projection::project_vector(flux_space, [](double radius) {
|
||||
return radius;
|
||||
});
|
||||
REQUIRE(scalar);
|
||||
REQUIRE(flux);
|
||||
mfem::FunctionCoefficient exact_scalar([](const mfem::Vector& position) {
|
||||
return position * position;
|
||||
});
|
||||
mfem::VectorFunctionCoefficient exact_vector(2, [](const mfem::Vector& position, mfem::Vector& value) {
|
||||
value = position;
|
||||
});
|
||||
mfem::ConstantCoefficient divergence(2.0);
|
||||
CHECK(scalar->ComputeL2Error(exact_scalar) < 2.0e-13);
|
||||
CHECK(flux->ComputeL2Error(exact_vector) < 2.0e-13);
|
||||
CHECK(flux->ComputeDivError(&divergence) < 2.0e-13);
|
||||
CHECK(constraint_defect(*scalar) < 1.0e-13);
|
||||
CHECK(constraint_defect(*flux) < 1.0e-13);
|
||||
}
|
||||
423
tests/utils/projection/radial.cpp
Normal file
423
tests/utils/projection/radial.cpp
Normal file
@@ -0,0 +1,423 @@
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <expected>
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <numbers>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "mfem/fem/coefficient.hpp"
|
||||
#include "serif/backend/mfem/mapping/compactification/kelvin.hpp"
|
||||
#include "serif/dimensions/type_alias.hpp"
|
||||
#include "serif/backend/mfem/mapping/evaluator.hpp"
|
||||
#include "serif/utils/laneemden/laneemden.hpp"
|
||||
#include "serif/utils/projection/projection.hpp"
|
||||
#include "serif/tests/deformation/mesh.hpp"
|
||||
#include "serif/backend/mfem/mapping/transformations.hpp"
|
||||
|
||||
namespace {
|
||||
namespace projection = serif::utils::projection;
|
||||
namespace backend = projection::backend::mfem;
|
||||
namespace laneemden = serif::utils::laneemden;
|
||||
namespace dimensions = serif::dimensions;
|
||||
namespace mapping = serif::mapping;
|
||||
|
||||
/** @brief Resolve radius without sharing the production sampler's distance implementation. */
|
||||
[[nodiscard]] double radius_squared(const mfem::Vector& point, const std::array<double, 3>& center) {
|
||||
double squared = 0.0;
|
||||
for (int component = 0; component < 3; ++component) {
|
||||
squared += std::pow(point[component] - center[component], 2);
|
||||
}
|
||||
return squared;
|
||||
}
|
||||
|
||||
/** @brief Check physical reconstruction through independent quadrature. */
|
||||
void check_linear_vector(mfem::FiniteElementSpace& space, const std::array<double, 3>& center) {
|
||||
const auto result = backend::project_vector(space, [](const double radius) { return 2.0 * radius; }, {.center = center});
|
||||
REQUIRE(result);
|
||||
mfem::VectorFunctionCoefficient exact(3, [center](const mfem::Vector& point, mfem::Vector& value) {
|
||||
value.SetSize(3);
|
||||
for (int component = 0; component < 3; ++component) {
|
||||
value[component] = 2.0 * (point[component] - center[component]);
|
||||
}
|
||||
});
|
||||
CHECK(result->ComputeL2Error(exact) < 2.0e-12);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Radial scalar projection reproduces physical polynomials in H1 and L2", "[unit][projection]") {
|
||||
const std::array<double, 3> center{0.5, 0.25, 0.75};
|
||||
for (const mfem::Element::Type geometry : {mfem::Element::HEXAHEDRON, mfem::Element::TETRAHEDRON}) {
|
||||
mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 2, 2, geometry);
|
||||
mfem::H1_FECollection h1(2, 3);
|
||||
mfem::L2_FECollection l2(2, 3);
|
||||
mfem::L2_FECollection integral_l2(2, 3, mfem::BasisType::GaussLegendre, mfem::FiniteElement::INTEGRAL);
|
||||
for (mfem::FiniteElementCollection* collection : std::array<mfem::FiniteElementCollection*, 3>{&h1, &l2, &integral_l2}) {
|
||||
mfem::FiniteElementSpace space(&mesh, collection);
|
||||
const auto result = backend::project_scalar(space, [](const double radius) {
|
||||
return 3.0 - radius * radius;
|
||||
}, {.center = center});
|
||||
REQUIRE(result);
|
||||
CHECK(result->FESpace() == &space);
|
||||
mfem::FunctionCoefficient exact([center](const mfem::Vector& point) {
|
||||
return 3.0 - radius_squared(point, center);
|
||||
});
|
||||
CHECK(result->ComputeL2Error(exact) < 2.0e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Radial vectors preserve component order", "[unit][projection]") {
|
||||
for (const mfem::Element::Type geometry : {mfem::Element::HEXAHEDRON, mfem::Element::TETRAHEDRON}) {
|
||||
mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 2, 2, geometry);
|
||||
mfem::H1_FECollection h1(1, 3);
|
||||
mfem::L2_FECollection l2(1, 3);
|
||||
for (const mfem::Ordering::Type order : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM}) {
|
||||
mfem::FiniteElementSpace continuous(&mesh, &h1, 3, order);
|
||||
mfem::FiniteElementSpace discontinuous(&mesh, &l2, 3, order);
|
||||
check_linear_vector(continuous, {0.5, 0.5, 0.5});
|
||||
check_linear_vector(continuous, {0.0, 0.0, 0.0});
|
||||
check_linear_vector(discontinuous, {0.5, 0.5, 0.5});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Radial projection samples curved physical mesh coordinates", "[unit][projection]") {
|
||||
mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 2, 2, mfem::Element::HEXAHEDRON);
|
||||
mesh.SetCurvature(2);
|
||||
mesh.Transform([](const mfem::Vector& point, mfem::Vector& image) {
|
||||
image = point;
|
||||
image[1] += 0.1 * point[0] * point[0];
|
||||
image[2] *= 1.0 + 0.1 * point[0];
|
||||
});
|
||||
mfem::H1_FECollection h1(4, 3);
|
||||
mfem::FiniteElementSpace space(&mesh, &h1);
|
||||
const auto result = backend::project_scalar(space, [](const double radius) { return radius * radius; });
|
||||
REQUIRE(result);
|
||||
mfem::FunctionCoefficient exact([](const mfem::Vector& point) { return point * point; });
|
||||
CHECK(result->ComputeL2Error(exact) < 3.0e-12);
|
||||
}
|
||||
|
||||
TEST_CASE("Radial projection pulls physical values back through stellar mapping", "[unit][projection]") {
|
||||
mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 2, 2, mfem::Element::HEXAHEDRON);
|
||||
mfem::H1_FECollection h1(2, 3);
|
||||
mfem::FiniteElementSpace displacement_space(&mesh, &h1, 3);
|
||||
mfem::FiniteElementSpace scalar_space(&mesh, &h1);
|
||||
mfem::GridFunction displacement(&displacement_space);
|
||||
mfem::GridFunction compactification(&scalar_space);
|
||||
const std::array<double, 3> stretch{1.2, 0.8, 1.1};
|
||||
mfem::VectorFunctionCoefficient affine(3, [stretch](const mfem::Vector& point, mfem::Vector& value) {
|
||||
value.SetSize(3);
|
||||
for (int component = 0; component < 3; ++component) {
|
||||
value[component] = (stretch[component] - 1.0) * point[component];
|
||||
}
|
||||
});
|
||||
displacement.ProjectCoefficient(affine);
|
||||
compactification = 0.0;
|
||||
mapping::DomainMapper mapper(
|
||||
{.dimension = 3, .vacuum_element_attribute_ID = 3},
|
||||
std::make_unique<mapping::compactification::KelvinCompactification>(
|
||||
serif::utils::user_options::KelvinCompactificationOptions{.r_star_ref = 1.0, .r_inf_ref = 4.0}
|
||||
)
|
||||
);
|
||||
mapping::GridFunctionMappingEvaluator evaluator(mapper, displacement, compactification);
|
||||
CHECK(evaluator.mesh() == &mesh);
|
||||
// Equal element counts do not make geometry from another mesh interchangeable.
|
||||
mfem::Mesh other_mesh = mfem::Mesh::MakeCartesian3D(2, 2, 2, mfem::Element::HEXAHEDRON);
|
||||
mfem::FiniteElementSpace other_scalar(&other_mesh, &h1), other_vector(&other_mesh, &h1, 3);
|
||||
CHECK_THROWS_AS(backend::project_scalar(other_scalar, [](const double r) { return r; }, {.mapping = &evaluator}), std::invalid_argument);
|
||||
CHECK_THROWS_AS(backend::project_vector(other_vector, [](const double r) { return r; }, {.mapping = &evaluator}), std::invalid_argument);
|
||||
const auto scalar = backend::project_scalar(scalar_space, [](const double radius) { return radius * radius; }, {.mapping = &evaluator});
|
||||
REQUIRE(scalar);
|
||||
mfem::FunctionCoefficient exact_scalar([stretch](const mfem::Vector& point) {
|
||||
double value = 0.0;
|
||||
for (int component = 0; component < 3; ++component) {
|
||||
value += std::pow(stretch[component] * point[component], 2);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
CHECK(scalar->ComputeL2Error(exact_scalar) < 3.0e-12);
|
||||
|
||||
const auto vector = backend::project_vector(displacement_space, [](const double radius) { return 2.0 * radius; }, {.mapping = &evaluator});
|
||||
REQUIRE(vector);
|
||||
// Vector H1 values are physical: 2 x_phys = 2 A x_mesh.
|
||||
mfem::VectorFunctionCoefficient exact_vector(3, [stretch](const mfem::Vector& point, mfem::Vector& value) {
|
||||
value.SetSize(3);
|
||||
for (int component = 0; component < 3; ++component) {
|
||||
value[component] = 2.0 * stretch[component] * point[component];
|
||||
}
|
||||
});
|
||||
CHECK(vector->ComputeL2Error(exact_vector) < 3.0e-12);
|
||||
const double determinant = stretch[0] * stretch[1] * stretch[2];
|
||||
|
||||
mfem::L2_FECollection integral_l2(2, 3, mfem::BasisType::GaussLegendre, mfem::FiniteElement::INTEGRAL);
|
||||
mfem::FiniteElementSpace density_space(&mesh, &integral_l2);
|
||||
const auto density = backend::project_scalar(density_space, [](const double) { return 2.0; }, {.mapping = &evaluator});
|
||||
REQUIRE(density);
|
||||
mfem::ConstantCoefficient exact_density(2.0 * determinant);
|
||||
CHECK(density->ComputeL2Error(exact_density) < 3.0e-12);
|
||||
|
||||
displacement *= -6.0;
|
||||
evaluator.refresh();
|
||||
CHECK_FALSE(backend::project_scalar(scalar_space, [](const double radius) { return radius; }, {.mapping = &evaluator}));
|
||||
|
||||
displacement = 0.0;
|
||||
compactification = 1.0;
|
||||
for (int element = 0; element < mesh.GetNE(); ++element) {
|
||||
mesh.GetElement(element)->SetAttribute(3);
|
||||
}
|
||||
mesh.SetAttributes();
|
||||
evaluator.refresh();
|
||||
const auto infinite = backend::project_scalar(scalar_space, [](const double) { return 0.0; }, {.mapping = &evaluator});
|
||||
REQUIRE_FALSE(infinite);
|
||||
CHECK(infinite.error().find("compactified infinity") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("Lane Emden physical profiles project density enthalpy potential and gravity", "[unit][projection][laneemden]") {
|
||||
const auto solution = laneemden::solve(0.0);
|
||||
REQUIRE(solution);
|
||||
const auto scales = laneemden::scale_from_mass(*solution,
|
||||
dimensions::QuantityValue<dimensions::Mass>{3.0},
|
||||
dimensions::QuantityValue<dimensions::MassDensity>{2.0},
|
||||
dimensions::QuantityValue<dimensions::GravitationalConstant>{1.5});
|
||||
REQUIRE(scales);
|
||||
const double radius = scales->radius.value();
|
||||
const double central_enthalpy = scales->central_enthalpy.value();
|
||||
const double potential_at_surface = -1.5 * 3.0 / radius;
|
||||
const double extent = 0.5 * radius;
|
||||
mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 2, 2, mfem::Element::HEXAHEDRON, extent, extent, extent);
|
||||
mfem::H1_FECollection h1(2, 3);
|
||||
mfem::L2_FECollection l2(2, 3);
|
||||
mfem::FiniteElementSpace enthalpy_space(&mesh, &h1), density_space(&mesh, &l2), gravity_space(&mesh, &h1, 3);
|
||||
const auto field = [&](const double physical_radius) {
|
||||
return laneemden::evaluate_physical(*solution, *scales, dimensions::QuantityValue<dimensions::Length>{physical_radius});
|
||||
};
|
||||
const auto density = backend::project_scalar(density_space, [&](const double r) -> projection::RadialValue {
|
||||
return field(r).transform([](const auto& value) { return value.density.value(); });
|
||||
});
|
||||
const auto enthalpy = backend::project_scalar(enthalpy_space, [&](const double r) -> projection::RadialValue {
|
||||
return field(r).transform([](const auto& value) { return value.enthalpy.value(); });
|
||||
});
|
||||
const auto potential = backend::project_scalar(density_space, [&](const double r) -> projection::RadialValue {
|
||||
return field(r).transform([](const auto& value) { return value.potential.value(); });
|
||||
});
|
||||
const auto gravity = backend::project_vector(gravity_space, [&](const double r) -> projection::RadialValue {
|
||||
return field(r).transform([](const auto& value) { return value.gravity.value(); });
|
||||
});
|
||||
REQUIRE(density);
|
||||
REQUIRE(enthalpy);
|
||||
REQUIRE(potential);
|
||||
REQUIRE(gravity);
|
||||
mfem::ConstantCoefficient exact_density(2.0);
|
||||
mfem::FunctionCoefficient exact_enthalpy([=](const mfem::Vector& point) {
|
||||
return central_enthalpy * (1.0 - (point * point) / (radius * radius));
|
||||
});
|
||||
mfem::FunctionCoefficient exact_potential([=](const mfem::Vector& point) {
|
||||
return potential_at_surface - central_enthalpy * (1.0 - (point * point) / (radius * radius));
|
||||
});
|
||||
mfem::VectorFunctionCoefficient exact_gravity(3, [](const mfem::Vector& point, mfem::Vector& value) {
|
||||
value = point;
|
||||
value *= 4.0 * std::numbers::pi * 1.5 * 2.0 / 3.0;
|
||||
});
|
||||
CHECK(density->ComputeL2Error(exact_density) < 1.0e-11);
|
||||
CHECK(enthalpy->ComputeL2Error(exact_enthalpy) < 1.0e-9);
|
||||
CHECK(potential->ComputeL2Error(exact_potential) < 1.0e-9);
|
||||
CHECK(gravity->ComputeL2Error(exact_gravity) < 1.0e-9);
|
||||
}
|
||||
|
||||
TEST_CASE("Projection retains profile exterior choices and rejects invalid samples atomically", "[unit][projection]") {
|
||||
mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 2, 2, mfem::Element::HEXAHEDRON);
|
||||
mfem::H1_FECollection h1(1, 3);
|
||||
mfem::FiniteElementSpace scalar(&mesh, &h1), vector(&mesh, &h1, 3);
|
||||
const auto exterior = backend::project_scalar(scalar, [](const double radius) {
|
||||
return radius > 0.25 ? -7.0 : 3.0;
|
||||
});
|
||||
REQUIRE(exterior);
|
||||
CHECK(exterior->Min() == -7.0);
|
||||
CHECK(exterior->Max() == 3.0);
|
||||
for (const double invalid : {std::numeric_limits<double>::quiet_NaN(), std::numeric_limits<double>::infinity()}) {
|
||||
CHECK_FALSE(backend::project_scalar(scalar, [invalid](const double) { return invalid; }));
|
||||
CHECK_FALSE(backend::project_vector(vector, [invalid](const double) { return invalid; }));
|
||||
CHECK_FALSE(backend::project_scalar(scalar, [](const double r) { return r; }, {.center = {invalid, 0.0, 0.0}}));
|
||||
}
|
||||
CHECK_FALSE(backend::project_vector(vector, [](const double) { return 1.0; }));
|
||||
int successful_samples = 0;
|
||||
const auto failure = backend::project_scalar(scalar, [&](const double r) -> projection::RadialValue {
|
||||
if (r > 0.5) {
|
||||
return std::unexpected("profile is only available inside radius 0.5");
|
||||
}
|
||||
++successful_samples;
|
||||
return 2.0;
|
||||
});
|
||||
REQUIRE_FALSE(failure);
|
||||
CHECK(successful_samples > 0);
|
||||
CHECK(failure.error().find("profile is only available") != std::string::npos);
|
||||
const auto recovered = backend::project_scalar(scalar, [](const double) { return 4.0; });
|
||||
REQUIRE(recovered);
|
||||
CHECK(recovered->Min() == 4.0);
|
||||
CHECK(recovered->Max() == 4.0);
|
||||
CHECK_THROWS_AS(backend::project_scalar(vector, [](const double r) { return r; }), std::invalid_argument);
|
||||
CHECK_THROWS_AS(backend::project_vector(scalar, [](const double r) { return r; }), std::invalid_argument);
|
||||
}
|
||||
|
||||
TEST_CASE("Projected n one polytropes converge to their analytic radial fields", "[unit][projection][laneemden]") {
|
||||
const auto solution = laneemden::solve(1.0, {.step = 0.002});
|
||||
REQUIRE(solution);
|
||||
const auto scales = laneemden::scale_from_mass(*solution,
|
||||
dimensions::QuantityValue<dimensions::Mass>{2.0},
|
||||
dimensions::QuantityValue<dimensions::MassDensity>{1.0},
|
||||
dimensions::QuantityValue<dimensions::GravitationalConstant>{1.0});
|
||||
REQUIRE(scales);
|
||||
const double radial_scale = scales->radial_scale;
|
||||
double coarse_error = 0.0;
|
||||
for (const int count : {2, 4}) {
|
||||
const double extent = scales->radius.value() / std::sqrt(3.0);
|
||||
mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(count, count, count, mfem::Element::HEXAHEDRON, extent, extent, extent);
|
||||
mfem::H1_FECollection h1(2, 3);
|
||||
mfem::FiniteElementSpace space(&mesh, &h1);
|
||||
const auto density = backend::project_scalar(space, [&](const double radius) -> projection::RadialValue {
|
||||
return laneemden::evaluate_physical(*solution, *scales, dimensions::QuantityValue<dimensions::Length>{radius})
|
||||
.transform([](const auto& value) { return value.density.value(); });
|
||||
});
|
||||
REQUIRE(density);
|
||||
mfem::FunctionCoefficient exact([radial_scale](const mfem::Vector& point) {
|
||||
const double xi = point.Norml2() / radial_scale;
|
||||
if (xi == 0.0) {
|
||||
return 1.0;
|
||||
}
|
||||
return std::sin(xi) / xi;
|
||||
});
|
||||
const double error = density->ComputeL2Error(exact);
|
||||
if (count == 2) {
|
||||
coarse_error = error;
|
||||
CHECK(coarse_error > 1.0e-7);
|
||||
} else {
|
||||
CHECK(error < coarse_error / 6.0);
|
||||
CHECK(error < 2.0e-4 * std::pow(extent, 1.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Radial projection completes the compactified stellar mesh with explicit finite limits", "[unit][projection][laneemden][infinity]") {
|
||||
auto generated = serif::tests::deformation::make_mesh(0);
|
||||
mfem::H1_FECollection h1(2, 3);
|
||||
mfem::FiniteElementSpace scalar_space(generated.mesh.get(), &h1);
|
||||
mfem::FiniteElementSpace vector_space(generated.mesh.get(), &h1, 3);
|
||||
mfem::GridFunction displacement(&vector_space);
|
||||
displacement = 0.0;
|
||||
mapping::DomainMapper mapper(
|
||||
{.dimension = 3, .vacuum_element_attribute_ID = 3},
|
||||
std::make_unique<mapping::compactification::KelvinCompactification>(
|
||||
serif::utils::user_options::KelvinCompactificationOptions{.r_star_ref = 1.0, .r_inf_ref = 4.0}
|
||||
)
|
||||
);
|
||||
mapping::GridFunctionMappingEvaluator evaluator(mapper, displacement, *generated.exterior_coordinate->values);
|
||||
const auto solution = laneemden::solve(1.0, {.step = 0.002});
|
||||
REQUIRE(solution);
|
||||
// R = 1/2 leaves the entire curved exterior mesh in the analytic vacuum region.
|
||||
const auto scales = laneemden::scale_from_mass(*solution,
|
||||
dimensions::MassValue{2.0}, dimensions::DensityValue{4.0 * std::numbers::pi},
|
||||
dimensions::QuantityValue<dimensions::GravitationalConstant>{0.7});
|
||||
REQUIRE(scales);
|
||||
const double gravitational_mass = 0.7 * scales->mass.value();
|
||||
const auto fields = [&](double radius) {
|
||||
return laneemden::evaluate_physical(*solution, *scales, dimensions::QuantityValue<dimensions::Length>{radius});
|
||||
};
|
||||
int scalar_limits = 0;
|
||||
int vector_limits = 0;
|
||||
const auto scalar_limit = [&](const mfem::Vector&) -> projection::RadialValue {
|
||||
++scalar_limits;
|
||||
return 0.0;
|
||||
};
|
||||
const auto vector_limit = [&](const mfem::Vector& point) -> std::expected<mfem::Vector, std::string> {
|
||||
++vector_limits;
|
||||
mfem::Vector value(point);
|
||||
value *= gravitational_mass / std::pow(point.Norml2(), 3);
|
||||
return value;
|
||||
};
|
||||
const auto potential = backend::project_scalar(scalar_space,
|
||||
[&](double radius) -> projection::RadialValue {
|
||||
return fields(radius).transform([](const laneemden::PhysicalSample& sample) { return sample.potential.value(); });
|
||||
}, {.mapping = &evaluator, .scalar_at_infinity = scalar_limit});
|
||||
const auto gravity = backend::project_vector(vector_space,
|
||||
[&](double radius) -> projection::RadialValue {
|
||||
return fields(radius).transform([](const laneemden::PhysicalSample& sample) { return sample.gravity.value(); });
|
||||
}, {.mapping = &evaluator, .vector_at_infinity = vector_limit});
|
||||
REQUIRE(potential);
|
||||
REQUIRE(gravity);
|
||||
CHECK(scalar_limits > 0);
|
||||
CHECK(vector_limits > 0);
|
||||
mfem::Array<int> infinity_marker(generated.mesh->bdr_attributes.Max());
|
||||
infinity_marker = 0;
|
||||
infinity_marker[serif::tests::deformation::PairedMesh::infinity_attribute - 1] = 1;
|
||||
mfem::Array<int> infinity_dofs;
|
||||
scalar_space.GetEssentialTrueDofs(infinity_marker, infinity_dofs);
|
||||
REQUIRE(infinity_dofs.Size() > 0);
|
||||
for (const int dof : infinity_dofs) {
|
||||
CHECK((*potential)[dof] == 0.0);
|
||||
}
|
||||
|
||||
CHECK(gravity->Norml2() > 0.0);
|
||||
}
|
||||
|
||||
TEST_CASE("Compactified projection validates explicit limits before publishing", "[unit][projection][infinity]") {
|
||||
mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON);
|
||||
mesh.GetElement(0)->SetAttribute(3);
|
||||
mesh.SetAttributes();
|
||||
mfem::H1_FECollection h1(1, 3);
|
||||
mfem::FiniteElementSpace scalar_space(&mesh, &h1), vector_space(&mesh, &h1, 3);
|
||||
mfem::GridFunction displacement(&vector_space), compactification(&scalar_space);
|
||||
displacement = 0.0;
|
||||
compactification = 1.0;
|
||||
mapping::DomainMapper mapper(
|
||||
{.dimension = 3, .vacuum_element_attribute_ID = 3},
|
||||
std::make_unique<mapping::compactification::KelvinCompactification>(
|
||||
serif::utils::user_options::KelvinCompactificationOptions{.r_star_ref = 1.0, .r_inf_ref = 4.0}
|
||||
)
|
||||
);
|
||||
mapping::GridFunctionMappingEvaluator evaluator(mapper, displacement, compactification);
|
||||
int ordinary_samples = 0;
|
||||
const auto profile = [&](double) {
|
||||
++ordinary_samples;
|
||||
return 1.0;
|
||||
};
|
||||
CHECK_FALSE(backend::project_scalar(scalar_space, profile, {.mapping = &evaluator}));
|
||||
CHECK_FALSE(backend::project_vector(vector_space, profile, {.mapping = &evaluator}));
|
||||
for (double invalid : {std::numeric_limits<double>::quiet_NaN(), std::numeric_limits<double>::infinity()}) {
|
||||
CHECK_FALSE(backend::project_scalar(scalar_space, profile, {.mapping = &evaluator,
|
||||
.scalar_at_infinity = [invalid](const mfem::Vector&) -> projection::RadialValue { return invalid; }}));
|
||||
CHECK_FALSE(backend::project_vector(vector_space, profile, {.mapping = &evaluator,
|
||||
.vector_at_infinity = [invalid](const mfem::Vector&) -> std::expected<mfem::Vector, std::string> {
|
||||
mfem::Vector value(3);
|
||||
value = invalid;
|
||||
return value;
|
||||
}}));
|
||||
}
|
||||
const auto rejected_scalar = backend::project_scalar(scalar_space, profile, {.mapping = &evaluator,
|
||||
.scalar_at_infinity = [](const mfem::Vector&) -> projection::RadialValue {
|
||||
return std::unexpected("scalar asymptote unavailable");
|
||||
}});
|
||||
const auto rejected_vector = backend::project_vector(vector_space, profile, {.mapping = &evaluator,
|
||||
.vector_at_infinity = [](const mfem::Vector&) -> std::expected<mfem::Vector, std::string> {
|
||||
return std::unexpected("vector asymptote unavailable");
|
||||
}});
|
||||
REQUIRE_FALSE(rejected_scalar);
|
||||
REQUIRE_FALSE(rejected_vector);
|
||||
CHECK(rejected_scalar.error().find("scalar asymptote unavailable") != std::string::npos);
|
||||
CHECK(rejected_vector.error().find("vector asymptote unavailable") != std::string::npos);
|
||||
CHECK_FALSE(backend::project_vector(vector_space, profile, {.mapping = &evaluator,
|
||||
.vector_at_infinity = [](const mfem::Vector&) -> std::expected<mfem::Vector, std::string> {
|
||||
mfem::Vector value(2);
|
||||
value = 0.0;
|
||||
return value;
|
||||
}}));
|
||||
const auto recovered = backend::project_scalar(scalar_space, profile, {.mapping = &evaluator,
|
||||
.scalar_at_infinity = [](const mfem::Vector& point) -> projection::RadialValue { return point[0]; }});
|
||||
REQUIRE(recovered);
|
||||
CHECK(recovered->Min() == 0.0);
|
||||
CHECK(recovered->Max() == 1.0);
|
||||
CHECK(ordinary_samples == 0);
|
||||
}
|
||||
Reference in New Issue
Block a user