Files
MeanField_SERiF/docs/static/dependencies.md
Emily Boudreaux 6b57738330 docs(docs): built docs
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)
2026-11-09 14:09:44 -05:00

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:

  1. Matching before callbacks run in observer registration order.
  2. Edge callbacks run in the node's declared InputList order, with callbacks for the same edge running in registration order.
  3. The numerical callable runs.
  4. 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 get are 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.