Branch Review — knepley/feature-friction-assembly

Compared against origin/main
SRCHEAD (743706f464c)
DESTorigin/main (e000d6ce225)
Commits reviewed3 — 655b17fd0c0, 71fad547eb1, 743706f464c
Files changed6 files, +168/−20 (no .out files touched)
Generated2026-07-23 18:14 UTC

Contents

  1. Scope of the diff
  2. CRITICAL — local vector passed to DMProjectFunction()
  3. CRITICAL — hybrid key part hard-coded to 0/1/2
  4. HIGH — nondeterministic side assignment for cohesive keys
  5. MEDIUM — no test coverage for the new/changed code paths
  6. Verified correct / not findings
  7. Suppressed LOW findings
  8. Suggested fixes (diffs)

Scope of the diff

FileChange
doc/changes/dev.md+1
include/petscds.h+1
src/dm/dt/interface/dtweakform.c+36 — new PetscWeakFormGetKeys()
src/dm/impls/plex/plexfem.c+1/−1 — boundary-type check in DMPlexComputeBdResidual_Internal()
src/dm/impls/plex/plexgeometry.c+11/−4 — guards in DMPlexComputeCellGeometryFEM()
src/snes/utils/dmplexsnes.c+118/−15 — hybrid evaluation in DMPlexSNESComputeResidualFEM(), and a rewrite of DMPlexSetSNESVariableBounds()

Findings

CRITICALLocal vector passed where DMProjectFunction() requires a global vector src/snes/utils/dmplexsnes.c:1232, :1245 — DMPlexSetSNESVariableBounds()

DMPlexSetSNESVariableBounds() now builds a local work vector locb via DMGetLocalVector(), then calls:

PetscCall(DMGetLocalVector(dm, &locb));
PetscCall(VecSet(locb, PETSC_NINFINITY));
PetscCall(DMProjectFunction(dm, 0., lfuncs, lctxs, INSERT_VALUES, locb));   // ← locb is local
PetscCall(DMPlexInsertBounds(dm, PETSC_TRUE, 0., locb));

DMProjectFunction()'s own docstring states it "put[s] the coefficients in a global vector." Internally it allocates its own local work vector, projects into that, then does DMLocalToGlobalBegin/End(dm, localX, mode, X) where X is the argument passed in — here that argument is locb, which is local-sized. This mismatches vector layouts whenever local ≠ global (parallel runs, or any DM with ghosted/constrained dofs), causing the scatter to fail or silently write to the wrong offsets.

The correct call is DMProjectFunctionLocal(), which has the identical signature but expects a local vector (see src/dm/interface/dm.c:8475, parameter named localX).

This bug is masked by the only existing test that exercises this path, src/snes/tutorials/ex34.c, which runs at nsize=1 with no constrained dofs — there, local and global vector sizes happen to coincide.

Both occurrences

Lines 1232 (hasLower branch, using lfuncs/lctxs) and 1245 (hasUpper branch, using ufuncs/uctxs) have the identical bug.

Suggested fix

See Fix A below.

CRITICALHybrid PetscFormKey.part hard-coded to 0/1/2 instead of the registered value src/snes/utils/dmplexsnes.c:440–451 — DMPlexSNESComputeResidualFEM()
keys[0].part  = 0;
keys[1].part  = 1;
keys[2].part  = 2;

Per include/petscdstypes.h:44, part "Selects the equation part… LHS = 0 and RHS = 1 in IMEX…", and lookups are an exact hash match on (label, value, field, part) (PetscWeakFormGetFunction_Private, src/dm/dt/interface/dtweakform.c:101). Every existing registration of a boundary residual — including the only reference implementation of this exact hybrid pattern, src/dm/impls/plex/tests/ex69.c:1317–1322 — uses part=0 for all three keys; the sides are distinguished by value/field, not by part.

Because PetscFEIntegrateHybridResidual_Basic() (src/dm/dt/fe/impls/basic/febasic.c:639–640) does:

PetscCall(PetscWeakFormGetBdResidual(wf, key.label, key.value, key.field, key.part, &n0, &f0_func, &n1, &f1_func));
if (!n0 && !n1) PetscFunctionReturn(PETSC_SUCCESS);

the part=1 and part=2 keys will find nothing and silently skip computation. The positive-side and cohesive-surface-field residual contributions are dropped from the assembled residual every time this hybrid path fires.

Suggested fix

See Fix B below — propagate the actual .part found on the matched bdf0keys[i]/bdf1keys[i] entries instead of hard-coding the array index.

