Note for now much of the docs is LLM written. This was done to save time and focus on code development. The docs have been LOOSLEY reviewed. However, the code itself has been either fully written by hand or fully reviewed. Therefore, the docs should at the moment be seen as a loose resource rather than authoratiative (and this is on top of the general ability for docs to drift)
16 KiB
Computational graphs
serif::utils::dependencies is a computational graph library: computations are
nodes, and their input keys declare incoming edges from sources or other nodes.
The existing namespace and API names are retained. The library is independent of
MFEM, equations, surfaces, and deformation. A context supplies external inputs;
a graph owns the computations and their execution order; a cache owns that graph,
source snapshots, and accepted results.
A complete calculation
#include <array>
#include "serif/utils/dependencies/dependencies.hpp"
/// Compute b = lambda * a, then reuse it while both inputs remain unchanged.
int main() {
namespace dependencies = serif::utils::dependencies;
namespace keys = dependencies::keys;
using Vector = std::array<double, 3>;
Vector a{1.0, 2.0, 3.0};
double lambda = 2.0;
const auto context = dependencies::make_dependency_context(
dependencies::borrow<keys::Input>(a),
dependencies::borrow<keys::Scale>(lambda)
);
const auto scale_vector = dependencies::make_computation<
keys::Output, dependencies::InputList<keys::Input, keys::Scale>
>(
[](const Vector& input, double scale) -> Vector {
return {scale * input[0], scale * input[1], scale * input[2]};
}
);
auto cache = dependencies::make_computation_cache(context, scale_vector);
auto report = cache.try_prepare(context);
if (!report) {
return 1;
}
const Vector b = cache.get<keys::Output>(); // <2, 4, 6>
report = cache.try_prepare(context);
if (!report) {
return 1;
}
return report->status<keys::Output>() == dependencies::PreparationStatus::reused ? 0 : 1;
}
Creating the cache does not run the calculation or retain the context. The first
preparation computes the result. Further preparations compare input snapshots and
reuse the result while both a and lambda remain unchanged. Changing either one
requires recomputation. Keep the cache between calls so it can retain this state.
The dependency sandbox prints the values, per-node computation/reuse status, and elapsed time through those cases.
Keys and ordered input lists
A key is a type identifying a role, independently of the type of value stored for
that role. keys.hpp offers keys::Input, keys::Output, keys::Scale,
keys::Parameters, and keys::Coordinates for small computations. These keys are
optional; custom types and existing discretization term descriptors work without
registration. A key need not be empty or constructible, because no key object is
created. Use distinct application keys when a graph has several different inputs
or outputs; giving two results the same convenience key is still a duplicate.
InputList<A, B> passes the values for A and B to a callable in that order.
RequestedResults<X, Y> selects which outputs to prepare. Both are aliases of the
same KeyList implementation, which also supports context selection and key
combination. CombinedKeyList removes duplicate roles in first-appearance order.
Multiple computations and explicit graphs
The ordinary factory accepts any number of named computations:
auto cache = dependencies::make_computation_cache(context, first, second);
Their supplied order is unrestricted. Dependency relationships establish the evaluation order at compile time; each required computation runs at most once per preparation. For a named or reusable collection, build an explicit graph:
const auto graph = dependencies::make_computation_graph(first, second);
auto cache = dependencies::make_computation_cache(graph, context);
auto report = cache.try_prepare(context, dependencies::RequestedResults<FinalResult>{});
Named copyable computations and graphs are copied into their owner, so ordinary
calls need no std::move. Temporaries transfer ownership. A named computation or
graph owning noncopyable state requires an explicit transfer with std::move.
The factories reject unavailable copies at compile time.
Observing execution and input consumption
Observers belong to the cache that executes a graph. Node observers run before and
after a calculation; edge observers inspect the inputs that calculation consumes.
The graph and its numerical computations do not need to know about observation.
Existing factory calls remain valid; an explicit graph accepts an optional observer
bundle as the third argument to make_computation_cache.
Continuing the vector-scaling example, include <chrono> and <cstddef>, and attach
a timer to the output node and a callback to its scale input:
using Clock = std::chrono::steady_clock;
std::chrono::nanoseconds elapsed{};
std::size_t executions = 0;
double last_scale = 0.0;
dependencies::NodeOutcome last_outcome = dependencies::NodeOutcome::completed;
const auto observers = dependencies::make_observers(
dependencies::observe_node<keys::Output>(
[]() { return Clock::now(); },
[&](Clock::time_point& started, dependencies::NodeOutcome outcome) noexcept {
elapsed += std::chrono::duration_cast<std::chrono::nanoseconds>(Clock::now() - started);
++executions;
last_outcome = outcome;
}
),
dependencies::observe_edge<keys::Scale, keys::Output>(
[&](const double& scale) { last_scale = scale; }
)
);
const auto graph = dependencies::make_computation_graph(scale_vector);
auto observed_cache = dependencies::make_computation_cache(graph, context, observers);
const auto observed_report = observed_cache.try_prepare(context);
The cache owns its callbacks. Each before callback returns a movable object that
belongs to that one execution. The matching after callback receives this same
object by reference, which gives a timer or other paired operation local storage
without adding state to the numerical computation. Accumulated measurements belong
to the application, as the captures above illustrate. Captured references must
outlive every use of the cache; an owning capture may be used instead. Named,
noncopyable observer bundles must be transferred with std::move.
A node execution has the following order:
- Matching before callbacks run in observer registration order.
- Edge callbacks run in the node's declared
InputListorder, with callbacks for the same edge running in registration order. - The numerical callable runs.
- Matching after callbacks run in reverse registration order.
An edge is identified by input key, then consuming node key. The input can be an external source or another node's result. Each callback receives the underlying value by const reference, including for owning computations whose numerical callable receives shared handles. An unchanged prerequisite can therefore have an edge callback when its consumer recomputes. Callbacks must not modify source or result state, including objects reachable through pointers. Callbacks must not prepare, invalidate, or move the cache whose execution they are observing. Numerical transformations belong in declared computations.
No callbacks run for reused or unrequested nodes. Node keys, actual graph edges, and callback signatures are checked at compile time. Multiple observers may attach to the same node or edge. A node observer needs both before and after callbacks; return a small empty object from before when no per-execution data is needed.
NodeOutcome::completed means the callable returned a value, rejected means it
returned an unsuccessful std::expected, and exception means execution exited
through an exception, including from an edge callback. These describe individual
execution, not publication of the request: a later node can still fail and
cause the whole preparation to be rejected. Inspect the final preparation report
when recording accepted requests.
After callbacks must return void and be noexcept, so they can safely finish
during exception propagation. Before and edge callbacks may propagate exceptions;
the cache retains the same transactional protection as numerical-callable failures.
If a before callback fails, its after callback does not run; already-started node
observers finish with exception. Observation side effects, such as recorded
measurements, are not rolled back. The sandbox records measurements in callbacks
and prints them afterward, keeping potentially throwing output out of after hooks.
Operator caches
The MFEM operator backend uses these ordinary caches directly. Its volume
factories declare domain-specific calculations and return ComputationCache objects;
there is no second lifecycle owner around them. Input-validation helpers construct
current contexts before invoking the cache. Residual and Jacobian functions consume
the accepted data, and the MFEM adapter borrows the cache. The
operator guide shows a complete volume
example.
Where things belong
| Component | Responsibility |
|---|---|
keys.hpp |
Optional general role names; applications may supply their own. |
binding.hpp, key_list.hpp |
Associate role keys with target types and express ordered inputs or requested results. |
key_list_operations.hpp |
Combine key lists without duplicating roles. |
resource.hpp, dependency_context.hpp |
Supply owned or borrowed objects and select views of their roles. |
snapshot.hpp |
Specify which source state determines whether cached work remains valid. |
computation.hpp |
Attach result and input keys to a callable. |
computation_graph.hpp |
Store calculations and establish their dependency order. |
computation_cache.hpp, preparation.hpp |
Prepare requested results, retain accepted work, and report status or rejection. |
observers.hpp |
Attach execution callbacks to node and edge keys, with per-execution node state. |
detail/ |
Graph traversal, type resolution, and cache storage used by these interfaces. |
Computation and lifetime contracts
- A callable can be a lambda, function, or function object. It must be callable through a const reference, using its inputs as const references or copies. Each result must be a movable object, returned by value.
- Every changing input that affects a result belongs in its
InputList. Captures may hold fixed configuration. Changing state hidden behind captures, globals, or pointers cannot be discovered automatically. own<Key>(value)copies an lvalue or moves an rvalue.borrow<Key>(object)borrows an existing object; temporaries are rejected. The object must outlive the context's uses. Owning a view does not own its referenced data.context.select(KeyList<...>{})borrows its parent context and preserves the selected source identities.- Results and captured snapshots must retain their relevant state independently of subsequent source edits. An owning container works; a view into mutable source storage does not freeze that storage. Treat cached results and their referenced objects as read-only.
- Preparation is serial. Sources and callable configuration must remain stable
during each call. References obtained from
getare valid until the next preparation, invalidation, or destruction of their owner.
cache.share<Key>() retains an immutable result across later preparations,
rejection, invalidation, and cache destruction. It owns the result itself; anything
borrowed inside that result must still outlive its use. Both get and share
require the result to belong to the latest successful request.
Use make_owning_computation<Key, InputList<...>>(callable) when a result needs
to retain its computed prerequisites. Its inputs are immutable shared handles,
so retained results keep the exact prerequisite generation that produced them.
Every input to an owning computation must be produced by the same graph; external
context sources cannot be retained through this interface.
Tracking changes and reusing work
The default ValueSnapshot owns a copy and compares it using equality. It works
for numbers and value-owning standard containers. Objects with indirect state,
expensive copies, or no copy operation need an appropriate policy. A revision
counter can be used if its owner updates it for every relevant edit:
auto source = dependencies::borrow<Samples>(samples,
dependencies::track_by([](const auto& value) { return value.revision(); }));
This example assumes a source type providing revision(). For more control,
provide a policy with capture(const Source&) returning owned state and
matches(const Captured&, const Source&) returning a boolean. Snapshots also track
source identity: replacing a borrowed object forces recomputation even if its
tracked value is equal. A policy can override identity(const Source&) to return
const void* identifying the underlying object of a temporary view. Returning
nullptr deliberately opts into content-only comparison; the snapshot must then
include any identity relevant to correctness. Unused context objects need no
snapshot policy.
try_prepare(context) defaults to PreparationMode::reuse_unchanged and requests every result. RequestedResults<...>{} restricts
the request to those results and their prerequisites. Changed inputs invalidate
their dependents, including those requested in a later call. Other branches
retain their caches but are unavailable through get until requested again.
Accepted results stay frozen between preparations. When an input changes, the
computation and its required dependents run even if recomputation happens to produce
the same numerical output; result equality is not used to infer reuse.
The report's status<Key>() returns PreparationStatus::not_required, reused,
or computed. computed includes first evaluation and reevaluation; not_required
means outside the requested results and their prerequisites. The report also
provides source_changed<Key>(), computed_count(), and reused_count().
An uninspected source is reported unchanged.
Pass PreparationMode::rebuild as the third argument to refresh the requested
results and their prerequisites, or call invalidate() to discard the whole
cache. Forced refreshes also mark inspected sources changed. contains<Key>()
checks result availability; ready() reports a successful current preparation.
preparation_count() counts successful requests, including requests that reused every
result and requests for only part of a graph. Rejected or throwing attempts do not
increment it. Invalidation retains the count. Moving the cache transfers the count
and accepted state to the destination and resets the source to an unprepared state
with count zero. This counter describes accepted requests, not numerical work;
use the report to distinguish computation from reuse.
Rejection and compile-time checks
A computation can return std::expected<Value, Error> when it may reject an input.
Ordinary value-returning and fallible computations can share a graph. The default
error type is std::string; use make_computation_cache<Error>(context, computations...)
or make_computation_cache<Error>(graph, context) for another type. Each fallible
computation must return that exact error type, or an exact, unambiguous alternative
when the cache error is a std::variant. Implicit conversions between unrelated
error types are rejected. A failure contains the error
and the computation's index in the supplied graph; ComputationGraph::index<Key>()
gives the corresponding index for a known result key.
Preparation stages snapshots and results together. Success publishes all requested
results and their prerequisites. Rejection publishes none and makes results
unavailable until the next successful preparation. The previous accepted cache is
retained for possible reuse. Exceptions from callables or policies propagate with
the same cache protection. External side effects are not rolled back. A backend may
choose a stricter validation boundary: the MFEM try_prepare_* helpers invalidate
storage on rejection so that a later retry starts fresh. That policy is explicit
in the backend and does not change the generic cache's behavior.
Duplicate context roles, duplicate producers, and cycles fail at construction.
ComputationGraphFor<Graph, Context> checks input availability, source/result key
collisions, callable signatures, and result types. CanPrepare<Graph, Context, Error>
also checks snapshot policies and error compatibility. Both cache factories apply
these checks. Requests are constrained to result keys. Reading an unavailable
result throws std::logic_error because it indicates a caller sequencing error.
The general dependency module owns snapshots, accepted results, and rejection handling; it contains no MFEM types.