Skip to content

FInAT: Dual evaluation for compositions of TensorProductElement + EnrichedElement - #268

Open
pbrubeck wants to merge 12 commits into
pbrubeck/hdivcurl-deltafrom
pbrubeck/fix/dual-enriched
Open

FInAT: Dual evaluation for compositions of TensorProductElement + EnrichedElement#268
pbrubeck wants to merge 12 commits into
pbrubeck/hdivcurl-deltafrom
pbrubeck/fix/dual-enriched

Conversation

@pbrubeck

@pbrubeck pbrubeck commented Jul 29, 2026

Copy link
Copy Markdown

Stack

Merge in this order, each PR retargets to main once the one above it lands:

# PR what
1 #271 FInAT: select H(div)/H(curl) components with a Delta
2 #268 FInAT: dual evaluate on each sub-element's own points
3 firedrakeproject/firedrake#5289 p-multigrid: remove custom interpolation

Interpolating into the facet space of a hexahedron raised
NotImplementedError: Dual basis not defined for element EnrichedElement,
and matrix-free adjoint interpolation out of a restricted space failed in
gem.unconcatenate. Both come from the same gap: dual_basis is a single weight
tensor on a single point set, and an element whose functionals do not all evaluate
on the same points has no such thing.

An element is a direct sum

Such an element is a direct sum, so give every element the sub_elements it is the
direct sum of — itself, unless it is one — and dual evaluate each on its own points:

  • an EnrichedElement is the sum of the elements it enriches;
  • a TensorProductElement with a summed factor is the sum of the products of that
    factor's sub-elements with the factors either side of it, since the tensor product
    distributes over the direct sum;
  • the H(div)/H(curl), flattened and discontinuous wrappers carry the decomposition of
    whatever they wrap.

dual_basis keeps its exact meaning; it is simply only defined on an element that is
not a sum. A sub-element is an element rather than a bare (Q, x) pair because
contracting it needs its own dual_transformation — a physically mapped
sub-element's basis transformation applies to its own index range, not the enriched
one — and its own get_indices(). dual_evaluation on a sum is then literally
"concatenate my sub-elements' own dual evaluations", and each of those is the
ordinary single-element path.

Why the points are never brought together

The blocks could instead be aligned onto one point set and stacked along a single
point index. They must not be: unconcatenate splits a Concatenate only along an
index that is still free in an assignment, and the point index never is — points are
always contracted by the sum in dual_evaluation. A concatenation over points is a
dead end by construction. It survives into the generated code as a Concatenate
temporary, and the per-block structure that motivated aligning them is destroyed
anyway.

Concatenating over the basis index is exactly what unconcatenate was designed
for; its own docstring works the example through for RTCF7, splitting into blocks
of 8x7 and 7x8 that each keep their own tensor structure. That index stays free in
the assignment, so it splits downstream, and each block is contracted on its own
points with whatever structure it has.

One level at a time

The decomposition goes one level deep and dual_evaluation recurses, so each element
concatenates along the index its own sum occupies. That matters for a tensor
product: its sub-elements own a slice of one factor's basis index and the whole of
every other, which is a sub-box of the basis multiindex rather than a contiguous
range of a flat one. Stacking on that factor's own index is correct wherever the
summed factor sits, whereas flattening straight to the elements that are not sums
would renumber the DoFs unless the summed factor happened to be outermost.

The restricted NCE/NCF tests exercise this several levels deep: the summed factor
sits inside a FlattenedDimensions inside an H(curl) or H(div) wrapper.

Restricted H(div)/H(curl) elements are nodal

EnrichedElement infers nodality from pairwise orthogonality, which only recognises
blocks that map to different components. Restricting an element on a tensor product
cell splits each direction into several blocks that map to the same component, so
the inferred answer was False and restrictions of NCE/NCF were reported as
non-nodal. Restriction selects disjoint subsets of the DoFs, so the restricted
subelements are nodal whenever the original ones are; carry the flag through instead
of re-deriving it, as the tensor product restriction already does.