HIGHCohesive side (A/B) assignment depends on hash map iteration order src/snes/utils/dmplexsnes.c:425–439 — DMPlexSNESComputeResidualFEM()
for (PetscInt i = 0; i < bdf0Nk; ++i) {
  if (bdf0keys[i].field == 0) {
    if (!label0) { label0 = ...; value0A = bdf0keys[i].value; value0B = value0A; }
    else if (bdf0keys[i].value != value0A) value0B = bdf0keys[i].value;
  }
  ...
}

bdf0keys[] comes from PetscWeakFormGetKeys()PetscHMapFormGetKeys(), whose iteration order follows PetscFormKeyHash = PetscHashCombine(PetscHashPointer(label), ...) (include/petsc/private/petscdsimpl.h:42) — a function of the DMLabel's heap address, not insertion order. But key[0]/key[1] are matched positionally against the mesh's structural sides inside DMPlexComputeResidualHybridByKey()/PetscFEIntegrateHybridResidual() (s=0/s=1 come from which neighbor cell is in the face's support[], independent of key content). A pointer-hash-dependent A/B assignment can silently swap which registered function (e.g. f0_bd_u_neg vs f0_bd_u_pos) is applied to which geometric side, and this can vary between runs/builds.

Note also that bdf1keys is fetched but its contents are never scanned, so any PETSC_WF_BDF1-only surface registration is silently ignored — consistent with the code's own // In the future, we need a way to construct the keys... comment.

Suggested fix

See Fix B below — sort the two field-0 values so the assignment is deterministic instead of hash-order dependent.

MEDIUMNo test coverage for either new/changed code path src/snes/utils/dmplexsnes.c — DMPlexSNESComputeResidualFEM(), DMPlexSetSNESVariableBounds()

None of the three commits add or update a test that exercises the new hybrid branch in DMPlexSNESComputeResidualFEM(), nor the local/global vector fix needed in DMPlexSetSNESVariableBounds(). The one existing hybrid-assembly test, src/dm/impls/plex/tests/ex69.c, calls DMPlexComputeResidualHybridByKey() directly with hand-built keys (part=0 throughout) — it never goes through dmplexsnes.c, so it cannot catch findings CRITICAL 2 or HIGH above. A small SNES/TS test driving a fault/cohesive problem through DMPlexSNESComputeResidualFEM() would likely have surfaced these.

VERIFIED OKNot findings — verified correct / beneficial

src/dm/impls/plex/plexfem.c: type & DM_BC_ESSENTIAL!(type & DM_BC_NATURAL) is a real fix, not a no-op rewrite — DM_BC_LOWER_BOUND (4) and DM_BC_UPPER_BOUND (8) have neither the ESSENTIAL nor the NATURAL bit set, so the old check let VI-bound boundary entries fall through into natural-residual processing; the new check correctly excludes them.

src/dm/impls/plex/plexgeometry.c: both changes (hoisting the qdim==dim check earlier, and falling back to DMPlexComputeCellGeometryFEM_Implicit() when the coordinate FE's basis-space dimension doesn't match the caller's quadrature) are consistent, correct guards for the new lower-dimensional (surface/hybrid) quadrature usage.

PetscWeakFormGetKeys() (new public API) matches the existing internal allocation pattern in PetscWeakFormViewTable_Ascii(); docstring formatting and header prototype are correct.

2 LOW findings suppressed — minor per-iteration overhead from always calling PetscWeakFormGetKeys() even when the weak form has no boundary residuals; and missing doc/changes/dev.md entries for the DMPlexSNESComputeResidualFEM()/DMPlexSetSNESVariableBounds() behavior changes. Ask to show them in detail.

Suggested fixes (diffs)

These patches have been applied to the local working tree (uncommitted) to verify they compile cleanly and pass clang-format; they are not part of the reviewed commits. Both touch only src/snes/utils/dmplexsnes.c.

Fix B — preserve registered part, make cohesive side ordering deterministic

Addresses CRITICAL 2 and HIGH.

--- a/src/snes/utils/dmplexsnes.c
+++ b/src/snes/utils/dmplexsnes.c
@@ -420,6 +420,7 @@ PetscErrorCode DMPlexSNESComputeResidualFEM(DM dm, Vec locX, Vec locF, PetscCtx
       IS       cohesiveCells;
       DMLabel  label0 = NULL, label1 = NULL;
       PetscInt value0A = 0, value0B = 0, value1 = 0;
+      PetscInt part0A = 0, part0B = 0, part1 = 0;

       // In the future, we need a way to construct the keys for both sides and the surface, and also the cellIS from the label
       for (PetscInt i = 0; i < bdf0Nk; ++i) {
@@ -427,28 +428,41 @@ PetscErrorCode DMPlexSNESComputeResidualFEM(DM dm, Vec locX, Vec locF, PetscCtx
           if (!label0) {
             label0  = bdf0keys[i].label;
             value0A = bdf0keys[i].value;
+            part0A  = bdf0keys[i].part;
             value0B = value0A;
+            part0B  = part0A;
           } else if (bdf0keys[i].value != value0A) {
             value0B = bdf0keys[i].value;
+            part0B  = bdf0keys[i].part;
           }
         }
         if (bdf0keys[i].field == 1) {
           label1 = bdf0keys[i].label;
           value1 = bdf0keys[i].value;
+          part1  = bdf0keys[i].part;
         }
       }
+      // Order sides by value so the key assignment does not depend on PetscWeakFormGetKeys() hash map iteration order
+      if (value0B < value0A) {
+        PetscInt tmpValue = value0A, tmpPart = part0A;
+
+        value0A = value0B;
+        part0A  = part0B;
+        value0B = tmpValue;
+        part0B  = tmpPart;
+      }
       keys[0].label = label0;
       keys[0].value = value0A;
       keys[0].field = 0;
-      keys[0].part  = 0;
+      keys[0].part  = part0A;
       keys[1].label = label0;
       keys[1].value = value0B;
       keys[1].field = 0;
-      keys[1].part  = 1;
+      keys[1].part  = part0B;
       keys[2].label = label1;
       keys[2].value = value1;
       keys[2].field = 1;
-      keys[2].part  = 2;
+      keys[2].part  = part1;
       PetscCall(CreateSurfaceCellIS_Private(plex, &cohesiveCells));
       PetscCall(DMPlexComputeResidualHybridByKey(plex, keys, cohesiveCells, PETSC_MIN_REAL, locX, NULL, 0.0, locF, ctx));
       PetscCall(ISDestroy(&cohesiveCells));

part now reflects whatever value was actually used when the residual functions were registered (normally 0), so PetscWeakFormGetBdResidual()'s exact-match lookup succeeds for all three keys instead of only keys[0]. Sorting by value makes the side assignment reproducible instead of depending on PetscHMapFormGetKeys()'s pointer-hash-derived iteration order — and it happens to match the convention already used in ex69.c (negative side = lower label value, positive side = higher). This does not address the unused-bdf1keys gap noted above, which needs a design decision rather than a mechanical fix.

Fix A — use DMProjectFunctionLocal() instead of DMProjectFunction()

Addresses CRITICAL 1.

--- a/src/snes/utils/dmplexsnes.c
+++ b/src/snes/utils/dmplexsnes.c
@@ -1229,7 +1243,7 @@ PetscErrorCode DMPlexSetSNESVariableBounds(DM dm, SNES snes)

     PetscCall(DMGetLocalVector(dm, &locb));
     PetscCall(VecSet(locb, PETSC_NINFINITY));
-    PetscCall(DMProjectFunction(dm, 0., lfuncs, lctxs, INSERT_VALUES, locb));
+    PetscCall(DMProjectFunctionLocal(dm, 0., lfuncs, lctxs, INSERT_VALUES, locb));
     PetscCall(DMPlexInsertBounds(dm, PETSC_TRUE, 0., locb));
     PetscCall(DMLocalToGlobalBegin(dm, locb, INSERT_VALUES, lb));
     PetscCall(DMLocalToGlobalEnd(dm, locb, INSERT_VALUES, lb));
@@ -1242,7 +1256,7 @@ PetscErrorCode DMPlexSetSNESVariableBounds(DM dm, SNES snes)

     PetscCall(DMGetLocalVector(dm, &locb));
     PetscCall(VecSet(locb, PETSC_INFINITY));
-    PetscCall(DMProjectFunction(dm, 0., ufuncs, uctxs, INSERT_VALUES, locb));
+    PetscCall(DMProjectFunctionLocal(dm, 0., ufuncs, uctxs, INSERT_VALUES, locb));
     PetscCall(DMPlexInsertBounds(dm, PETSC_FALSE, 0., locb));
     PetscCall(DMLocalToGlobalBegin(dm, locb, INSERT_VALUES, ub));
     PetscCall(DMLocalToGlobalEnd(dm, locb, INSERT_VALUES, ub));

Both call sites just swap the function name; DMProjectFunction() and DMProjectFunctionLocal() share the same argument list, differing only in whether the final Vec is global or local.

Both fixes were checked with clang-format --dry-run --Werror against the project's .clang-format and produced no diagnostics. Neither fix addresses MEDIUM finding (test coverage) — that requires a new test fixture, not a mechanical patch.