diff --git a/src/include/serif/utils/dependencies/binding.hpp b/src/include/serif/utils/dependencies/binding.hpp index e69de29b..9e06a045 100644 --- a/src/include/serif/utils/dependencies/binding.hpp +++ b/src/include/serif/utils/dependencies/binding.hpp @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include "serif/utils/misc/traits/type_uniqueness.hpp" + +namespace serif::utils::dependencies { + template + struct Binding { + using Key = KeyT; + using Target = TargetT; + }; + + template + concept IsBinding = requires { + typename T::Key; + typename T::Target; + } && std::is_same_v>; + + namespace detail { + /** @brief Validate each binding without an implicit constraint fold proportional to the collection size. */ + template + inline constexpr bool valid_binding_types_v = [] { + constexpr std::array valid{IsBinding...}; + for (const bool binding : valid) { + if (!binding) { return false; } + } + return true; + }(); + } + + template requires detail::valid_binding_types_v + struct Bindings {}; + + template + inline constexpr bool is_bindings_v = false; + + template requires detail::valid_binding_types_v + inline constexpr bool is_bindings_v> = misc::traits::all_unique_types_v; + + template + concept IsBindings = is_bindings_v>; + + namespace detail { + template + struct HasBinding; + + template requires valid_binding_types_v + struct HasBinding, KeyT> { + constexpr static bool value = [] { + constexpr std::array matches{std::is_same_v...}; + for (const bool match : matches) { + if (match) { return true; } + } + return false; + }(); + }; + + template + struct BindingLookup; + + /** @brief Share one compile-time lookup structure across all keys in a binding collection. */ + template requires valid_binding_types_v + struct BindingLookup> : BindingTs... { + /** @brief Select the matching base type without constructing any bound target. */ + template + static Binding select(Binding); + }; + + template + struct BoundType { + using Lookup = BindingLookup; + using target_type = decltype(Lookup::template select(Lookup{}))::Target; + }; + } + + template + struct HasBinding : detail::HasBinding, KeyT> {}; + + template + inline constexpr bool has_binding_v = HasBinding::value; + + template + concept BindingsWithBinding = IsBindings && has_binding_v; + + template + requires BindingsWithBinding + struct BoundType : detail::BoundType, KeyT> {}; + + template + using bound_type_t = BoundType::target_type; +} diff --git a/src/include/serif/utils/dependencies/computation.hpp b/src/include/serif/utils/dependencies/computation.hpp new file mode 100644 index 00000000..b6545c7c --- /dev/null +++ b/src/include/serif/utils/dependencies/computation.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include +#include +#include "serif/utils/dependencies/key_list.hpp" + +namespace serif::utils::dependencies { + template + class Computation { + public: + using Key = KeyT; + using Inputs = std::remove_cvref_t; + using Function = FunctionT; + static constexpr bool owns_inputs = OwnsInputs; + + /// Own the callable; result-affecting mutable state belongs in the declared inputs. + explicit Computation(FunctionT function) : m_function(std::move(function)) { + } + + /// Supply inputs in declaration order and infer the result from the callable. + template requires std::invocable + [[nodiscard]] decltype(auto) evaluate(const Arguments&... arguments) const { + return std::invoke(m_function, arguments...); + } + + private: + FunctionT m_function; + }; + + template + inline constexpr bool is_computation_v = false; + + template + inline constexpr bool is_computation_v> = true; + + template + concept IsComputation = is_computation_v>; + + /// Attach an output key and ordered input keys to a lambda, function, or other callable. + template + requires std::constructible_from, FunctionT> + [[nodiscard]] auto make_computation(FunctionT&& function) { + return Computation, std::decay_t>(std::forward(function)); + } + + /** + * @brief Supply immutable shared handles to computed prerequisites so results can retain them without copying. + * @note Every input must be produced by this graph. Context sources remain borrowed and cannot be retained this way. + * Resources borrowed inside a prerequisite must still outlive their use; the handle owns only the computed result. + */ + template + requires std::constructible_from, FunctionT> + [[nodiscard]] auto make_owning_computation(FunctionT&& function) { + return Computation, std::decay_t, true>(std::forward(function)); + } +} diff --git a/src/include/serif/utils/dependencies/computation_cache.hpp b/src/include/serif/utils/dependencies/computation_cache.hpp new file mode 100644 index 00000000..dc0c0047 --- /dev/null +++ b/src/include/serif/utils/dependencies/computation_cache.hpp @@ -0,0 +1,331 @@ +#pragma once + +#include "serif/utils/error/errors.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "serif/utils/dependencies/preparation.hpp" +#include "serif/utils/dependencies/observers.hpp" +#include "serif/utils/dependencies/detail/cache_storage.hpp" + +namespace serif::utils::dependencies { + /** @brief Execute a computational graph on demand, publishing immutable results only after the entire request succeeds. */ + template > + requires CanPrepare && ObserversFor + class ComputationCache { + public: + using Traits = detail::ComputationCacheTraits; + using Bindings = typename Traits::Bindings; + using Sources = typename Traits::Sources; + using Report = PreparationReport; + using Failure = PreparationFailure; + + /// Own the graph, observer callbacks, and accepted cache; each preparation borrows its context. + explicit ComputationCache(GraphT graph, ObserversT observers = ObserversT{}) : + m_graph(std::move(graph)), m_observers(std::move(observers)) { + } + + ComputationCache(const ComputationCache&) = delete; + ComputationCache& operator=(const ComputationCache&) = delete; + + /// Transfer accepted results while leaving the original object unprepared. + ComputationCache(ComputationCache&& other) : + m_graph(std::move(other.m_graph)), + m_observers(std::move(other.m_observers)), + m_storage(std::move(other.m_storage)), + m_report(std::exchange(other.m_report, {})), + m_ready(std::exchange(other.m_ready, false)), + m_preparation_count(std::exchange(other.m_preparation_count, 0)) { + } + + ComputationCache& operator=(ComputationCache&&) = delete; + + /// Prepare the requested results and their prerequisites, publishing only a complete successful candidate. + template + requires detail::key_list_subset_v, typename GraphT::Results> + [[nodiscard]] std::expected try_prepare( + const ContextT& context, + RequestedT = {}, + const PreparationMode mode = PreparationMode::reuse_unchanged + ) { + clear_preparation(); + + Storage candidate = m_storage; + Report report; + std::array inspected{}; + + constexpr auto required = GraphT::Dependencies::required(std::remove_cvref_t{}); + auto outcome = prepare_in_order<0>(context, candidate, report, inspected, required, mode); + + if (!outcome) { + return std::unexpected(std::move(outcome.error())); + } + + m_storage = std::move(candidate); + m_report = report; + m_ready = true; + ++m_preparation_count; + + return report; + } + + /// Test whether a result belongs to the latest successful request, including its prerequisites. + template + requires detail::contains_key_v + [[nodiscard]] bool contains() const { + return m_ready && m_report.template status() != PreparationStatus::not_required; + } + + /// Read a frozen result. References remain valid until the next preparation or invalidation. + template + requires detail::contains_key_v + [[nodiscard]] const auto& get() const { + if (!contains()) { + serif::utils::errors::serif_error( + "dependencies::ComputationCache::get: this result is unavailable. Successfully prepare requested results " + "containing its key or a result that depends on it before reading it." + ); + } + + return *std::get()>(m_storage.results).value; + } + + /** + * @brief Retain an immutable accepted result independently of subsequent cache preparations. + * @note This retains the result itself; resources borrowed by that result must still outlive their use. + */ + template + requires detail::contains_key_v + [[nodiscard]] auto share() const { + (void)get(); + return std::get()>(m_storage.results).value; + } + + /// Report reuse and rebuilding from the latest successful preparation. + [[nodiscard]] const Report& report() const noexcept { + return m_report; + } + + /// A failed preparation makes all result access unavailable until a subsequent success. + [[nodiscard]] bool ready() const noexcept { + return m_ready; + } + + /// Count successful preparations, including requests whose results were all reused. + [[nodiscard]] std::size_t preparation_count() const noexcept { + return m_preparation_count; + } + + /** + * @brief Make results unavailable before validating a new candidate's inputs. + * @note Accepted snapshots remain reusable after rejection; only a successful preparation + * restores public access. Owning handles obtained earlier remain valid. + */ + void clear_preparation() noexcept { + m_report = {}; + m_ready = false; + } + + /// Discard accepted snapshots and results, requiring fresh preparation on the next request. + void invalidate() noexcept { + m_storage = {}; + clear_preparation(); + } + + private: + static constexpr std::size_t source_count = detail::KeyListSize::value; + using Storage = detail::ComputationCacheStorage>; + using ResultMask = typename GraphT::Dependencies::Mask; + using SourceMask = std::array; + + /// Share one version namespace between source snapshots and computed results. + template + [[nodiscard]] static consteval std::size_t version_index() { + if constexpr (detail::contains_key_v) { + return detail::type_index(typename Sources::Keys{}); + } else { + return source_count + GraphT::template index(); + } + } + + /// Inspect each required source once; other context objects need no snapshot policy. + template + void inspect_source( + const ContextT& context, + Storage& candidate, + Report& report, + SourceMask& inspected, + const PreparationMode mode + ) const { + if constexpr (detail::contains_key_v) { + constexpr std::size_t index = version_index(); + + if (inspected[index]) { + return; + } + + inspected[index] = true; + + const auto& resource = context.template resource(); + auto& snapshot = std::get(candidate.sources); + + const void* identity = source_identity(resource.policy(), resource.get()); + const bool changed = mode == PreparationMode::rebuild || !snapshot.captured || snapshot.identity != identity || !resource.policy().matches(*snapshot.captured, resource.get()); + + if (changed) { + using Captured = typename std::remove_reference_t::Captured; + snapshot.captured = std::make_shared(resource.policy().capture(resource.get())); + snapshot.identity = identity; + ++candidate.versions[index]; + report.changed_sources[index] = true; + } + } + } + + /// Sources and computed prerequisites expose the same read-only values to edge observers. + template + [[nodiscard]] const auto& input_value(const ContextT& context, const Storage& candidate) const { + if constexpr (detail::contains_key_v) { + return context.template get(); + } else { + return *std::get()>(candidate.results).value; + } + } + + /// Only computed prerequisites have immutable owning handles; ordinary inputs remain references. + template + [[nodiscard]] const auto& input(const ContextT& context, const Storage& candidate) const { + if constexpr (ComputationT::owns_inputs) { + return std::get()>(candidate.results).value; + } else { + return input_value(context, candidate); + } + } + + /** + * @brief Execute one node after its inputs are ready and reuse has been ruled out. + * @note This returns a candidate; it does not publish results or change the accepted cache. + */ + template + [[nodiscard]] auto evaluate_node( + const ContextT& context, + const Storage& candidate, + KeyList + ) { + using Computation = GraphT::template ComputationAt; + const auto& computation = m_graph.template computation(); + + return m_observers.template evaluate([&]() { + // A comma fold makes edge callbacks follow Inputs order, unlike function arguments. + (m_observers.template edge(input_value(context, candidate)), ...); + + return computation.evaluate(input(context, candidate)...); + }); + } + + /// Reuse a result only when every input version matches its accepted version. + template + [[nodiscard]] std::expected prepare_one( + const ContextT& context, + Storage& candidate, + Report& report, + SourceMask& inspected, + const PreparationMode mode, + KeyList + ) { + using Computation = GraphT::template ComputationAt; + using Result = detail::ResolveComputation; + + (inspect_source(context, candidate, report, inspected, mode), ...); + + const std::array versions{candidate.versions[version_index()]...}; + auto& cached = std::get(candidate.results); + + if (mode == PreparationMode::reuse_unchanged && cached.value && cached.input_versions == versions) { + report.statuses[Index] = PreparationStatus::reused; + return {}; + } + + auto result = evaluate_node(context, candidate, KeyList{}); + + if constexpr (Result::fallible) { + if (!result) { + return std::unexpected(Failure{ + .computation_index = Index, + .error = std::move(result.error()) + }); + } + + cached.value = std::make_shared(std::move(*result)); + } else { + cached.value = std::make_shared(std::move(result)); + } + + cached.input_versions = versions; + ++candidate.versions[source_count + Index]; + report.statuses[Index] = PreparationStatus::computed; + + return {}; + } + + /// Follow the compile-time order while skipping branches outside the requested closure. + template + [[nodiscard]] std::expected prepare_in_order( + const ContextT& context, + Storage& candidate, + Report& report, + SourceMask& inspected, + const ResultMask& required, + PreparationMode mode + ) { + if constexpr (Position < GraphT::size) { + constexpr std::size_t index = GraphT::Dependencies::order[Position]; + if (required[index]) { + auto outcome = prepare_one( + context, candidate, report, inspected, mode, typename GraphT::template ComputationAt::Inputs{} + ); + if (!outcome) { + return outcome; + } + } + + return prepare_in_order(context, candidate, report, inspected, required, mode); + } else { + return {}; + } + } + + GraphT m_graph; + [[no_unique_address]] ObserversT m_observers; + Storage m_storage; + Report m_report; + bool m_ready{false}; + std::size_t m_preparation_count{0}; + }; + + /// Fix the graph's source types without retaining the supplied context or evaluating any computations. + template > + requires CanPrepare && ObserversFor && + std::constructible_from, GraphT> && std::constructible_from, ObserversT> + [[nodiscard]] auto make_computation_cache(GraphT&& graph, const ContextT&, ObserversT&& observers = ObserversT{}) { + return ComputationCache, std::remove_cvref_t, ErrorT, std::remove_cvref_t>( + std::forward(graph), std::forward(observers)); + } + + /// Build a checked graph and an empty cache directly from named computations. + template + requires CompatibleComputations && + (std::constructible_from, Computations> && ...) && + CanPrepare...>, ContextT, ErrorT> + [[nodiscard]] auto make_computation_cache(const ContextT& context, Computations&&... computations) { + return make_computation_cache(make_computation_graph(std::forward(computations)...), context); + } +} diff --git a/src/include/serif/utils/dependencies/computation_graph.hpp b/src/include/serif/utils/dependencies/computation_graph.hpp new file mode 100644 index 00000000..bb31663f --- /dev/null +++ b/src/include/serif/utils/dependencies/computation_graph.hpp @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "serif/utils/dependencies/computation.hpp" +#include "serif/utils/dependencies/key_list_operations.hpp" +#include "serif/utils/dependencies/detail/graph.hpp" + +namespace serif::utils::dependencies { + namespace detail { + /// Owning nodes can retain computed inputs; context resources remain borrowed. + template + inline constexpr bool valid_input_ownership_v = !std::remove_cvref_t::owns_inputs || + key_list_subset_v::Inputs, ResultKeysT>; + } + + /// A computational graph needs unique producers, valid input ownership, and no cycles. + template + concept CompatibleComputations = + (IsComputation && ...) && + misc::traits::all_unique_types_v::Key...> && + (detail::valid_input_ownership_v::Key...>> && ...) && + detail::Graph...>::acyclic(); + + template requires CompatibleComputations + class ComputationGraph { + public: + using Results = KeyList; + using Inputs = CombinedKeyList; + /// Compile-time topology; indices follow declaration order, not execution order. + using Dependencies = detail::Graph; + static constexpr std::size_t size = sizeof...(Computations); + + /// Select a node by its declaration index; Dependencies::order gives execution order. + template + using ComputationAt = std::tuple_element_t>; + + /// Accept computations in any order; the graph determines their evaluation order. + explicit ComputationGraph(Computations... computations) : m_computations(std::move(computations)...) {} + + /// Read a callable without permitting its stored configuration to change between preparations. + template requires (Index < size) + [[nodiscard]] const auto& computation() const { + return std::get(m_computations); + } + + /// Convert a known result key to its stable declaration index. + template requires detail::contains_key_v + [[nodiscard]] static consteval std::size_t index() { + return detail::type_index(typename Results::Keys{}); + } + + private: + std::tuple m_computations; + }; + + template + inline constexpr bool is_computation_graph_v = false; + + template + inline constexpr bool is_computation_graph_v> = true; + + template + concept IsComputationGraph = is_computation_graph_v>; + + /// Reject duplicate producers and cyclic computational graphs at the declaration site. + template + requires CompatibleComputations && + (std::constructible_from, Computations> && ...) + [[nodiscard]] auto make_computation_graph(Computations&&... computations) { + return ComputationGraph...>(std::forward(computations)...); + } +} diff --git a/src/include/serif/utils/dependencies/declerations.hpp b/src/include/serif/utils/dependencies/declerations.hpp deleted file mode 100644 index e69de29b..00000000 diff --git a/src/include/serif/utils/dependencies/dependencies.hpp b/src/include/serif/utils/dependencies/dependencies.hpp new file mode 100644 index 00000000..4da63f5e --- /dev/null +++ b/src/include/serif/utils/dependencies/dependencies.hpp @@ -0,0 +1,170 @@ +#pragma once + +/** + * @file dependencies.hpp + * @author Emily M. Boudreaux + * @brief This file is a convenience header which includes all of the headers in the dependencies module. Further, we include the detailed + * documentation for the dependencies module here. + * + */ + +#include "serif/utils/dependencies/keys.hpp" +#include "serif/utils/dependencies/binding.hpp" +#include "serif/utils/dependencies/key_list.hpp" +#include "serif/utils/dependencies/key_list_operations.hpp" +#include "serif/utils/dependencies/dependency_context.hpp" +#include "serif/utils/dependencies/computation.hpp" +#include "serif/utils/dependencies/computation_graph.hpp" +#include "serif/utils/dependencies/observers.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" +#include "serif/utils/dependencies/snapshot.hpp" + +/** + * *Computational Graphs* + * + * Demonstration code for how to use the SERiF computational graph system + * The broad idea here is that there are many calculations which depend on each other but which may not necessarily always need to be recomputed just + * because another part of the model has changed. We model this as a combination of an acyclic computational graph, defined at compile time, and + * a runtime cache of values. The general workflow is that a developer defines the "context" of the computational graph, think of this + * as all of the inputs to the graph, or maybe more specifically, anything that could change due to some external event (e.g. a time step, a newton step, etc...) + * This context is probably the most CS intensive part of this code as the developer needs to think about lifetimes and ownership. + * + * **Context** + * Contexts are made with the make_dependency_context function, this accepts a variadic pack of bindings between a type and a runtime value. + * You can generate these yourself using the binding functions and types; however, we provide two convenience wrappers which generally developers should consider + * using. + * - borrow: Used to bind a type to a reference to a value. This is useful for values which are owned by the caller and which the caller will manage the lifetime of (takes an lvalue). + * - own: Used to bind a type to a value which is owned by the context (copies an lvalue or moves an rvalue) + * Each of these functions accepts a type as a template parameter and a runtime value as an argument. The type is used as an ID, this is key to understand, if we write + * \code{.cpp} + * using namespace serif::utils::dependencies; + * + * double x{1.0}; + * auto context = make_dependency_context(borrow(x)); + * \endcode + * + * we are not saying that x is of type keys::Input, this is clearly not the case. Rather, we are registering in the computational graph a binding + * between the type keys::Input and the runtime value x. Think of this as naming the various inputs to the graph. + * + * **Computations** + * After defining a context we can define a computation, each computation defines a node, while its ordered input keys define the incoming edges. + * Consider the following example (assuming the context from above): + * + * \code{.cpp} + * auto func = make_computation>( + * [](const double _x) -> double { + * return _x * _x; + * } + * ); + * \endcode + * + * what we have defined here is a node which takes a single input tagged keys::Input and produces an output tagged as keys::Output. The input key + * declares an edge from that source to this node. Other nodes can use keys::Output as an input, and a successfully prepared result can be read with get + * or retained with share. These connections allow us to form a larger graph. Consider now the following code + * + * \code{.cpp} + * using namespace serif::utils::dependencies; + * + * struct tags { + * struct X{}; + * struct Y{}; + * + * struct Output1{}; + * struct Output2{}; + * }; + * + * double x{1.0}; + * double y{2.0}; + * auto context = make_dependency_context(borrow(x), borrow(y)); + * + * auto func1 = make_computation>( + * [](const double _x) -> double { + * return _x * _x; + * } + * ); + * + * auto func2 = make_computation>( + * [](const double _x, const double _y) -> double { + * return _x + _y; + * } + * ); + * \endcode + * + * we have now defined two computations, the first takes a single input, whichever input is tagged as tags::X and produces an output tagged as tags::Output1. + * The second computation takes two inputs, the first is the output of the first computation (lookup by the tag) and the second is whichever input is tagged as tags::Y, + * and produces an output tagged as tags::Output2. Note that the order of arguments to the lambda must be the same as the order of the tags in the InputList, the + * dependencies module will automatically order arguments based on the InputList. There is type checking to ensure that the types match, but if you have two + * inputs of the same type that are flipped there is no way for the system to inspect that and warn you. Further, depending on your compiler and flags + * there may be implicit casting between convertable types (e.g. int <-> double). + * + * @todo We may want to remove the ability to have implicit casting between types, this is a bit of a footgun and may lead to subtle bugs. + * + * @note This is primary goal of this system, split computations into small pieces with the ability to cache intermediate results and reuse + * as many of those intermediate results as we can can prove is safe to do. The whole reason this system is so complex is that very purpose. There is a fundamental + * problem with high dimensional stellar models, they are always going to be slow, therefore we need to accept some degree of complexity in order to make them tractable. + * + * **Computation Cache** + * Once we have the computations we need to connect them together, generally use the helper function make_computation_cache which + * takes a context and a variadic pack of computations. There are *a lot* of compile time checks which run to ensure that the graph is valid, the context + * describes what is needed to compute the graph, and that each computation is valid. Generally if a static analysis error or compile error pops up here + * it probably means there is a mistake in the definition of the context or the computations. I've tried to make these errors as clear as possible; however, + * there is only so much that you can do with the C++ compiler. I recommend first checking, probably with a drawn-out diagram, that all the + * tags are correct and what you expect. + * + * A developer can also choose to use the make_computation_graph function to explicitly name a graph. This + * is useful when naming or reusing a collection of computations (make_computation_cache + * calls make_computation_graph and then fixes the source types from the supplied context without retaining the context). Now consider the following code assuming the context and computations from above: + * + * \code{.cpp} + * auto graph_cache = make_computation_cache(context, func1, func2); + * \endcode + * + * At this point we have a cache which we can access various parts of; however, no computations have been run yet. To run a computation we must call try_prepare. + * + * try_prepare returns std::expected: ordinary rejection is a value, and no candidate results or snapshots are published on failure. + * Exceptions from a callable or snapshot policy propagate with the same cache protection. Previously retained results remain valid, + * but get and share require a successful current preparation. Always check the report before reading results. + * + * try_prepare takes the context and evaluates the requested nodes and their prerequisites, reusing unchanged results. By default every result + * is requested, so the first call evaluates the entire graph. RequestedResults can restrict evaluation to a branch. + * + * \code{.cpp} + * auto report = graph_cache.try_prepare(context); + * if (!report) { + * return; // Handle report.error() before reading the cache. + * } + * + * double func1_result = graph_cache.template get(); + * double func2_result = graph_cache.template get(); + * + * auto func1_status = report->template status(); + * auto func2_status = report->template status(); + * + * std::string_view func1_status_string = preparation_status_name_map[func1_status]; // note this is an inline map defined in serif::utils::dependencies + * std::string_view func2_status_string = preparation_status_name_map[func2_status]; + * + * std::println("Func1 Result: {}, Func2 Result: {}, Func1 Status: {}, Func2 Status: {}", func1_result, func2_result, func1_status_string, func2_status_string); + * \endcode + * + * Now what we could do is change only one part of the input context and call try_prepare again. Lets say we change only y, such that only func2 is stale. + * + * \code{.cpp} + * y = 3.0; + * report = graph_cache.try_prepare(context); + * if (!report) { + * return; // Handle report.error() before reading the cache. + * } + * + * double func1_result = graph_cache.template get(); + * double func2_result = graph_cache.template get(); + * + * auto func1_status = report->template status(); + * auto func2_status = report->template status(); + * + * std::string_view func1_status_string = preparation_status_name_map[func1_status]; // note this is an inline map defined in serif::utils::dependencies + * std::string_view func2_status_string = preparation_status_name_map[func2_status]; + * + * std::println("Func1 Result: {}, Func2 Result: {}, Func1 Status: {}, Func2 Status: {}", func1_result, func2_result, func1_status_string, func2_status_string); + * \endcode + */ +namespace serif::utils::dependencies{}; diff --git a/src/include/serif/utils/dependencies/dependency_context.hpp b/src/include/serif/utils/dependencies/dependency_context.hpp new file mode 100644 index 00000000..5f1f79f0 --- /dev/null +++ b/src/include/serif/utils/dependencies/dependency_context.hpp @@ -0,0 +1,124 @@ +#pragma once + +#include +#include +#include +#include +#include "serif/utils/dependencies/resource.hpp" +#include "serif/utils/dependencies/detail/type_lists.hpp" +#include "serif/utils/misc/concepts/concat.hpp" + +namespace serif::utils::dependencies { + template + requires SatisfiesKeyList + class DependencyContextView; + + template + requires misc::traits::all_unique_types_v + class DependencyContext { + public: + using Resources = types::TypeList; + using Keys = KeyList; + using Bindings = dependencies::Bindings...>; + using ResourceBindings = dependencies::Bindings...>; + + /// Each resource explicitly chooses owned or borrowed storage. + explicit(sizeof...(ResourceTs) != 0) DependencyContext(ResourceTs... resources) : m_resources(std::move(resources)...) {} + + /// Retrieve a resource by role even when several roles have the same target type. + template requires BindingsWithBinding + [[nodiscard]] const auto& get() const { + return resource().get(); + } + + /// Access the value together with the policy used to track it. + template requires BindingsWithBinding + [[nodiscard]] const auto& resource() const { + constexpr std::size_t index = detail::type_index(types::TypeList{}); + return std::get(m_resources); + } + + /// Select declared roles without copying their objects or tracking policies. + template requires SatisfiesKeyList + [[nodiscard]] auto select(KeysT) const & { + return DependencyContextView>(*this); + } + + /// A view cannot safely borrow a temporary context. + template + auto select(KeysT) const && = delete; + + private: + std::tuple m_resources; + }; + + template + requires SatisfiesKeyList> + class DependencyContextView> { + public: + using Keys = KeyList; + using Bindings = dependencies::Bindings>...>; + using ResourceBindings = dependencies::Bindings>...>; + using Resources = types::TypeList...>; + + /// The parent context must outlive this view. + explicit DependencyContextView(const ParentT& parent) : m_parent(parent) {} + + /// Reject temporary parents even when constructing a view directly. + DependencyContextView(const ParentT&&) = delete; + + /// Expose only the declared portion of the parent context. + template requires BindingsWithBinding + [[nodiscard]] const auto& get() const { + return m_parent.template get(); + } + + /// Preserve the original source identity and tracking policy through selection. + template requires BindingsWithBinding + [[nodiscard]] const auto& resource() const { + return m_parent.template resource(); + } + + private: + const ParentT& m_parent; + }; + + template + inline constexpr bool is_dependency_context_v = false; + + template + inline constexpr bool is_dependency_context_v> = true; + + template + inline constexpr bool is_dependency_context_v> = true; + + template + concept IsDependencyContext = is_dependency_context_v>; + + /// Build a context whose schema follows directly from its resource keys and types. + template + requires (IsResource && ...) && misc::traits::all_unique_types_v::Key...> + [[nodiscard]] auto make_dependency_context(Resources&&... resources) { + return DependencyContext...>(std::forward(resources)...); + } + + namespace detail { + /** @brief Combining contexts must preserve every resource, with exactly one owner of each key. */ + template + [[nodiscard]] consteval bool combinable_resources(types::TypeList) { + return (std::copy_constructible && ...) && misc::traits::all_unique_types_v; + } + } + + /** @brief Copy disjoint resources and their tracking policies into one context; borrowed objects retain their original lifetime. */ + template + requires (detail::combinable_resources(misc::concepts::ConcatT{})) + [[nodiscard]] auto combine_dependency_contexts(const Contexts&... contexts) { + const auto resources = [](const auto& context, types::TypeList) { + return std::tie(context.template resource()...); + }; + return std::apply([](const auto&... entries) { + return make_dependency_context(entries...); + }, std::tuple_cat(resources(contexts, typename Contexts::Resources{})...)); + } +} diff --git a/src/include/serif/utils/dependencies/detail/cache_storage.hpp b/src/include/serif/utils/dependencies/detail/cache_storage.hpp new file mode 100644 index 00000000..c7b023a0 --- /dev/null +++ b/src/include/serif/utils/dependencies/detail/cache_storage.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "serif/utils/dependencies/detail/cache_traits.hpp" + +namespace serif::utils::dependencies::detail { + template + struct SourceSnapshot { + using Captured = decltype(std::declval().capture(std::declval())); + const void* identity{nullptr}; + std::shared_ptr captured; + }; + + template + struct CachedResult { + std::shared_ptr value; + std::array input_versions{}; + }; + + template + struct ComputationCacheStorage; + + // Shared immutable objects make candidate preparation cheap to stage and safe to discard on failure. + template + struct ComputationCacheStorage, std::index_sequence> { + std::tuple...> sources; + std::tuple::Key>, + KeyListSize::Inputs>::value>...> results; + std::array versions{}; + }; +} diff --git a/src/include/serif/utils/dependencies/detail/cache_traits.hpp b/src/include/serif/utils/dependencies/detail/cache_traits.hpp new file mode 100644 index 00000000..4b44a482 --- /dev/null +++ b/src/include/serif/utils/dependencies/detail/cache_traits.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "serif/utils/dependencies/detail/schema.hpp" +#include "serif/utils/misc/concepts/filter.hpp" + +namespace serif::utils::dependencies::detail { + template + struct UsedResource { + template + struct Predicate : std::bool_constant> {}; + }; + + template + using UsedResources = misc::concepts::FilterT::template Predicate>; + + template + struct ResourceKeys; + + template + struct ResourceKeys> { + using type = KeyList; + static constexpr bool trackable = (SnapshotPolicyFor && ...); + }; + + /// Ordinary graph failures retain an exact type; numerical conversions must not erase diagnostics. + template + inline constexpr bool compatible_error_v = std::same_as; + + /// Independent modules may explicitly aggregate their unchanged failures in an unambiguous tagged union. + template + inline constexpr bool compatible_error_v> = + std::same_as> || ((std::same_as || ...) && std::constructible_from, Source>); + + /// Value-returning calculations can share a graph with expected results carrying its declared error or an exact union alternative. + template + [[nodiscard]] consteval bool compatible_errors(std::index_sequence) { + return ((std::is_void_v, BindingsT>::Error> || + compatible_error_v, BindingsT>::Error, ErrorT>) && ...); + } + + template > + struct ComputationCacheTraits { + static constexpr bool valid = false; + }; + + template + struct ComputationCacheTraits { + using Bindings = typename ComputationGraphSchema::ResolvedBindings; + using Resources = UsedResources; + using Sources = typename ResourceKeys::type; + static constexpr bool valid = ResourceKeys::trackable && std::is_object_v && std::move_constructible && + compatible_errors(std::make_index_sequence{}); + }; +} + +namespace serif::utils::dependencies { + template + concept CanPrepare = detail::ComputationCacheTraits, std::remove_cvref_t, ErrorT>::valid; +} diff --git a/src/include/serif/utils/dependencies/detail/graph.hpp b/src/include/serif/utils/dependencies/detail/graph.hpp new file mode 100644 index 00000000..dadd94e2 --- /dev/null +++ b/src/include/serif/utils/dependencies/detail/graph.hpp @@ -0,0 +1,97 @@ +#pragma once + +#include +#include +#include "serif/utils/dependencies/detail/type_lists.hpp" + +namespace serif::utils::dependencies::detail { + /// Immutable topology of computed nodes; context sources are resolved separately. + template + struct Graph { + static constexpr std::size_t size = sizeof...(Computations); + using Mask = std::array; + using Matrix = std::array; + using Order = std::array; + + /// Record only computed prerequisites; other input keys are supplied by a context. + template + [[nodiscard]] static consteval Mask prerequisites() { + return {contains_key_v...}; + } + + /// edges[consumer][producer] is true when the consumer reads the producer's result. + static constexpr Matrix edges{prerequisites()...}; + + /** + * @brief Order prerequisites before their users, preferring the earliest ready declaration at each step. + * @note Remaining prerequisite counts avoid rescanning each candidate's entire input list. Both the initial count + * and subsequent updates are quadratic in graph size, keeping complete stellar graphs within constexpr limits. + */ + [[nodiscard]] static consteval Order make_order() { + Order result{}; + result.fill(size); + Mask visited{}; + std::array remaining{}; + for (std::size_t candidate = 0; candidate < size; ++candidate) { + std::size_t count = 0; + for (const bool prerequisite : edges[candidate]) { + count += prerequisite; + } + remaining[candidate] = count; + } + for (std::size_t position = 0; position < size; ++position) { + std::size_t selected = size; + for (std::size_t candidate = 0; candidate < size; ++candidate) { + if (!visited[candidate] && remaining[candidate] == 0) { + selected = candidate; + break; + } + } + if (selected == size) { + break; + } + result[position] = selected; + visited[selected] = true; + for (std::size_t user = 0; user < size; ++user) { + if (edges[user][selected]) { + --remaining[user]; + } + } + } + return result; + } + + /// Each execution position maps to a declaration index; size marks an unresolved cycle. + static constexpr Order order = make_order(); + + /// A missing position identifies a cycle, including a computation requiring itself. + [[nodiscard]] static consteval bool acyclic() { + for (const std::size_t index : order) { + if (index == size) { + return false; + } + } + return true; + } + + /** + * @brief Expand requested results to include all computed prerequisites in one reverse traversal. + * @note Every user appears after its prerequisites in the topological order. Visiting users first + * therefore marks their inputs before those inputs are visited, independently of declaration order. + */ + template + [[nodiscard]] static consteval Mask required(KeyList) { + Mask result{(contains_key_v>)...}; + for (std::size_t position = size; position > 0; --position) { + const std::size_t user = order[position - 1]; + if (user == size || !result[user]) { + continue; + } + for (std::size_t input = 0; input < size; ++input) { + result[input] = result[input] || edges[user][input]; + } + } + return result; + } + }; +} diff --git a/src/include/serif/utils/dependencies/detail/result.hpp b/src/include/serif/utils/dependencies/detail/result.hpp new file mode 100644 index 00000000..a1026a88 --- /dev/null +++ b/src/include/serif/utils/dependencies/detail/result.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "serif/utils/types/type_list.hpp" + +namespace serif::utils::dependencies::detail { + /// Computations publish owned, movable values, either directly or through std::expected. + template + struct Result { + using Value = T; + using Error = void; + static constexpr bool fallible = false; + static constexpr bool valid = std::is_object_v && std::move_constructible; + }; + + template + struct Result> { + using Value = ValueT; + using Error = ErrorT; + static constexpr bool fallible = true; + static constexpr bool valid = std::is_object_v && std::move_constructible; + }; + + /** @brief Owning computations receive the cache handle; ordinary computations receive the value. */ + template + using ComputationArgument = std::conditional_t, TargetT>; + + /// Match the const argument access used by the runtime cache without constructing any values. + template + using InvocationResult = decltype(std::declval().evaluate( + std::declval&>()... + )); + + /// Ill-formed calls fail the graph concept instead of producing a hard instantiation error. + template + struct Invocation { + static constexpr bool valid = false; + }; + + template + struct Invocation, std::void_t>> : + Result>> { + }; +} diff --git a/src/include/serif/utils/dependencies/detail/schema.hpp b/src/include/serif/utils/dependencies/detail/schema.hpp new file mode 100644 index 00000000..8c2f84b1 --- /dev/null +++ b/src/include/serif/utils/dependencies/detail/schema.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include "serif/utils/dependencies/dependency_context.hpp" +#include "serif/utils/dependencies/computation_graph.hpp" +#include "serif/utils/dependencies/detail/result.hpp" + +namespace serif::utils::dependencies::detail { + template + struct AppendBinding; + + template + struct AppendBinding, BindingT> { + using type = Bindings; + }; + + /// Missing inputs stop resolution before instantiating the callable or its result type. + template > + struct ResolveComputation { + static constexpr bool valid = false; + }; + + template + struct ResolveComputation : Invocation> { + }; + + /// Walk execution positions, appending each result type to the available input bindings. + template + struct ResolveGraph; + + /// An invalid callable must not instantiate the remaining graph or access a missing Value type. + template + struct ResolveStep { + static constexpr bool valid = false; + }; + + template + struct ResolveStep : ResolveGraph::Value>>::type, + Position + 1> {}; + + template + struct ResolveGraph : ResolveStep, + ResolveComputation, BindingsT>::valid> {}; + + template + struct ResolveGraph { + static constexpr bool valid = true; + using ResolvedBindings = BindingsT; + }; + + /// A key has exactly one producer: either the context or a computation. + template + [[nodiscard]] consteval bool independent_results(KeyList) { + return (!has_binding_v && ...); + } + + template (typename GraphT::Results{})> + struct ComputationGraphSchema { + static constexpr bool valid = false; + }; + + template + struct ComputationGraphSchema : ResolveGraph {}; +} + +namespace serif::utils::dependencies { + template + concept ComputationGraphFor = IsComputationGraph && IsDependencyContext && + detail::ComputationGraphSchema, std::remove_cvref_t>::valid; +} diff --git a/src/include/serif/utils/dependencies/detail/type_lists.hpp b/src/include/serif/utils/dependencies/detail/type_lists.hpp new file mode 100644 index 00000000..3a82e756 --- /dev/null +++ b/src/include/serif/utils/dependencies/detail/type_lists.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include "serif/utils/dependencies/key_list.hpp" + +namespace serif::utils::dependencies::detail { + /// Locate a type without requiring the types themselves to be constructible. + template + [[nodiscard]] consteval std::size_t type_index(types::TypeList) { + constexpr std::array matches{std::is_same_v...}; + for (std::size_t index = 0; index < matches.size(); ++index) { + if (matches[index]) { return index; } + } + return matches.size(); + } + + template + inline constexpr bool contains_key_v = false; + + template + inline constexpr bool contains_key_v> = type_index(types::TypeList{}) < sizeof...(Keys); + + template + inline constexpr bool key_list_subset_v = false; + + template + inline constexpr bool key_list_subset_v, AvailableT> = [] { + constexpr std::array available{contains_key_v...}; + for (const bool present : available) { + if (!present) { return false; } + } + return true; + }(); + + template + struct KeyListSize; + + template + struct KeyListSize> : std::integral_constant {}; + + template + struct AppendUnique; + + template + struct AppendUnique, T> { + using type = std::conditional_t>, KeyList, KeyList>; + }; + + template + struct AppendKeys { + using type = ResultT; + }; + + template + struct AppendKeys : AppendKeys::type, Rest...> {}; + + template + struct MergeKeyLists { + using type = ResultT; + }; + + template + struct MergeKeyLists, Rest...> : + MergeKeyLists::type, Rest...> {}; +} diff --git a/src/include/serif/utils/dependencies/key_list.hpp b/src/include/serif/utils/dependencies/key_list.hpp new file mode 100644 index 00000000..ff076051 --- /dev/null +++ b/src/include/serif/utils/dependencies/key_list.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include "serif/utils/dependencies/binding.hpp" +#include "serif/utils/misc/traits/type_uniqueness.hpp" +#include "serif/utils/types/type_list.hpp" + +namespace serif::utils::dependencies { + /// An ordered collection of role keys, independent of their associated value types. + template + struct KeyList { + using Keys = types::TypeList; + }; + + /// Computation arguments are supplied in this key order. + template + using InputList = KeyList; + + /// Preparing these results also prepares their required intermediate computations. + template + using RequestedResults = KeyList; + + template + inline constexpr bool is_key_list_v = false; + + template + inline constexpr bool is_key_list_v> = misc::traits::all_unique_types_v; + + template + concept IsKeyList = is_key_list_v>; + + template + struct KeyListBindings; + + template + struct KeyListBindings> : + std::bool_constant<(BindingsWithBinding && ...)> {}; + + template + struct ResolveKeys; + + template + struct ResolveKeys> { + using type = types::TypeList...>; + }; + + template + concept SatisfiesKeyList = IsBindings && IsKeyList && KeyListBindings>::value; + + template + requires SatisfiesKeyList + using ResolvedTargets = ResolveKeys>::type; +} diff --git a/src/include/serif/utils/dependencies/key_list_operations.hpp b/src/include/serif/utils/dependencies/key_list_operations.hpp new file mode 100644 index 00000000..c5f0ae6e --- /dev/null +++ b/src/include/serif/utils/dependencies/key_list_operations.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include "serif/utils/dependencies/detail/type_lists.hpp" + +namespace serif::utils::dependencies { + /** @brief Test exact key membership, rejecting malformed and duplicate-key lists. */ + template + concept ContainsKey = IsKeyList && detail::contains_key_v>; + + /** @brief Count distinct declared keys without constructing their associated values. */ + template + inline constexpr std::size_t key_count_v = detail::KeyListSize>::value; + + /** @brief Locate a declared key in its ordered list; absent keys are rejected at compile time. */ + template + requires ContainsKey + [[nodiscard]] consteval std::size_t key_index() { + return detail::type_index(typename std::remove_cvref_t::Keys{}); + } + + /// Combine requirements in first-appearance order, retaining distinct keys with shared targets. + template + using CombinedKeyList = detail::MergeKeyLists, std::remove_cvref_t...>::type; +} diff --git a/src/include/serif/utils/dependencies/keys.hpp b/src/include/serif/utils/dependencies/keys.hpp new file mode 100644 index 00000000..e213effe --- /dev/null +++ b/src/include/serif/utils/dependencies/keys.hpp @@ -0,0 +1,17 @@ +#pragma once + +namespace serif::utils::dependencies::keys { + // Optional roles for small computations. Use distinct application keys or existing term + // descriptors when a graph needs more specific names; no registration is required. + + /// The main input to a computation. + struct Input {}; + /// The main result of a computation. + struct Output {}; + /// A multiplicative scale supplied as an input. + struct Scale {}; + /// Configuration values that can affect the result. + struct Parameters {}; + /// Positions supplied to a computation, in the caller's coordinate convention. + struct Coordinates {}; +} diff --git a/src/include/serif/utils/dependencies/observers.hpp b/src/include/serif/utils/dependencies/observers.hpp new file mode 100644 index 00000000..42ddcbe0 --- /dev/null +++ b/src/include/serif/utils/dependencies/observers.hpp @@ -0,0 +1,282 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "serif/utils/dependencies/detail/schema.hpp" + +namespace serif::utils::dependencies { + /// Describe a node invocation, independently of whether the whole graph request is later accepted. + enum class NodeOutcome { + completed, + rejected, + exception + }; + + /// Completion must be safe during stack unwinding and receives the state created by the before hook. + template + concept NodeObserverCallbacks = std::invocable && + std::is_object_v> && std::move_constructible> && + requires(AfterT& after, std::invoke_result_t& state, NodeOutcome outcome) { + { std::invoke(after, state, outcome) } noexcept -> std::same_as; + }; + + template requires NodeObserverCallbacks + struct NodeObserver { + BeforeT before; + AfterT after; + }; + + /// An all-node callback receives the compiled result identity before creating its invocation state. + template + concept KeyedNodeObserverCallbacks = std::invocable> && + std::is_object_v>> && + std::move_constructible>> && + requires(AfterT& after, std::invoke_result_t>& state, NodeOutcome outcome) { + { std::invoke(after, state, outcome) } noexcept -> std::same_as; + }; + + /// One registration can inspect every result without building a graph-sized observer tuple. + template + struct AllNodesObserver { + BeforeT before; + AfterT after; + }; + + template + struct EdgeObserver { + FunctionT function; + }; + + namespace detail { + template + inline constexpr bool is_observer_v = false; + + template + inline constexpr bool is_observer_v> = true; + + template + inline constexpr bool is_observer_v> = true; + + template + inline constexpr bool is_observer_v> = true; + + template + inline constexpr bool observes_node_v = false; + + template + inline constexpr bool observes_node_v, KeyT> = true; + + template + inline constexpr bool observes_node_v, KeyT> = true; + + /// Preserve the existing nullary callback contract for individually selected nodes. + template + [[nodiscard]] decltype(auto) begin_node(NodeObserver& observer) { + return std::invoke(observer.before); + } + + /// Bind the identity once so generic registrations use the same exception-safe invocation scope. + template + [[nodiscard]] decltype(auto) begin_node(AllNodesObserver& observer) { + return std::invoke(observer.before, std::type_identity{}); + } + + /// Unrelated registrations do not observe this edge. + template + void observe_input(ObserverT&, const ValueT&) {} + + /// Edge observers read the numerical value even when its consumer receives an owning handle. + template + void observe_input(EdgeObserver& observer, const ValueT& value) { + std::invoke(observer.function, value); + } + + /// Each started hook owns one invocation state and finishes once, including on exceptional exit. + template + class NodeObservation { + public: + /// If before fails, construction fails and no matching completion is due. + NodeObservation(BeforeT& before, AfterT& after) : + m_after(after), m_state(std::invoke(before)), m_exceptions(std::uncaught_exceptions()) {} + + NodeObservation(const NodeObservation&) = delete; + NodeObservation& operator=(const NodeObservation&) = delete; + + /// A throwing result transfer also interrupts the invocation; no catch/rethrow is needed. + ~NodeObservation() noexcept { + const NodeOutcome outcome = std::uncaught_exceptions() > m_exceptions ? NodeOutcome::exception : m_outcome; + std::invoke(m_after, m_state, outcome); + } + + /// Distinguish an ordinary value from a computation's value-based rejection. + template + void returned(const ResultT& result) noexcept { + m_outcome = NodeOutcome::completed; + if constexpr (Result::fallible) { + if (!result) { + m_outcome = NodeOutcome::rejected; + } + } + } + + private: + AfterT& m_after; + std::invoke_result_t m_state; + int m_exceptions; + NodeOutcome m_outcome{NodeOutcome::exception}; + }; + } + + /// Own registrations; execution state lasts for one invocation and is never part of the numerical cache. + template requires (detail::is_observer_v && ...) + class Observers { + public: + /// Copy or move callbacks into this bundle without running them. + explicit Observers(Registrations... observers) : m_observers(std::move(observers)...) {} + + /// Nest matching node hooks so before runs in registration order and after in reverse order. + template + [[nodiscard]] auto evaluate(FunctionT&& function) { + if constexpr (Position == sizeof...(Registrations)) { + return std::invoke(function); + } else { + auto& observer = std::get(m_observers); + if constexpr (detail::observes_node_v, KeyT>) { + auto before = [&observer]() -> decltype(auto) { return detail::begin_node(observer); }; + detail::NodeObservation observation(before, observer.after); + auto result = evaluate(function); + observation.returned(result); + return result; + } else { + return evaluate(function); + } + } + } + + /// The cache visits inputs in declaration order; each edge's callbacks follow registration order. + template + void edge(const ValueT& value) { + std::apply([&](auto&... observers) { + (detail::observe_input(observers, value), ...); + }, m_observers); + } + + private: + std::tuple m_observers; + }; + + /// Bind before/after callbacks to a result key; callback captures determine any external storage lifetime. + template + requires NodeObserverCallbacks, std::decay_t> && + std::constructible_from, BeforeT> && std::constructible_from, AfterT> + [[nodiscard]] auto observe_node(BeforeT&& before, AfterT&& after) { + return NodeObserver, std::decay_t>{ + .before = std::forward(before), .after = std::forward(after)}; + } + + /// Observe all executed nodes; cache construction checks the callback contract for every result key. + template + requires std::constructible_from, BeforeT> && std::constructible_from, AfterT> + [[nodiscard]] auto observe_nodes(BeforeT&& before, AfterT&& after) { + return AllNodesObserver, std::decay_t>{ + .before = std::forward(before), .after = std::forward(after)}; + } + + /// Bind a read-only callback to one input of one consumer; graph construction validates the edge and value type. + template + requires std::constructible_from, FunctionT> + [[nodiscard]] auto observe_edge(FunctionT&& function) { + return EdgeObserver>{.function = std::forward(function)}; + } + + /// Collect any number of registrations, including several observers of the same node or edge. + template + requires (detail::is_observer_v> && ...) && + (std::constructible_from, Registrations> && ...) + [[nodiscard]] auto make_observers(Registrations&&... observers) { + return Observers...>(std::forward(observers)...); + } + + /// Default high-level binding adds no observation storage, timers, or callback work. + struct NoGraphObservers { + /// The graph tag allows optional factories to specialize their catalogs without inspecting numerical values. + template + [[nodiscard]] auto operator()(std::type_identity) const { + return Observers<>{}; + } + }; + + namespace detail { + /// Nodes are identified by result keys, including constant computations without inputs. + template + [[nodiscard]] consteval bool valid_observer(NodeObserver*) { + return ContainsKey; + } + + /// Use a bounded array scan rather than a graph-sized fold expression at the compiler's nesting limit. + template + [[nodiscard]] consteval bool valid_all_nodes(KeyList) { + constexpr std::array valid{KeyedNodeObserverCallbacks...}; + for (const bool entry : valid) { + if (!entry) { return false; } + } + return true; + } + + /// A generic callback must accept every node, including constant computations without inputs. + template + [[nodiscard]] consteval bool valid_observer(AllNodesObserver*) { + return valid_all_nodes(typename GraphT::Results{}); + } + + /// Use declared inputs to include source edges as well as computed prerequisites. + template + [[nodiscard]] consteval bool valid_observer(EdgeObserver*) { + if constexpr (!ContainsKey) { + return false; + } else { + using Consumer = GraphT::template ComputationAt()>; + if constexpr (!ContainsKey) { + return false; + } else { + return requires(FunctionT& function, const bound_type_t& value) { + { std::invoke(function, value) } -> std::same_as; + }; + } + } + } + + template > + struct ValidObservers : std::false_type {}; + + template + struct ValidObservers, GraphT, ContextT, true> : std::bool_constant< + (valid_observer::ResolvedBindings>(static_cast(nullptr)) && ...)> {}; + } + + /// Reject unknown nodes, nonexistent edges, and callbacks that cannot observe the graph's immutable values. + template + concept ObserversFor = detail::ValidObservers, std::remove_cvref_t, std::remove_cvref_t>::value; +} + +template <> +struct std::formatter : std::formatter { + auto format(const serif::utils::dependencies::NodeOutcome& outcome, auto& ctx) const { + std::string_view name = "unknown"; + switch (outcome) { + case serif::utils::dependencies::NodeOutcome::completed: name = "completed"; break; + case serif::utils::dependencies::NodeOutcome::rejected: name = "rejected"; break; + case serif::utils::dependencies::NodeOutcome::exception: name = "exception"; break; + } + return std::formatter::format(name, ctx); + } +}; diff --git a/src/include/serif/utils/dependencies/preparation.hpp b/src/include/serif/utils/dependencies/preparation.hpp new file mode 100644 index 00000000..e6f4941a --- /dev/null +++ b/src/include/serif/utils/dependencies/preparation.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "serif/utils/dependencies/detail/type_lists.hpp" +#include "serif/meta/enums.hpp" + +namespace serif::utils::dependencies { + enum class PreparationMode { + reuse_unchanged, + rebuild + }; + enum class PreparationStatus { + not_required, + reused, + computed + }; + + inline std::unordered_map preparation_status_name_map = meta::enum_to_enum_string_map(); + inline std::unordered_map preparation_mode_name_map = meta::enum_to_enum_string_map(); + + template + struct PreparationFailure { + std::size_t computation_index; + ErrorT error; + }; + + template + struct PreparationReport { + std::array::value> statuses{}; + std::array::value> changed_sources{}; + + /// Inspect a computation by its result key rather than an implementation index. + template requires detail::contains_key_v + [[nodiscard]] PreparationStatus status() const { + return statuses[detail::type_index(typename ResultsT::Keys{})]; + } + + /// An unused source is reported unchanged because it was not inspected by this request. + template requires detail::contains_key_v + [[nodiscard]] bool source_changed() const { + return changed_sources[detail::type_index(typename SourcesT::Keys{})]; + } + + /// Count only computations evaluated during this successful preparation. + [[nodiscard]] std::size_t computed_count() const { return std::count(statuses.begin(), statuses.end(), PreparationStatus::computed); } + + /// Count requested computations served from the accepted cache. + [[nodiscard]] std::size_t reused_count() const { return std::count(statuses.begin(), statuses.end(), PreparationStatus::reused); } + }; +} diff --git a/src/include/serif/utils/dependencies/resource.hpp b/src/include/serif/utils/dependencies/resource.hpp new file mode 100644 index 00000000..09e5cab7 --- /dev/null +++ b/src/include/serif/utils/dependencies/resource.hpp @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include +#include "serif/utils/dependencies/snapshot.hpp" + +namespace serif::utils::dependencies { + namespace detail { + template + struct OwnedValue { + using Target = T; + T value; + + /// Expose a source for read-only computation. + [[nodiscard]] const T& get() const { return value; } + }; + + template + struct BorrowedValue { + using Target = T; + std::reference_wrapper value; + + /// Borrowing does not extend the lifetime of the source. + [[nodiscard]] const T& get() const { return value.get(); } + }; + } + + template + class Resource { + public: + using Key = KeyT; + using Target = typename StorageT::Target; + using Policy = PolicyT; + + /// Keep storage and its tracking policy together under one role key. + Resource(StorageT storage, PolicyT policy) : m_storage(std::move(storage)), m_policy(std::move(policy)) {} + + /// Read the resource without allowing computations to mutate their inputs. + [[nodiscard]] const Target& get() const { return m_storage.get(); } + + /// Expose tracking separately from the value passed to a computation. + [[nodiscard]] const PolicyT& policy() const { return m_policy; } + + private: + StorageT m_storage; + PolicyT m_policy; + }; + + template + inline constexpr bool is_resource_v = false; + + template + inline constexpr bool is_resource_v> = true; + + template + concept IsResource = is_resource_v>; + + /// Store a value in the context; rvalues transfer ownership and lvalues are copied. + template + [[nodiscard]] auto own(ValueT&& value, PolicyT policy = {}) { + using Storage = detail::OwnedValue>; + return Resource(Storage{.value = std::forward(value)}, std::move(policy)); + } + + /// Bind an existing object whose lifetime must cover every use of the context. + template + [[nodiscard]] auto borrow(ValueT& value, PolicyT policy = {}) { + using Storage = detail::BorrowedValue; + return Resource(Storage{.value = std::cref(value)}, std::move(policy)); + } + + /// Borrowed storage must never refer to a temporary, including a const temporary. + template + auto borrow(const ValueT&&, PolicyT = {}) = delete; +} diff --git a/src/include/serif/utils/dependencies/snapshot.hpp b/src/include/serif/utils/dependencies/snapshot.hpp new file mode 100644 index 00000000..6169fc4f --- /dev/null +++ b/src/include/serif/utils/dependencies/snapshot.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace serif::utils::dependencies { + /// Default tracking owns an exact value copy; noncopyable resources supply another policy. + struct ValueSnapshot { + /// Keep the accepted value independent of subsequent edits to a borrowed source. + template requires std::copy_constructible && std::equality_comparable + [[nodiscard]] T capture(const T& value) const { return value; } + + /// Compare content rather than a hash that could silently miss a change. + template requires std::equality_comparable + [[nodiscard]] bool matches(const T& captured, const T& value) const { return captured == value; } + }; + + template + class ProjectedSnapshot { + public: + /// Own the policy callable so its configuration travels with the source. + explicit ProjectedSnapshot(FunctionT function) : m_function(std::move(function)) {} + + /// Capture a comparable value such as a resource revision or a tuple of revisions. + template requires std::invocable + [[nodiscard]] auto capture(const T& value) const { return std::invoke(m_function, value); } + + /// Recompute the tracking value without copying the resource itself. + template + requires requires(const CapturedT& captured, const ProjectedSnapshot& policy, const T& value) { + { captured == policy.capture(value) } -> std::convertible_to; + } + [[nodiscard]] bool matches(const CapturedT& captured, const T& value) const { return captured == capture(value); } + + private: + FunctionT m_function; + }; + + /// Select the state that determines whether a source needs recomputation. + template + [[nodiscard]] auto track_by(FunctionT&& function) { + return ProjectedSnapshot>(std::forward(function)); + } + + template + concept SnapshotPolicyFor = requires(const PolicyT& policy, const ValueT& value) { + policy.capture(value); + requires std::is_object_v; + requires std::move_constructible; + { policy.matches(std::declval(), value) } -> std::convertible_to; + } && (!requires(const PolicyT& policy, const ValueT& value) { policy.identity(value); } || + requires(const PolicyT& policy, const ValueT& value) { { policy.identity(value) } -> std::convertible_to; }); + + /// Views may identify their underlying resource; ordinary sources retain object identity. + template requires SnapshotPolicyFor + [[nodiscard]] const void* source_identity(const PolicyT& policy, const ValueT& value) { + if constexpr (requires { policy.identity(value); }) { return policy.identity(value); } + else { return std::addressof(value); } + } +} diff --git a/src/include/serif/utils/dependencies/timing.hpp b/src/include/serif/utils/dependencies/timing.hpp new file mode 100644 index 00000000..fd0c8e1d --- /dev/null +++ b/src/include/serif/utils/dependencies/timing.hpp @@ -0,0 +1,127 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "nameof.hpp" +#include "serif/utils/dependencies/observers.hpp" + +namespace serif::utils::dependencies { + /** @brief Wall time for attempted node work, independently of later cache or Newton acceptance. */ + struct NodeTiming { + std::size_t index{}; + std::string key_name; + std::uint64_t calls{}; + std::uint64_t completed{}; + std::uint64_t rejected{}; + std::uint64_t exceptions{}; + double seconds{}; + double maximum_seconds{}; + + /** @brief Closing a node must neither allocate nor fail during stack unwinding. */ + void finish(double duration, NodeOutcome outcome) noexcept { + ++calls; + seconds += duration; + maximum_seconds = std::max(maximum_seconds, duration); + switch (outcome) { + case NodeOutcome::completed: ++completed; break; + case NodeOutcome::rejected: ++rejected; break; + case NodeOutcome::exception: ++exceptions; break; + } + } + }; + + /** @brief One graph binding's declaration-ordered catalog, including nodes which never execute. */ + struct GraphTimingRun { + std::vector nodes; + }; + + /** + * @brief Collect independent graph bindings without mixing timers into numerical restart history. + * @note One cache executes its nodes serially, although each node may contain parallel kernels. + * Concurrent caches require separate collectors. Returned observers own their run; the collector + * need only outlive factory invocation. Formatting and sorting belong outside timed execution. + */ + class GraphTiming { + public: + /** @brief Inspect completed and active measurements without changing their catalog or indices. */ + [[nodiscard]] const std::vector>& runs() const noexcept { return m_runs; } + + /** @brief Allocate names and counters once, before any measured node begins. */ + template + [[nodiscard]] std::shared_ptr start_run(std::type_identity) { + auto run = std::make_shared(); + run->nodes = [](KeyList) { + return std::vector{NodeTiming{.index = GraphT::template index(), + .key_name = std::string{nameof::nameof_type()}}...}; + }(typename GraphT::Results{}); + m_runs.push_back(run); + return run; + } + + private: + std::vector> m_runs; + }; + + /** @brief Read monotonic elapsed wall time without changing physical computations. */ + struct SteadyNodeClock { + /** @brief Use one nonthrowing time source for node entry and exit. */ + [[nodiscard]] std::chrono::steady_clock::time_point operator()() const noexcept { + return std::chrono::steady_clock::now(); + } + }; + + namespace detail { + template + inline constexpr bool node_time_point_v = false; + + template + inline constexpr bool node_time_point_v> = std::is_arithmetic_v; + } + + /** @brief Restrict injected clocks to ordinary arithmetic chrono values safe in a closing callback. */ + template + concept NodeTimingClock = std::is_nothrow_invocable_v && + detail::node_time_point_v>; + + /** @brief Bind one generic timing observer after the model's complete graph type is known. */ + template + struct GraphTimingFactory { + GraphTiming& timing; + std::shared_ptr clock; + + /** + * @brief Include edge callbacks and the node callable, excluding source checks and cache publication. + * @note Reused or unrequested nodes produce no sample. Keep regular wall/linear-solve measurements + * alongside these durations: action applications generally execute after graph preparation. + */ + template + [[nodiscard]] auto operator()(std::type_identity graph) const { + const auto run = timing.start_run(graph); + using TimePoint = std::invoke_result_t; + struct Invocation { + std::size_t index; + TimePoint started; + }; + return make_observers(observe_nodes( + [clock = clock](std::type_identity) noexcept { + return Invocation{.index = GraphT::template index(), .started = std::invoke(*clock)}; + }, + [run, clock = clock](Invocation& invocation, NodeOutcome outcome) noexcept { + const double duration = std::chrono::duration{std::invoke(*clock) - invocation.started}.count(); + run->nodes[invocation.index].finish(duration, outcome); + })); + } + }; + + /** @brief Opt into node timings; a custom clock supports deterministic accounting tests without sleeps. */ + template + [[nodiscard]] auto time_graph_nodes(GraphTiming& timing, Clock clock = {}) { + return GraphTimingFactory{.timing = timing, .clock = std::make_shared(std::move(clock))}; + } +}