Testing

check_nodal — applying the dual basis to the primal basis and asserting the
identity — is the mathematical check, and it runs over the enriched, tensor-product
and restricted H(div)/H(curl) cases. The FInAT and gem suites are green (352 passed,
8 skipped), as is
tests/firedrake/multigrid/test_hiptmair.py::test_gmg_hiptmair_hcurl[hexahedron-aij],
which was the failing case that motivated this.

The broader Firedrake suites (multigrid, interpolation) have not been run against
this yet.

@pbrubeck
pbrubeck force-pushed the pbrubeck/fix/dual-enriched branch from 77a6f51 to bdb3b8b Compare July 29, 2026 12:08
@pbrubeck
pbrubeck changed the base branch from main to pbrubeck/sum-factorise July 29, 2026 12:09
@pbrubeck
pbrubeck force-pushed the pbrubeck/fix/dual-enriched branch from bdb3b8b to e3ada2f Compare July 29, 2026 18:27
@pbrubeck
pbrubeck changed the base branch from pbrubeck/sum-factorise to pbrubeck/hdivcurl-delta July 29, 2026 18:30
@pbrubeck pbrubeck changed the title Make EnrichedElement a normal FInAT element with a dual basis FInAT: dual evaluate on each sub-element's own points Jul 29, 2026
pbrubeck added a commit to firedrakeproject/firedrake that referenced this pull request Jul 29, 2026
EnrichedElement has no dual basis without it, so interpolating into the
facet space of a hexahedron raises NotImplementedError, and gem cannot
unconcatenate a dual basis whose blocks have already been contracted with
a Cofunction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread finat/quadrature_element.py Outdated
@pbrubeck pbrubeck changed the title FInAT: dual evaluate on each sub-element's own points FInAT: Dual evaluation for compositions of TensorProductElement + EnrichedElement Aug 1, 2026

@rckirby rckirby left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like there are a lot of tests. We're returning a lot of more complicated GEM (that gives fewer flops in the end), so I guess this wants testing in Firedrake as well?

Comment thread finat/enriched.py Outdated
Comment thread finat/enriched.py Outdated
Comment thread finat/enriched.py Outdated
Comment thread finat/restricted.py
@pbrubeck
pbrubeck force-pushed the pbrubeck/fix/dual-enriched branch from cf129c7 to 7eacfd1 Compare August 18, 2026 10:03
pbrubeck added a commit to firedrakeproject/firedrake that referenced this pull request Aug 18, 2026
EnrichedElement has no dual basis without it, so interpolating into the
facet space of a hexahedron raises NotImplementedError, and gem cannot
unconcatenate a dual basis whose blocks have already been contracted with
a Cofunction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pbrubeck added a commit to firedrakeproject/firedrake that referenced this pull request Aug 18, 2026
EnrichedElement has no dual basis without it, so interpolating into the
facet space of a hexahedron raises NotImplementedError, and gem cannot
unconcatenate a dual basis whose blocks have already been contracted with
a Cofunction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pbrubeck
pbrubeck force-pushed the pbrubeck/fix/dual-enriched branch from 7eacfd1 to 20379ad Compare August 19, 2026 14:11
@pbrubeck
pbrubeck force-pushed the pbrubeck/fix/dual-enriched branch from 20379ad to a62a980 Compare August 24, 2026 21:15
@pbrubeck
pbrubeck force-pushed the pbrubeck/fix/dual-enriched branch from a62a980 to d630222 Compare August 26, 2026 11:11
@pbrubeck
pbrubeck force-pushed the pbrubeck/fix/dual-enriched branch from d630222 to 4d52a34 Compare August 26, 2026 11:57
pbrubeck and others added 2 commits August 26, 2026 16:10
find_group() collected indexed Concatenate nodes by intersecting against
the free indices of the expressions, and asserted that the concatenation
index was among them.  But a Concatenate can only be split if the index is
carried by an assignment *variable*: that is what split_variable() needs to
slice the variable into blocks.

