Files
MeanField_SERiF/docs/static/operators.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

588 lines
36 KiB
Markdown

# Applying equations as discrete operators
An equation describes local physics and its chosen weak formulation. An operator
evaluates that description on a discretization to produce a residual `R(u)` and a
Jacobian action `J(u) du`. New physics using supported operands needs an equation,
not another bespoke operator.
The MFEM volume backend supports global scalar VALUE, scalar H1/L2 VALUE, scalar H1 GRADIENT and vector H1
VALUE/DIVERGENCE/GRADIENT operands and tests on three-dimensional conforming serial meshes.
Scalar equations, mass and angular-momentum constraints, and primal gravity use the same volume graph and numerical application functions.
Surface integrals,
H(div), H(curl), and parallel assembly remain outside the current scope.
## Where things belong
| Responsibility | Implementation |
| --- | --- |
| Physical laws, parameters, admissibility, local partial derivatives | [`equations`](../src/include/serif/equations/) |
| Existing field and term identities, spaces, supports, operands | [`discretization`](../src/include/serif/discretization/) |
| Bind a spatial term to an MFEM space or a global scalar to its mesh | [`TermBinding`](../src/include/serif/backend/mfem/discretization/term_binding.hpp) |
| Supported coefficient indices, orientation, extraction, assembly | [`DofMap`](../src/include/serif/backend/mfem/discretization/dof_map.hpp) |
| Traverse an equation's additive contributions in declaration order | [`operators/contributions.hpp`](../src/include/serif/operators/contributions.hpp) |
| Interpret operand order, row support, and quadrature forms | [`EquationPlan`](../src/include/serif/operators/plan.hpp) |
| Bind physical terms to coefficient vectors in an ordinary dependency context | [`CoefficientView`, `make_term_coefficients`](../src/include/serif/backend/mfem/discretization/term_coefficients.hpp) |
| Verify and reuse canonical tensor factors | [`TensorBasisCache`](../src/include/serif/backend/mfem/discretization/tensor_basis.hpp) |
| Contract a dense basis or tensor factors with pointwise maps | [`BasisEvaluation`](../src/include/serif/backend/mfem/discretization/basis_evaluation.hpp) |
| Evaluate a supported operation and apply its basis or transpose | [`OperandEvaluator`](../src/include/serif/backend/mfem/operators/operand_evaluation.hpp) |
| Name volume stages and describe their fixed configuration and results | [`volume_data.hpp`](../src/include/serif/backend/mfem/operators/volume_data.hpp) |
| Sample bases and operands, map geometry, and evaluate one element's equation | [`element_preparation.hpp`](../src/include/serif/backend/mfem/operators/element_preparation.hpp) |
| Apply these stages over one contribution's supported elements | [`volume_stages.hpp`](../src/include/serif/backend/mfem/operators/volume_stages.hpp) |
| Validate and describe current volume sources | [`volume_inputs.hpp`](../src/include/serif/backend/mfem/operators/volume_inputs.hpp) |
| Declare volume graph nodes and return an ordinary dependency cache | [`volume_computations.hpp`](../src/include/serif/backend/mfem/operators/volume_computations.hpp) |
| Consume accepted volume data and apply its Jacobian or transpose | [`volume_application.hpp`](../src/include/serif/backend/mfem/operators/volume_application.hpp) |
| Integrate and differentiate accepted element data | [`element_evaluation.hpp`](../src/include/serif/backend/mfem/operators/element_evaluation.hpp) |
| Compare exact MFEM coefficient values and persistent binding identities | [`CoefficientTracking`](../src/include/serif/backend/mfem/discretization/coefficient_tracking.hpp) |
| Track changes, retain accepted results, and publish successful preparations | [`utils::dependencies`](dependencies.md) |
| Expose the frozen linearization to MFEM solvers | [`JacobianOperator`](../src/include/serif/backend/mfem/operators/jacobian_operator.hpp) |
The volume path separates input construction, graph declarations,
accepted data, and numerical application. The dependency cache is their only
preparation owner. Operators consume the equation interface; concrete physics
headers do not depend on operators or MFEM. Geometry and quadrature continue to use
the existing mapping and discretization modules.
A binding identifies a **term**, not a new field catalog and not an operand operation.
Gravity's potential and gradient are different terms. Multiple operations on a term
share one coefficient vector and one packed Jacobian column. `Plan::Operands` lists
unique sampled operations; `Plan::Columns` lists unique coefficient terms.
## Reading the calculation
`make_contribution_computations` in `volume_computations.hpp` reads in numerical
order: reference bases, geometry, operand samples, then equation values. Its short
lambdas call the ordinary numerical functions in `volume_stages.hpp` and
`element_preparation.hpp`. The declarations themselves do no numerical work;
`try_prepare` runs the required nodes later.
The contribution index distinguishes repeated equations, including repeated instances
of the same type. Each node returns data for the contribution's supported elements;
there is no separate C++ graph node for every mesh point.
| Result key (`Basis`, `Geometry`, and sample keys are in `backend::volume_keys`) | Declared numerical dependencies |
| --- | --- |
| `Basis<I>` | `MeshAndCoefficientBindings`: fixed-mesh coordinate sequence and binding identities; equation and integration options are fixed configuration. |
| `Geometry<I>` | `Basis<I>`, displacement coefficients, compactification coefficients. |
| `ReferenceValues<I, Operand>` | `Basis<I>` and the coefficient source identified by `Operand::term_type`. |
| `PhysicalValues<I, Operand>` | That operand's reference values and `Geometry<I>`; present only when the operand's physical representation depends on geometry. |
| `QuadratureLinearization<I>` | Basis, geometry, and each declared operand's reference or physical values. Residuals, derivatives, weights, and contribution scaling are computed together. |
| `Linearization` | Mesh and binding metadata and every contribution's `QuadratureLinearization`; retain ordered batches, assemble the residual, and validate complete integrals. |
There are no manual change flags inside these numerical stages. The dependency
engine follows the declared inputs to decide which stages need execution. For scalar VALUE,
```text
u(q) = sum_j E(q,j) c(j)
z(q) = W(q) f(u(q), x(q))
R_element = E_test^T z
```
`element_residual` performs the last contraction. `element_jacobian_action` differentiates
the same expression. Coefficients, explicit physical position, integration weight,
and (for gradients and H1 divergence) both operand and test bases vary. Scalar and vector H1 value bases stay fixed.
The evaluator's `evaluate` method and its transpose contraction work for all supported
operations by flattening a vector sample's components into three rows per point.
`OperandValue<Operand>` describes the sampled shape using **both** term rank and
operation: `Value(u)` of a vector is `std::array<double, 3>`, while `Divergence(u)` is `double`.
`PointLinearization<Operands, Result>` supplies scalar/vector/matrix partials of the
appropriate shape; the default result remains scalar for existing equations.
There are two maps. MFEM first maps from its canonical element to the fixed
computational mesh: RT `CalcVShape(transformation, ...)` and `CalcPhysDivShape(...)`
already include this map. `OperandEvaluator` then applies SERiF's additional map
`A = dx/dX`, `j = det(A)`: `g_x = A g_X / j`, `div_x(g_x) = div_X(g_X) / j`.
The same transformation is applied to the test basis. Geometry variations include
`delta(A g_X / j)` and `delta(div_X(g_X) / j)` at fixed coefficients, on both sides
of the contraction. Cached fixed-mesh samples make these variations independent of
subsequent changes to the caller's coefficient vectors.
H1 divergence uses a different transformation: `div_x(u) = trace(G A^-1)`, where
`G[c,k] = partial_X_k(u_c)`. Its evaluator retains all nine entries of `G` internally,
then produces one scalar per physical quadrature point. A scalar reference divergence
alone would lose the information needed under shear or anisotropic dilation. The
geometry derivative is `trace(G delta(A^-1))`, using the existing mapping variation.
Consequently an evaluator's reference sample and physical value can have different
shapes. It declares both sizes, reads its own reference samples, and advertises
`geometry_dependent`. Element assembly uses those declarations without branching on
a particular finite-element family. Coefficient evaluation and test integration
still share the same matrix-vector and transpose contractions.
`prepare_volume` validates the current coefficients, constructs their dependency
context, and asks the ordinary dependency cache to prepare the graph. The final
`volume_keys::Linearization` result owns the assembled residual and each contribution's
immutable element data. Ordinary application functions consume this result; there is
no second prepared-operator lifecycle.
Residual and Jacobian calls do not evaluate the EOS or silently prepare new values.
Packed columns remain unique terms in first-appearance order (`Plan::Columns`),
followed by displacement.
## Model ownership and startup
The model owns caches directly. Fixed discretization, current coefficient inputs,
and integration choices have separate names. With bindings, coefficient vectors,
and the mapping already constructed, one volume row can be set up as follows:
```cpp
namespace dependencies = serif::utils::dependencies;
namespace backend = serif::operators::backend::mfem;
namespace equations = serif::equations;
const auto closure_equation = equations::BarotropicDensityBalance{equation_of_state};
using Closure = std::remove_cvref_t<decltype(closure_equation)>;
const backend::VolumeDiscretization closure_discretization{
.residual_binding = density_binding,
.displacement_binding = displacement_binding,
.compactification = compactification,
.mapper = mapper
};
const auto term_coefficients = backend::make_term_coefficients(
backend::bind(density_binding, density),
backend::bind(potential_binding, potential),
backend::bind(surface_level_binding, surface_level),
backend::bind(angular_velocity_binding, angular_velocity)
);
const auto closure_inputs = backend::make_volume_inputs<Closure>(
closure_discretization, term_coefficients, displacement
);
auto closure_cache = backend::make_volume_equation_cache(
closure_equation, closure_discretization, closure_inputs
);
auto& closure = closure_cache;
// Repeat preparation with the current coefficients during nonlinear iterations.
backend::prepare_volume(closure, closure_discretization, term_coefficients, displacement);
const auto& linearization = closure.get<backend::volume_keys::Linearization>();
backend::volume_residual(linearization, density_residual);
backend::apply_jacobian(linearization, direction, displacement_direction, workspace, action);
backend::JacobianOperator jacobian(closure);
```
The snippets use `<type_traits>` for `std::remove_cvref_t` and `<utility>` for
`std::move`. The [equation sandbox](../../tests/sandbox/equations_sandbox.cpp) supplies
the complete manufactured equilibrium.
`VolumeOptions` optionally supplies named `quadrature_policy` and `contraction_mode`
members as the factory's fourth argument.
Cache construction declares work without evaluating physics. The graph owns the
equation, integration options, and reusable preparation scratch. Meshes, spaces,
term bindings, compactification, and mapper are borrowed and must outlive its uses.
Coefficient contexts own views, not the coefficient vectors. Keep those vectors
alive while preparing; accepted numerical results do not borrow their contents.
The workspace used for Jacobian application remains caller-owned and is not a
tracked dependency.
`make_volume_inputs` captures current mesh/binding metadata and borrows current
coefficients. Rebuild this context when the discretization changes. Calling
`prepare_volume` or `try_prepare_volume` does that explicitly on every call and
validates before requesting the complete linearization. Calling `cache.try_prepare`
directly is also available with a current, validated context; it is the generic
engine's interface and permits partial result requests.
Moving a cache after an adapter borrows it invalidates that reference. The cache must outlive
its adapter. The adapter reads the cache's latest accepted linearization on every
call, so rejection or a request that omits that linearization makes application
unavailable. References returned by `cache.get` must be reacquired after preparation
or invalidation.
Two adapters can share one cache, while separate caches retain independent
linearizations. Separate caches are not connected or published as one transaction; a
controller prepares them explicitly. Shared intermediates can be declared in a common
graph when that becomes useful.
## Equation contributions
An equation may describe itself as an additive tuple returned by `contributions()`; the
primal Poisson balance does so for its flux and source. Contributions share the same
residual term, while leaves retain independent test operations, supports, and
quadrature rules. `ContributionsOf` collects leaf types for concept checks and operand
planning. `operators::for_each_contribution` visits the corresponding leaf objects in
declaration order, borrowing the owned objects, including equations that cannot be copied.
## Global scalars: mass, angular momentum, C, and Ω
`barotropic_constant::mass_normalization` stores the unknown Bernoulli constant C and
receives the mass constraint. `rotation::angular_velocity` stores the signed angular
speed Ω about a prescribed axis and center. They are explicitly declared
`GlobalScalarTerm`s, with one coefficient each and no finite-element space. Other
nonspatial descriptors, such as surface-deformation coefficients, are not implicitly
admitted as scalars.
```cpp
using C = serif::discretization::blocks::barotropic_constant::mass_normalization;
using Omega = serif::discretization::blocks::rotation::angular_velocity;
using serif::discretization::backend::mfem::TermBinding;
namespace backend = serif::operators::backend::mfem;
TermBinding<C, Schema> c_binding(mesh);
TermBinding<Omega, Schema> omega_binding(mesh);
mfem::Vector c(1), omega(1);
c = surface_level;
omega = angular_velocity;
const auto term_coefficients = backend::make_term_coefficients(
backend::bind(density_binding, density),
backend::bind(potential_binding, potential),
backend::bind(c_binding, c),
backend::bind(omega_binding, omega)
);
const backend::VolumeDiscretization mass_discretization{
.residual_binding = c_binding,
.displacement_binding = displacement_binding,
.compactification = compactification,
.mapper = mapper
};
const auto mass_inputs = backend::make_volume_inputs<serif::equations::MassNormalization>(
mass_discretization, term_coefficients, displacement
);
auto mass = backend::make_volume_equation_cache(
serif::equations::MassNormalization{target_mass}, mass_discretization, mass_inputs
);
backend::prepare_volume(mass, mass_discretization, term_coefficients, displacement);
const backend::VolumeDiscretization angular_momentum_discretization{
.residual_binding = omega_binding,
.displacement_binding = displacement_binding,
.compactification = compactification,
.mapper = mapper
};
const auto angular_momentum_inputs = backend::make_volume_inputs<serif::equations::AngularMomentum>(
angular_momentum_discretization, term_coefficients, displacement
);
auto angular_momentum = backend::make_volume_equation_cache(
serif::equations::AngularMomentum{target_momentum, axis, center},
angular_momentum_discretization, angular_momentum_inputs
);
backend::prepare_volume(angular_momentum, angular_momentum_discretization, term_coefficients, displacement);
```
The scalar basis is one at every quadrature point. The same basis contractions,
geometry derivatives, coefficient validation, frozen snapshots, and packed adapter
serve spatial and global terms. A scalar row sums every supported element into its
single entry; there is no artificial order-zero FE space or per-element scalar DOF.
Global scalar reductions compensate cancellation at quadrature points and across
elements, preserving the old column-sum accuracy. Its mesh, support, and lifetime
checks still apply. Preparation also rejects overflow in the integrated scalar
residual, including its offset, before publishing the candidate. Calling `.space()` on a global
binding is an error; common assembly uses `.mesh()` and `.full_size()`.
`MassNormalization` evaluates rho over the stellar domains. Its `residual_offset()`
returns `-target_mass`, which final volume assembly adds **once after integration**.
This optional hook is restricted to global scalar rows and describes a constant,
state-independent offset. Arithmetic scales the offset along with its contribution;
the offset has no density or geometry derivative. The mass row depends on density
and displacement, not on C directly. C influences the density-balance row.
[`AngularMomentum`](../src/include/serif/equations/angular_momentum.hpp) receives the
Ω row and evaluates `rho Omega s^2`, where `s` is distance from the prescribed
physical axis through `center`. Its offset is `-target_momentum`. The target must be
finite and nonnegative; trial angular speeds may be signed or zero. It reuses
`RigidRotation` for `s^2 = 2 Psi_unit` and its position derivative. The equation
supplies `dF/drho = Omega s^2`, `dF/dOmega = rho s^2`, and
`dF/dx = 2 rho Omega grad(Psi_unit)`; the existing backend differentiates volume.
An equation may also define `validation_integrand(point, linearization)` and
`validate_integral(value)`. During the existing quadrature loop the backend sums
that scalar measure with the physical integration weight, then validates its total
over the contribution's support before publishing the preparation. Throwing
`std::domain_error` rejects the trial state. Each leaf is validated separately. Angular momentum uses its already computed
`dF/dOmega` to reject a nonfinite or negative total moment of inertia, including
at Ω = 0; it does not require every local trial density to be positive.
The density-balance constructor accepts an axis (default z) and center (default zero),
not C or Ω. The axis is normalized once. The balance reads Ω and C from the state.
It implements `d(A(rho) + Phi - Psi - C)/dC = -1` and `dPsi/dOmega = 2 Omega Psi_unit`.
These derivatives are defined at Ω = 0 without dividing by Ω. `RigidRotation` remains a
fixed-vector utility for independent calculations.
Packed density-balance columns are `rho, Phi, C, Omega, displacement`. Mass columns are
`density, displacement`; angular-momentum columns are `density, Omega, displacement`.
Each global scalar column occupies one entry. Directions
must bind the same scalar descriptors, including an explicit zero when a control is
held fixed. Reprepare after changing its value, as with spatial coefficients.
The mass port retains the old `1e-12` integral comparison, `5e-7` affine scaling,
`3e-8` density finite difference at step `1e-3`, `2e-7` geometry finite difference at
step `1e-6`, `2e-11` independent complete action, and `2e-15` column sum.
Serial transpose actions and preparation work reports are available. MPI reductions
and coupled root-layout routing remain deferred.
The angular-momentum port retains the old `2e-13` moment comparison, `7e-7`
fifth-power dilation law, `3e-13` parallel-axis identity, `2e-11` independent action,
`3e-15` column sum, and `2e-15` angular-speed action comparison. Density, geometry,
and angular-speed differences retain thresholds `4e-8`, `4e-7`, and `2e-10` at
steps `1e-3`, `1e-6`, and `1e-6`. Tests exercise tilted and displaced axes, both
element types and vector orderings, a curved star, frozen snapshots, rejection and
recovery, and default quadrature. Serial cache behavior is checked through the shared preparation reports. Distributed/root-layout
tests await their corresponding infrastructure.
The supported physical part of the old rotating-system test also checks one shared
Ω across density-balance and invariant rows (`3e-7` and `3e-10` at step `1e-5`),
its zero columns in mass, the test closure, and gravity, and the different
zero-speed limits. Seed generation, surface projection, and root manifests are not
part of this volume-row port.
## Prescribed surface pressure
[`Isobaric`](../src/include/serif/geometry/surface/isobaric.hpp) prescribes a typed
`PressureValue`. It describes the physical condition without selecting enthalpy or
any other field. The model's surface compiler selects a formulation from the
available thermodynamic fields, solved equation rows, and EOS relations:
1. Use a pressure unknown directly when one is available.
2. Otherwise use a declared inverse EOS relation to resolve a boundary field.
3. Otherwise evaluate a declared forward pressure relation and subtract the target.
An unavailable or ambiguous choice is rejected at compile time. The compiler uses
the EOS's declared relations; it does not numerically invert an EOS. A preferred
surface field can narrow the choice when the physical model admits several rows.
See [`AutomaticPressureSurface`](../src/include/serif/geometry/surface/automatic.hpp).
For a polytrope using density, the selected condition reduces to
```text
R_i = rho_i - rho_EOS(P_surface)
delta R_i = delta rho_i
```
Other EOS relations may depend on temperature, composition, or other bound fields.
Their values and derivatives are evaluated from the current boundary state. Zero
pressure also goes through the selected relation; its carrier value need not be zero.
A pressure unknown needs no EOS evaluation at all.
The finite-element backend compiles traces of the required fields at the selected
carrier nodes on `StellarSurfaceBoundary`. These fields may have different spaces
and polynomial orders. The scalar carrier must have a supported nodal H1 row;
Bernstein/positive coefficients are rejected because they are not point values.
For standalone operator work, the shared boundary graph can be used directly.
Here `Formulation` is the selected formulation, `eos_source` explicitly tracks the
EOS parameters, and `coefficients` binds the fields required by that formulation:
```cpp
namespace physics = serif::geometry::surface;
namespace surface = physics::backend::mfem;
namespace dependencies = serif::utils::dependencies;
const auto boundary = surface::make_pressure_boundary<Formulation>(coefficients);
const auto inputs = surface::make_pressure_boundary_inputs(
boundary, eos_source,
physics::Isobaric{serif::dimensions::PressureValue{0.03125}}, coefficients
);
auto cache = dependencies::make_computation_cache<physics::SurfaceFailure>(
surface::make_pressure_boundary_graph<Formulation>(), inputs
);
const auto prepared = cache.try_prepare(inputs);
if (prepared) {
const auto& linearization = cache.get<physics::keys::Linearization>();
// First assemble the volume residual and Jacobian action into these vectors.
surface::apply_residual_rows(*boundary, linearization, residual);
surface::apply_jacobian_rows(*boundary, linearization, direction, action);
}
// Handle prepared.error() before using a result from a rejected trial.
```
The graph tracks pressure, EOS parameters, and field coefficients. Preparation
owns the evaluated residuals and derivatives until the next preparation, so later
input changes do not silently change an already prepared action. Invalid numerical
states return a `SurfaceFailure`; invalid or stale bindings remain programming
errors. Both `coefficients` and `direction` must use the bindings that compiled the
traces, and those bindings and borrowed values must remain alive.
Apply boundary replacement **after** volume assembly. It removes every volume
coupling at a selected row and inserts every derivative required by the chosen
surface relation. For the transpose, mask selected residual-dual entries before
applying the volume transpose, then add the boundary transpose to each affected
field. Interior rows remain untouched. There is no surface measure or quadrature
weight in this strong condition. The model's assembled operator performs this
composition; a standalone volume operator alone does not.
The pressure regressions retain the MeanField residual/action comparisons at
`1e-13`, the analytic closure/density-balance thresholds of `2e-12`, and the mass
threshold of `2e-11 * target_mass`. The constrained density-balance finite difference
retains step `1e-5` and threshold `2e-6`. Analytic boundary values and independent
MFEM boundary masks check the shared graph on tetrahedra, hexahedra, both vector
orderings, and a curved star. General surface tests also cover non-enthalpy EOS
relations, state-dependent couplings, transpose actions, cache invalidation,
rejection, and recovery.
## Selective serial preparation
Preparation defaults to `PreparationMode::reuse_unchanged`, matching the generic
dependency engine. Request a full refresh explicitly with `PreparationMode::rebuild`:
```cpp
backend::prepare_volume(closure, closure_discretization, term_coefficients, displacement);
const auto report = backend::volume_work_report(closure);
// report.geometry_points, sampled_operands, physics_points and reused_elements
backend::prepare_volume(
closure, closure_discretization, term_coefficients, displacement,
backend::PreparationMode::rebuild
);
```
The dependency engine owns exact coefficient snapshots and accepted results.
`make_volume_inputs` supplies `MeshAndCoefficientBindings`, displacement,
compactification, and the equation plan's coefficient terms as source roles.
`CoefficientTracking` identifies each source by its persistent binding and compares
coefficient contents. Temporary context wrappers and copied equal vectors therefore
retain valid work. A changed operand refreshes its samples and the physics of
contributions using that operand. Other contributions retain their accepted data.
Displacement or compactification changes refresh mapping and mapped bases while
retaining unaffected fixed-mesh coefficient samples. A fixed-mesh coordinate change
rebuilds those bases too. No coefficient hashes or caller-maintained coefficient
revision counters are required.
Reuse assumes fixed equation configuration and any external data it references.
Request `PreparationMode::rebuild`, or invalidate the cache, after modifying external
EOS data; changes hidden behind a runtime EOS view cannot be discovered automatically.
Changing owned equation parameters requires a new graph. `preparation_count()` counts
successful generic cache preparations, including reuse and partial requests. Failed
attempts do not increment it; invalidation preserves the count. Numerical work counts
are derived from the most recent graph report, rather than separately stored state.
Inspect any cache directly through the dependency API:
```cpp
namespace stages = backend::volume_keys;
const auto& graph_report = closure.report();
const auto basis_status = graph_report.status<stages::Basis<0>>();
const auto geometry_status = graph_report.status<stages::Geometry<0>>();
const auto physics_status = graph_report.status<stages::QuadratureLinearization<0>>();
```
Statuses are `computed`, `reused`, or `not_required`. The numerical work report
summarizes these statuses in element and quadrature counts. Volume helpers request
the whole graph; `volume_keys::Linearization` retains accepted contributions and the
assembled residual together. Direct dependency requests can prepare selected
intermediates, but application requires the final linearization to be available.
Rejection makes current cache results unavailable. The generic engine keeps its last
accepted storage for possible later reuse; backend preparation helpers additionally
invalidate that storage on validation or numerical failure, ensuring a fresh retry.
Neither path publishes a partly prepared result. Reacquire accepted data after every
successful preparation rather than retaining a reference across cache updates.
Reuse depends only on unchanged tracked inputs. A recomputed node advances its
version even when it happens to produce the same value, so required downstream
computations also refresh. There is no output-equivalence test. Coefficient lookup
uses the ordinary dependency context; operand-list deduplication uses `CombinedKeyList`.
## Tensor contractions and complete transposes
On eligible H1/L2 and RT hexahedra, the backend verifies Cartesian quadrature ordering,
native signed DOF maps, and the factorization against MFEM basis values and derivatives.
It then performs successive one-dimensional contractions. H1 gradients and RT divergence
use the same factors and transpose routine. Owned tables are shared within one volume graph
and keyed by finite-element identity, operation and complete quadrature contents.
Temporary or modified rules cannot leave borrowed table data behind.
Small pointwise maps apply MFEM's canonical-to-fixed-mesh transformation and SERiF's
additional physical transformation. Tetrahedra, non-Cartesian rules and unverified
factorizations keep the dense path. Neither path changes the quadrature rule. Supply
`VolumeOptions{.contraction_mode = ContractionMode::dense}` to the cache factory
to use the dense reference path explicitly. `tensor_element_count(linearization)`
reports how many accepted contribution-elements use tensor contractions, including geometry.
`apply_jacobian_transpose(linearization, residual_dual, workspace, packed_result)` returns all
coefficient duals in `Plan::Columns` order followed by displacement. `JacobianOperator`
also implements MFEM's `MultTranspose`. The transpose includes weight, explicit
position, operand mapping, and test mapping derivatives. Geometry is differentiated
through twelve local inputs: three displacement values and nine canonical derivatives.
The mapping module supplies their exact directional variations; no finite differences
or global Jacobian-column probing are used in production.
A strong nodal replacement requires the following transpose order:
```cpp
backend::mask_residual_dual(boundary, residual_dual, volume_dual);
backend::apply_jacobian_transpose(volume, volume_dual, workspace, packed_dual);
// Obtain the constrained term's block from packed_dual using the volume plan's column order.
backend::add_jacobian_transpose_rows(boundary, residual_dual, constrained_term_dual);
```
Mask residual rows before the volume transpose, then add the constraint transpose to
the appropriate coefficient block. Zeroing coefficient entries after the volume
transpose would incorrectly remove contributions from unconstrained residual rows.
For centering, the constrained coefficient block is the displacement geometry block.
These remain serial coordinate transposes; no mass-matrix identification of duals or
MPI true-DOF ownership is implicit.
Tensor tests retain the
old scalar `2e-12` and gradient/RT `2e-11` comparisons. Full equation transposes are
checked against dense contractions, individual Jacobian columns, the old mass-adapter
`2e-12` pairing threshold, both element types/orderings, and curved compactified geometry. Existing tests and numerical tolerances are unchanged.
## Restrictions and what depends on them
| Boundary | Enforcement | Why the numerical code needs it |
| --- | --- | --- |
| Authoring interface | `equations::Equation` | Physics supplies the declared pointwise input/output contract. This does not promise execution support. |
| Current volume algorithm | `operators::VolumeEquation` | Every contribution needs a volume-domain support and implemented test/input operations. `ScalarVolumeEquation` remains available for existing callers. |
| Requested operand evaluation | `OperandEvaluator<Operand>` specialization; `EvaluableOperand` queries its interface | There is no generic fallback that ignores the operation tag. An unsupported operation cannot silently become VALUE sampling. |
| Actual MFEM element used for sampling | Always-active range/map checks in `sample_basis`; gradients and H1 divergence additionally require a transformation | Only scalar VALUE bases (one or three copies) are sampled. |
| Actual space bound to a term | `TermBinding`: family/rank compile-time checks; dimension, continuity, order, range, mapping runtime checks | Coefficient layout and basis representation must match the declared term. A valid binding still needs a supported operation; vector H1 VALUE and DIVERGENCE are implemented; GRADIENT and CURL remain unsupported. |
| Supported DOF mapping | `DofMap` checks serial conforming 3D spaces, sizes, support, orientation, and revisions | Extraction and assembly assume this representation. |
| Selected boundary rows | `select_boundary_dofs` and `DofSelection::validate` | Boundary tags must exist in the schema, have supported DOFs, and remain unchanged during use. |
| Cross-binding consistency | `make_volume_inputs` and the basis stage check schema, mesh, binding identities, and input support | Every operand must cover its contribution's integration support, which may be narrower than the row. No operand is silently extended into vacuum. |
| Inadmissible geometry or physics | Element preparation returns `PreparationRejection` with element and point; optional integral validation rejects at contribution level | A nonlinear trial state can be rejected without publishing incomplete data. Integral rejections have no single element/point. |
| Missing or stale preparation | Dependency result availability and `validate_volume_data` | Cached matrices and geometry must match the borrowed discretization. |
Malformed bindings and coefficient sizes are programming errors. Numerical trial-state
rejections are values returned from `try_prepare_volume`. The corresponding `prepare_*` convenience functions
translate a rejection for the caller. Physical admissibility
remains the equation/EOS author's responsibility.
## Extending the implementation later
For new physics expressible using supported operands, change only the equation and
model selection. Keep local physical partial derivatives in the equation; mesh and
mapping derivatives belong to numerical execution.
For a new space representation, start with `TermBinding` and the existing space
descriptors. For a new operation, start with `OperandEvaluator`: its result shape,
basis evaluation, coefficient action, test contraction, and geometry dependence must
agree. Then examine the element residual and its Jacobian together. In particular,
the existing gradient and H1 divergence implementations show why both operand and test
variations are needed. A new operation must provide these mathematics before its capability
check can admit it.
The volume admission contract should expand only after those calculations,
point/cache shapes, and quadrature handling exist. Unsupported operations remain
compile-time failures. There is no separate hand-maintained capability registry.
Compile-time checks in [`operators/plan.cpp`](../src/compile_time_checks/serif/operators/plan.cpp)
cover supported scalar, gradient and H1 divergence operations, sampled result shapes, packed columns,
and rejection of unsupported operations. Authoring an equation remains distinct from
executing it with this backend.
The existing equation tests retain independent quadrature comparisons, analytic
dilation checks, centered differences, ownership checks, and their original tolerances.
## Gravity without a separate assembly path
[`primal_gravity.hpp`](../src/include/serif/equations/primal_gravity.hpp) returns its weak
Poisson residual as a tuple of contributions: the potential-gradient flux on all domains and
the density source on `StellarDomains`. `Contribution<TestOperand, Support>` is an optional
authoring helper; each contribution supplies `Operands`, `evaluate`, and `additional_order`.
All contributions share the row term, but may select different test operations and supports.
Existing single-contribution equations need no wrapper. The source's `StellarDomains` support
prevents reading density on vacuum elements. This is volume-operator support, not a new
boundary-condition API.
## API migration
| Previous name | Current name |
| --- | --- |
| `equations::backend::mfem::EquationField` | `discretization::backend::mfem::TermBinding` |
| `equations::backend::mfem::FieldDofs` | `discretization::backend::mfem::DofMap` |
| `equations::backend::mfem::FieldState` | `operators::backend::mfem::CoefficientView` |
| `EquationState`, `State` | `make_term_coefficients(bind(...), ...)` returns a dependency context |
| `PreparedEquation`, `PreparedOperator`, `VolumeComputations` | `make_volume_equation_cache`, `prepare_volume`, and free application functions |
| `equations::EquationPlan` | `operators::EquationPlan` |
| `EQWorkspace`, `EQElementData` | `operators::backend::mfem::Workspace`, `ElementData` |
`bind(binding, coefficients)` now produces a view with `.binding` and `.values`.
Pointwise equation inputs and physical derivatives retain their existing interface.