From ba457e7d100d3d5ae8ef06d0514a17a3b1ca2838 Mon Sep 17 00:00:00 2001 From: Emily Boudreaux Date: Mon, 9 Nov 2026 13:38:55 -0500 Subject: [PATCH] feat(utils/dependencies): Added computational graph system The initial prototype for this code used a number of bespoke so-called "prepared" operators to avoid recomputation of values which we could be sure would not change. However, this system was hard to scale. Here we implement an equivalent, but much more scalable, solution through a computational graph which keeps track of inputs and outputs to each node and only reruns the node when the inputs have changed. Note that this is a simplistic optimization scheme, we are do not check for example if the outputs change strongly or even at all with different inputs (generally that would require derivatives of the outputs wrt. each input) nor do we do any sort of graph optimization. --- .../serif/utils/dependencies/binding.hpp | 92 +++++ .../serif/utils/dependencies/computation.hpp | 58 +++ .../utils/dependencies/computation_cache.hpp | 331 ++++++++++++++++++ .../utils/dependencies/computation_graph.hpp | 76 ++++ .../serif/utils/dependencies/declerations.hpp | 0 .../serif/utils/dependencies/dependencies.hpp | 170 +++++++++ .../utils/dependencies/dependency_context.hpp | 124 +++++++ .../dependencies/detail/cache_storage.hpp | 36 ++ .../dependencies/detail/cache_traits.hpp | 64 ++++ .../serif/utils/dependencies/detail/graph.hpp | 97 +++++ .../utils/dependencies/detail/result.hpp | 48 +++ .../utils/dependencies/detail/schema.hpp | 73 ++++ .../utils/dependencies/detail/type_lists.hpp | 67 ++++ .../serif/utils/dependencies/key_list.hpp | 53 +++ .../dependencies/key_list_operations.hpp | 26 ++ src/include/serif/utils/dependencies/keys.hpp | 17 + .../serif/utils/dependencies/observers.hpp | 282 +++++++++++++++ .../serif/utils/dependencies/preparation.hpp | 55 +++ .../serif/utils/dependencies/resource.hpp | 76 ++++ .../serif/utils/dependencies/snapshot.hpp | 63 ++++ .../serif/utils/dependencies/timing.hpp | 127 +++++++ 21 files changed, 1935 insertions(+) create mode 100644 src/include/serif/utils/dependencies/computation.hpp create mode 100644 src/include/serif/utils/dependencies/computation_cache.hpp create mode 100644 src/include/serif/utils/dependencies/computation_graph.hpp delete mode 100644 src/include/serif/utils/dependencies/declerations.hpp create mode 100644 src/include/serif/utils/dependencies/dependencies.hpp create mode 100644 src/include/serif/utils/dependencies/dependency_context.hpp create mode 100644 src/include/serif/utils/dependencies/detail/cache_storage.hpp create mode 100644 src/include/serif/utils/dependencies/detail/cache_traits.hpp create mode 100644 src/include/serif/utils/dependencies/detail/graph.hpp create mode 100644 src/include/serif/utils/dependencies/detail/result.hpp create mode 100644 src/include/serif/utils/dependencies/detail/schema.hpp create mode 100644 src/include/serif/utils/dependencies/detail/type_lists.hpp create mode 100644 src/include/serif/utils/dependencies/key_list.hpp create mode 100644 src/include/serif/utils/dependencies/key_list_operations.hpp create mode 100644 src/include/serif/utils/dependencies/keys.hpp create mode 100644 src/include/serif/utils/dependencies/observers.hpp create mode 100644 src/include/serif/utils/dependencies/preparation.hpp create mode 100644 src/include/serif/utils/dependencies/resource.hpp create mode 100644 src/include/serif/utils/dependencies/snapshot.hpp create mode 100644 src/include/serif/utils/dependencies/timing.hpp 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))}; + } +}