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

311 lines
16 KiB
Markdown

# 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
```cpp
#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](../../tests/sandbox/dependencies_sandbox.cpp) 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:
```cpp
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:
```cpp
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:
```cpp
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](operators.md#model-ownership-and-startup) 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:
```cpp
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.