Intersect against the variables' free indices instead.  An index that has
already been contracted away in the expression, or that belongs to another
pair's variable, is now left alone rather than tripping the assertion or
looping forever on a pair that cannot be split.  The groups found are
unchanged whenever the variables carry every concatenation index, which is
the case for form compilation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Interpolating into the facet space of a hexahedron raised
"NotImplementedError: Dual basis not defined for element EnrichedElement",
and matrix-free adjoint interpolation out of a restricted space failed in
gem.unconcatenate.  Both come from an element whose functionals do not all
evaluate on the same points having no way to be dual evaluated except
through dual_basis, which is a single weight tensor on a single point set.

Such an element is a direct sum.  Give every element the sub_elements it is
the direct sum of -- itself, unless it is one -- and dual evaluate each on
its own points, stacking the results along the basis index the direct sum
occupies:

  * an EnrichedElement is the sum of the elements it enriches;
  * a TensorProductElement with a summed factor is the sum of the products
    of that factor's sub-elements with the factors either side of it, since
    the tensor product distributes over the direct sum;
  * H(div)/H(curl), flattened and discontinuous wrappers carry the
    decomposition of what they wrap.

The decomposition is one level deep and dual_evaluation recurses, so each
element concatenates along the index its own sum occupies.  That matters for
a tensor product: its sub-elements own a slice of one factor's basis index
and the whole of every other, which is a sub-box of the basis multiindex
rather than a contiguous range of a flat one.  Stacking on the factor's own
index is correct wherever the summed factor sits; flattening to leaves would
renumber the DoFs unless it were outermost.

Concatenating over the basis index is what unconcatenate is for: the index
stays free in the assignment, so it can be split downstream.  A concatenation
over the points could not be -- points are always contracted by the sum in
dual_evaluation -- which is why the blocks are never brought onto common
points.

dual_point_set names the points of every sub-element, as a UnionPointSet, for
the callers that build a function space on them rather than contract against
them.  It carries no structure across the sub-elements: contracting is the
business of each of them, and each keeps its own indices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pbrubeck and others added 5 commits August 26, 2026 16:10
EnrichedElement infers nodality from pairwise orthogonality, which only
recognises blocks that map to different components.  Restricting an element
on a tensor product cell splits each direction into several blocks that map
to the same component, so the inferred answer is False and the restriction of
an NCE or NCF element was reported as non-nodal.

Restriction selects disjoint subsets of the DoFs, so the restricted
subelements are nodal whenever the original ones are, and a pullback applied
blockwise preserves that.  Carry the flag through instead of re-deriving it,
as the tensor product restriction already does.

The test exercises the decomposition several levels deep: the summed factor
of these elements sits inside a FlattenedDimensions inside an H(curl) or
H(div) wrapper, so reaching the elements that are not themselves a direct sum
means recursing rather than reading one level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An element whose functionals do not all evaluate on the same points had no
way to be dual evaluated: dual_basis is a single weight tensor on a single
point set, and there is no such tensor for a direct sum.  Interpolating into
the facet space of a hexahedron raised "Dual basis not defined for element
EnrichedElement", and interpolating into NCE of degree two or more failed in
gem, as one factor of a summand is a direct sum in turn.

Only a direct sum can evaluate summand by summand, so rewrite an element with
a summed part as one, with the sum outermost.  as_enriched() is that rewrite,
and the sum commutes with everything that can hold it:

  * a pullback is linear, so it distributes over the sum;
  * a tensor product distributes over a sum in its first factor;
  * the wrappers that leave the dual basis alone are transparent.

A tensor product and a pullback each delegate dual_evaluation to the rewrite,
and EnrichedElement contracts each summand on its own points and concatenates
along the basis index it occupies.  That index stays free in the assignment,
which is what unconcatenate needs to split it downstream; a concatenation over
the points could not be split, as dual_evaluation contracts them away.

Only the first factor of a product may be summed.  Its summands own a
contiguous range of the flat basis index and so stack in the order the product
already numbers them, whereas a sum in a later factor would interleave with the
factors before it and stacking would renumber the degrees of freedom.  Every
element of these families is built with the summed factor first.

EnrichedElement now has a dual_basis, block diagonal on the union of its
summands' points, for the callers that want a single weight tensor rather than
to contract against one.  It blocks over the same summands dual_evaluation
does, so the two cannot disagree about which points each summand owns.

A UnionPointSet is a PointSet that records where each summand's points begin,
so a function space can be built on it.  An element that needs structure to
tabulate -- a product, which cannot factor a union -- tabulates on each point
set of the union in turn rather than on the union unstructured, keeping the
structure each of them has to be sum factorised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A QuadratureElement whose rule is a union of point sets has no single set
of basis functions evaluating on all of them, so each summand evaluates on
its own points and the results stack along the basis index.  Tabulation
matches `ps` against the rule's blocks in the same way, and a vector or
tensor wrapper of a direct sum distributes over the sum first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FiniteElementBase.dual_evaluation brings the sum outermost and delegates
to a _dual_evaluation hook, so as_enriched is called from one place
rather than from each element that can hold a sum.  Drop the overrides in
TensorProductElement and WrapperElementBase, and reduce the ones in
TensorFiniteElement and EnrichedElement to the hook.

Fix QuadratureElement's union branch, which unpacked a pair from the
triple dual_evaluation returns, and returned a pair in turn.  Firedrake
builds a quadrature space on the points of the target's dual basis, so
this is reached whenever that target is a direct sum; test it.

Collapse restrict_hcurl and restrict_hdiv onto WrapperElementBase, and
trim the docstrings to :arg: style.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pbrubeck
pbrubeck force-pushed the pbrubeck/fix/dual-enriched branch from 215aa47 to 050579b Compare August 26, 2026 15:19
@pbrubeck
pbrubeck requested a review from rckirby August 26, 2026 15:35
Comment thread finat/finiteelementbase.py Outdated
Comment thread finat/finiteelementbase.py Outdated
Comment thread finat/finiteelementbase.py Outdated
Co-authored-by: Pablo Brubeck <brubeck@protonmail.com>
Comment thread finat/enriched.py Outdated
Comment thread finat/quadrature_element.py Outdated
pbrubeck and others added 2 commits August 27, 2026 10:18
A `QuadratureElement` whose rule sits on a `UnionPointSet` split the union
itself, in its own `_dual_evaluation`.  That only covered the element as it
stands: a `TensorFiniteElement` wrapping it takes the weights from
`dual_basis` and calls the callable once, on the whole union.  The callable
then tabulates into a `Concatenate` over the points, which is contracted
away before `unconcatenate` can split along it, and the `Concatenate`
reaches loopy:

    AssertionError: cannot generate expression from <class 'gem.gem.Concatenate'>

Firedrake interpolates into a vector-valued quadrature space on the points
of the target's dual basis, so every H(div)/H(curl) grid transfer took that
path.

Register the union with `as_enriched` instead.  `dual_evaluation` brings a
direct sum outermost before it evaluates, so the summands then each get
their own point set wherever the element appears, the tensor wrapper
included, and the hand-rolled split goes away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@rckirby rckirby left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems quite technical, but the tests seem to cover the particular things being added.

A tensor element couples a basis function to a value component through an
identity, so it needs no rewrite of its own to reach a direct sum: dual
evaluating the base element one component at a time gives the components
whatever structure the base element has, a direct sum among it, and keeps the
basis indices in the element's own index shape rather than the flat one a sum
of tensor elements gives. The identity is contracted rather than carried into
the basis indices, as a free index into a ListTensor defeats argument
factorisation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants