Uh oh!
There was an error while loading. Please reload this page.
Complete the boundary-normal sum across ranks, and make the partition-independence tests self-referential (#564) - #568
Conversation
…omplete the sum across ranks `mesh.boundary_normal()` is the default constraint direction for both `add_constraint_bc` and `add_nitsche_bc`. It accumulated `|f| * n_f` over THIS RANK's labelled boundary facets and then normalised. A boundary facet is labelled on exactly one rank, so a node on a partition seam sees only some of its facets locally, and normalising a partial sum does not give a slightly noisy normal - it gives the wrong one. Measured on Annulus(cellSize=0.12), the worst nodal normal against the exact radial one: boundary np=1 np=2 np=3 np=4 Upper 3.0e-10 5.8e-02 5.8e-02 5.8e-02 Lower 5.5e-10 1.1e-01 1.1e-01 1.1e-01 5.8e-02 is 3.3 degrees. It does not shrink with more ranks because it is not round-off: it is what you get by taking ONE of a vertex's two facet normals instead of the average of both, so its size is set by the facet's angular span. End to end that moved a constrained free-slip velocity by 3.4% between np=1 and np=2 - #495's number to every digit, and one of the seven rows of #564. The source carried a TODO(parallel) prescribing exactly this reduction and asserting the code was "parallel-SAFE as-is (only the handful of partition-seam surface vertices get a slightly-rotated unit normal)". That claim was the bug; it is replaced by the measurement. The weighted contributions are now summed through the variable's OWN sub-DM local-to-global scatter before normalising, so every rank ends with a bit-identical normal at a shared node. The reduction is COLLECTIVE - a rank owning no facet of this boundary still takes part. A plain ADD is exact with no de-duplication because no boundary facet is labelled twice and none is labelled away from its owner: measured at np=2,3,4 on the annulus and the spherical shell, for the per-boundary label this uses AND for the consolidated UW_Boundaries label. Work vectors are created from the sub-DM rather than borrowed from the variable, which is what the TODO's abandoned first attempt SEGV'd on. Two things went with it. The DOF a facet contributes to now comes from the variable's own section on that sub-DM instead of a kd-tree lookup of the DOFs nearest the facet centroid: the kd-tree is a heuristic that can mis-assign on a graded mesh, and it cannot be made to agree across ranks because each rank's tree is built from its own local coordinates - which would have made the sum land on different points on different ranks. And the outward-orientation rule, the facet measure and the internal-boundary guard now come from ONE shared `utilities/facet_normals.facet_measure_and_normal`, used by all three copies of this accumulation (rotated_bc, this one, and boundary_flux._node_normals, whose copy was still on the pre-#560 rule that orients against the mean of the rank's coordinates and points INTO the domain on a concave boundary). Three copies drifting is what produced #564 twelve weeks after #560 fixed the first one. Serial results are bit-identical: 3.005063e-10 / 5.488346e-10 before and after, and now the same to every digit at np=1, 2, 3 and 4. The new tests carry their own oracles rather than a recorded constant: on a circular arc the measure-weighted average of a vertex's two chord normals is EXACTLY radial by symmetry, and in 3-D - where faceting is honest discretisation error, 8.8e-02 on a coarse shell - the assembled field is compared against the sum over the GLOBAL facet set, gathered and done in numpy. The negative control disables the reduction and confirms both oracles fire at np>1. Corner preservation is asserted on a box, because averaging across a boundary discontinuity is the regression this change could most easily introduce. While measuring the Nitsche twin, a SECOND and unrelated partition dependence turned up: `mesh.cell_size()`, the default Nitsche local h, is built from a kd-tree query against this rank's centroids and moves with the rank count (field sum 26.0822 / 26.1211 / 26.1386 at np=1/2/4). It is marked TODO(BUG) where it lives and the Nitsche test passes local_h=False so it measures the normal and not the two together. Not fixed here - `_get_mesh_sizes` also feeds get_min_radius, the adaptivity metrics and the free-surface relaxation. Fixes the constrained half of #564 (and #495). The rotated rows of #564 are a separate question. Underworld development team with AI support from Claude Code
…heir xfails test_1063's three assertions were xfail(strict=False) against #564. With the boundary normal completed across ranks they hold on their own terms, so the xfails go. Measured here, this file's own diagnostics: [iso] velocity L2 np=1 6.194547793939e-01 np=2 6.194547793947e-01 np=3 6.194547793476e-01 np=4 6.194547793462e-01 [ti] velocity L2 np=1 3.925981603048e-01 np=2 3.925981604290e-01 np=3 3.925981603382e-01 np=4 3.925981604662e-01 - spreads of 1.2e-10 and 4.1e-10. Before the fix the same runs gave [iso] 5.982807168537e-01 at np=2 (-3.4%, and #495's number to every digit) and 6.187878061677e-01 at np=4, with the topography 2.4% adrift; [ti] moved 0.34% at np=2 and 2.8% at np=4. Reproduced by disabling only the cross-rank sum, so the attribution is a bisection and not an inference. The reference is no longer a constant recorded on a developer's machine. It is this file's own np=1 run, computed in the same environment by a single-rank child (tests/parallel/serial_reference.py), which is the property the test claims to be testing. A stored constant additionally measures the host's mesh generator: four other rows of #564 turned out to be exactly that, and the message "differs serial vs np=N" on a comparison against a number from somewhere else is what sent that investigation the wrong way for a day. Both mesh fingerprints (owned cell count, integral 1 dV) are printed on failure so a mesh difference reads as a mesh difference. Two tolerances are stated rather than inherited. Velocity keeps 1e-8, the parallel reduction order. Topography moves to 1e-5 from 1e-6 because it is read off the multiplier, whose [p,lambda] Schur sub-block grinds into its 200-iteration cap on this problem - the ti case sits at 6.4e-07 against the old 1e-6 gate, which is a 1.6x margin and not a tolerance, while 1e-5 still leaves three orders below the 2.4% it has to catch. The raw mean pressure is now asserted ABSOLUTELY (|meanP| < 1e-6) instead of against the reference: it is pinned to zero by the automatic gauge, and comparing two numbers near machine zero relatively measures nothing. Negative controls, both run: with the cross-rank sum reverted, [iso] and [ti] fail at np=2; with a 1e-3 relative partition dependence injected into every diagnostic, all three fail. Neither passes vacuously. Underworld development team with AI support from Claude Code
… them self-referential The four remaining #564 xfails are not a defect in the solver. Running test_1064's own _annulus_diagnostics on ONE CI host at both rank counts gives np=1 1.897329151623790e-02 and np=2 1.897329151623740e-02 - agreement to the 13th significant figure, with leak_lo and leak_up identical to every digit - while BOTH differ from the recorded GOLDEN_ANNULUS by exactly the same +1.676e-04. The rotated free-slip path is partition-independent. gmsh builds a different triangulation on the Linux runner than on macOS/arm64, the goldens were recorded on macOS, and because these files are mpi(min_size=2) CI has never run np=1 to notice. So the defect is in the test design, and it is the kind that costs real time: the assertion said "differs serial vs np=N" while what it measured was "differs from a number recorded elsewhere". Those are different statements. Seven assertions failing under the first reading looked like one family of solver bugs; four of them were the mesh. Every partition assertion in test_1064 and test_1066 now compares against a np=1 run of the same file, computed in the same environment by a single-rank child (tests/parallel/serial_reference.py, which scrubs the launcher's MPI variables so the child runs as a singleton rather than trying to join the parent job). Whatever mesh the host generates, both sides of the comparison use it. Each failure message carries both runs' mesh fingerprints - owned cell count and integral 1 dV, both partition-independent - so a future host difference is legible instead of mysterious. The checks that are genuinely ABSOLUTE are kept and relabelled as accuracy checks, with a comment saying they are NOT partition-independence checks: the analytic SolCx velocity error, sigma_nn's relL2 against the exact solution, the gathered top-node count, the Schur iteration ceiling, the converged reason, and the datum residual. Conflating the two is what this commit exists to stop. The mesh-owned FMG test deliberately takes the EXPLICIT-registration run as its np=1 reference: #467 is precisely the claim that the two routes produce the same solve, so that comparison asserts both properties at once. All four xfails removed. 11 passed at np=2 and at np=4. Negative control: with a 1e-3 relative partition dependence injected into every diagnostic, all eleven partition assertions in the two files fire. This addresses the ROTATED rows of #564 as a test-design problem. It does not touch the solver, and it is a separate matter from the constrained rows, which are fixed by the boundary-normal reduction earlier in this branch. Underworld development team with AI support from Claude Code
Completing the boundary-normal sum across ranks turned _assemble_boundary_normal into a COLLECTIVE routine, and deform() calls it inside a `try: ... except: pass` that predates the change. A rank-local failure there - the case that comment names, a boundary whose label vanished from the current DM after region extraction - would now take one rank out of the reduction and HANG the ranks that did find the label, converting a swallowed error into a deadlock. The facet walk is the only rank-local part, so its guard moves INSIDE the collective: a rank that cannot complete it contributes zero and still takes part in the reduction. The outer guard in deform() can then only ever see a symmetric failure, and says so. Also corrects scripts/test.sh's comment to name test_1069 and #564 alongside #560 in the parallel solver batch it enables. Underworld development team with AI support from Claude Code
There was a problem hiding this comment.
Pull request overview
This PR fixes a parallel correctness issue in mesh.boundary_normal() by completing the measure-weighted facet-normal accumulation across MPI ranks before normalization, eliminating partition-dependent constraint directions on curved boundaries. It also updates several parallel solver tests to be self-referential by computing their own np=1 reference in the same environment, avoiding false failures caused by host-dependent meshing differences.
Changes:
- Complete the per-node boundary-facet normal sum across ranks (collective reduction via the variable’s sub-DM local↔global scatter) before normalization.
- Centralize facet measure + outward-orientation logic in a shared helper (
facet_measure_and_normal) and reuse it across the three facet-walking accumulators to prevent drift. - Replace hardcoded “golden” serial constants in parallel partition-independence tests with an in-environment
np=1child-run reference (serial_reference.py), and add a dedicated parallel regression suite formesh.boundary_normal().
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
src/underworld3/discretisation/discretisation_mesh.py | Fix boundary_normal() assembly by summing facet contributions across ranks before normalizing; add collective reduction helper. |
src/underworld3/utilities/facet_normals.py | New shared utility defining a single outward-orientation + measure rule for boundary facets. |
src/underworld3/utilities/rotated_bc.py | Switch rotated free-slip facet loop to use the shared facet normal/measure helper. |
src/underworld3/utilities/boundary_flux.py | Align geometric normal accumulation with the shared facet rule; document remaining parallel reduction TODO for an unreachable branch. |
tests/parallel/serial_reference.py | New test helper to compute per-test-module np=1 diagnostics via a scrubbed-environment singleton child process (self-referential references). |
tests/parallel/test_1063_constrained_freeslip_parallel.py | Make partition-independence assertions compare against in-environment np=1 references rather than stored constants; adjust tolerances with documented rationale. |
tests/parallel/test_1064_rotated_freeslip_parallel.py | Convert rotated partition-independence checks to use serial_reference and keep absolute “accuracy” gates explicitly separate. |
tests/parallel/test_1066_rotated_datum_parallel.py | Remove xfail + hardcoded serial constant; compare solve-energy against in-environment np=1 reference while keeping an absolute datum-imposition gate. |
tests/parallel/test_1069_boundary_normal_parallel.py | New parallel regression tests for mesh.boundary_normal() including analytic and global-facet-sum oracles plus a negative control and Nitsche end-to-end coverage. |
scripts/test.sh | Enable/clarify execution of the tests/parallel/test_10*py solver-parallel suite in CI. |
docs/developer/subsystems/rotated-freeslip.md | Document that the same facet-normal rule applies to constrained/Nitsche paths and note remaining caveats (internal boundaries, cell_size() partition dependence). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
lmoresi
commented
Aug 15, 2026
Adversarial review — PR #568"Complete the boundary-normal sum across ranks, and make the partition-independence Reviewed independently of the implementer. Worktree VerdictRequest changes. The diagnosis is right, the mechanism is right, the fix is right,
None of these is a design objection. (1) is disclosure plus one serial test, (2) is a MERGE-BLOCKERSB1 — |
| mesh / boundary | max‖n_new − n_old‖ | new vs global-facet-sum oracle | old vs oracle |
|---|---|---|---|
Annulus(0.12) Upper | 1.11e-16 | 1.24e-16 | 1.24e-16 |
Annulus(0.12) Lower | 1.11e-16 | 0.00 | 1.57e-16 |
StructuredQuadBox(8,8) Top / Right | 0 (bit-identical) | — | — |
SphericalShell(0.55,1.0,cs=0.35) Upper | 4.32e-02 | 1.92e-16 | 4.71e-02 |
SphericalShell Lower | 8.94e-02 | 2.23e-16 | 1.03e-01 |
The oracle is the same construction test_1069 uses, run in serial where there is no
partition effect at all, so the only variable is the DOF-row change. Read it plainly:
the old kd-tree assignment was wrong by up to 0.103 in serial in 3-D and the new
section-based one is exact. On a tetrahedral boundary the three DOFs nearest a face
centroid are not always that face's three vertices, and k=nverts silently grabs a
neighbour. The PR argues the kd-tree "can mis-assign on a graded mesh" — it mis-assigns
on a uniform one, and the PR does not appear to know it.
Why this blocks rather than being a bonus:
- It is a numerical change to
mesh.boundary_normal(), i.e. to the default constraint
direction ofadd_constraint_bcandadd_nitsche_bc, and tosystems/free_surface.py:635,645(which the PR's stakeholder list omits) — in
serial, for every 3-D curved boundary. Any existing 3-D result withnormal=None
moves. The repo's own standing instruction is that solver-facing numerics do not
change silently. - The radial oracle cannot see it: at this resolution the faceting error is
8.8e-02 (Upper) / 2.26e-01 (Lower) and the worst node is the same for old and new, somax‖n − r̂‖is identical before and after. Only the facet-sum oracle catches it.
This is exactly why the PR's own 3-D justification ("that is honest faceting … which
is why the 3-D test uses a global-facet-sum oracle") is right and why the number
needed to be measured rather than inferred. - No test covers it.
test_1069ispytest.mark.mpi(min_size=2), so it never runs
at np=1. Sweeping the tree: every serialadd_nitsche_bc/add_constraint_bccall
withoutnormal=is on a box (test_1060,test_1065_nitsche_local_h,test_0641,test_1066) — flat walls, immune.test_1064_constrained_spherical_shell_response.py
passes explicitnormal=±unit_ron all four BCs (lines 104-117). So the 3-D serial
path had, and still has, zero coverage.
Asked for: correct the claim in the PR body and the docs (it is a second fix, and
a good one — say so); and cover the 3-D serial path. The remedy is one line: we deletedpytest.mark.mpi(min_size=2) from test_1069's pytestmark and ran it at np=1 —
6 passed, 1 skipped in 8.68s
SKIPPED tests/parallel/test_1069_boundary_normal_parallel.py:213:
a rank-local stencil is the complete stencil in serial
— every oracle, the corner test and the Nitsche end-to-end pass serially in 8.7 s, and
the only test that cannot run at np=1 skips itself, by a guard the file already carries.
Had that mark not been there, the 3-D facet-sum oracle would have caught the kd-tree
defect on its own. (The file is under tests/parallel/, so scripts/test.sh still only
reaches it via the --p N line; a serial companion under tests/ would be needed for
the serial job proper.)
B2 — the new swallow reproduces #564, silently, as its error path
discretisation_mesh.py, inside _assemble_boundary_normal:
exceptException:
accum[...] =0.0Keeping the guard inside the collective is the right call and the reasoning in the
comment is correct. Making it silent is not. The except is unqualified and wraps the
whole facet walk including the new indexing (ssec.getOffset(q) // ncomp), so anIndexError from the very code this PR introduces is caught and turned into "this rank
contributed nothing".
Measured (np=2, Annulus(cs=0.2), facet_measure_and_normal patched to raise on rank 1
only, then deform()):
| | Σ|n| over local rows, rank 0 | rank 1 |
|---|---:|---:|
| control | 21.306341 | 21.306341 |
| rank-1 facet walk raises | 21.492745 | 2.186404 |
deform() returns on both ranks, nothing is printed, and rank 1's owned boundary DOFs
come back with a zero normal — so the Nitsche/constraint direction is the zero
vector over that part of the boundary. That is strictly worse than the 3.3° the PR
exists to remove, and it is indistinguishable from success.
Asked for: set a local failed flag in the except, comm.allreduce(…, MAX) it
alongside the reduction that is already collective, and raise (or at minimumuw.pprint a warning) on every rank when any rank failed. The collective is already
there; this costs one extra reduction on the same communicator.
B3 — the deform() guard is not collective on every path
The PR moved the guard inside the collective and wrote:
"this outer guard therefore only ever sees a symmetric failure"
That is true only for exceptions raised inside the inner try. Everything beforedm.createSubDM(var.field_id) — var.num_components, numpy.zeros_like(numpy.asarray(var.data))
— and the one statement between createSubDM and the inner try,ssec = subdm.getLocalSection(), is outside it, and createSubDM / localToGlobal
are collective.
Measured, np=2, _assemble_boundary_normal patched to raise on rank 1 only, undermpirun --timeout 180:
[rank 1] entering deform (MODE=outer)
[rank 0] entering deform (MODE=outer)
[rank 1] deform returned
<rank 0 never returns; job killed by the launcher timeout, exit 241>
Rank 1 walks out through deform()'s except Exception: pass and rank 0 blocks in the
sub-DM collective. Before this PR the routine was rank-local and this was benign; the
PR created the hazard and closed part of it. deform() runs every free-surface
timestep, so a hang here is a wall-clock loss on a cluster, not a test failure.
Asked for: move ssec = subdm.getLocalSection() inside the inner try (one line —
it closes the only realistic remaining hole), and replace deform()'s per-boundaryexcept Exception: pass with a collective decision: allreduce the failure flag from B2
so every rank takes the same branch and an asymmetric failure aborts loudly instead of
hanging.
Findings — high
H1 — test_1063's gates were loosened 10× and the PR body does not say so
| assertion | before | after |
|---|---|---|
test_constrained_freeslip_* velocity | rtol=1e-9 | rtol=1e-8 |
test_constrained_freeslip_* topography | rtol=1e-6 | rtol=1e-5 |
test_constrained_raw_gauge_* velocity | rtol=1e-8 | rtol=1e-8 (unchanged) |
test_constrained_raw_gauge_* topography | rtol=1e-6 | rtol=1e-5 |
The PR body reports the residual spread as "1.2e-10 and 4.1e-10" and concludes "the
3.4 % closes completely". Those are absolute; the gates are relative. 4.1e-10 on a[ti] velocity of 0.3926 is 1.0e-9 relative — i.e. the [ti] case sits exactly on
the old 1e-9 gate, and the loosening was necessary, not cosmetic. That is a defensible
engineering call (one order of headroom on a solve whose [p,λ] Schur block caps at 200
iterations), but a reader of the PR body will assume the old assertions now pass, and
they do not. The topography loosening at least carries its reason in a comment.
Asked for: one line in the PR body stating both loosenings and the 1.0e-9 relative[ti] spread that forces the velocity one.
H2 — eleven absolute anchors deleted; the design note's own recommendation was half-implemented
The self-referential rewrite is the right diagnosis and the right shape. ButGOLDEN_BOX, GOLDEN_ANNULUS, GOLDEN_ANNULUS_FMG, GOLDEN_SPHERICAL3D,GOLDEN_SPHERICAL3D_TOPO, GOLDEN_BOX_NONLINEAR, GOLDEN_BOX_SIGMA,GOLDEN_TOPO_BDL2, GOLDEN, GOLDEN_GAUGE and _INT_VV_REF are all removed, and only
some are replaced by an accuracy check. Nothing now pins:
- the annulus radial leakage (4.6e-05 / 9.3e-06) — a rotated constraint that stopped
constraining at every rank count equally would pass; - the custom-FMG annulus answer — no absolute anchor at all, and it is not
cross-checked against the non-FMG annulus either (1.907e-02 vs 1.897e-02); - the Zhong l=2 topography coefficients — physics benchmark numbers, now free;
test_1066's ∫v·v;test_1063's velocity and topography.
The investigation note that this PR implements says (§"Recommended fix shape", and again
in the test-plan recipe):
"Where a stored constant is unavoidable, store a mesh fingerprint beside it … and
assert the fingerprint first, so a mesh change reports itself instead of masquerading
as a physics regression."
The PR built mesh_fingerprint() — and then deleted the constants instead of gating
them. That is the more expensive half (a full serial solve per test, see M3) and the
less protective one. Keeping each golden at a loose rtol=1e-2 as an explicitly-labelled
accuracy gate, alongside the self-referential tight gate, costs nothing and restores
what was lost.
Related and cheap: compare()reports both fingerprints in the failure message but
never asserts they match. The docstring's promise ("the day that stops being true it
says so") is only kept if the test fails for another reason first. Assert it.
Findings — medium
M1 — serial_reference deadlocks if the child launch fails for anything but a timeout
_run_child catches subprocess.TimeoutExpired only. Any other exception on rank 0 —OSError/PermissionError from subprocess.run, a json.JSONDecodeError on a
truncated SERIALREF line, a MemoryError on capture_output — propagates out ofserial_referencebeforeuw.mpi.comm.bcast, leaving every other rank blocked in
the broadcast. pytest-timeout eventually fires (900 s), and Open MPI busy-polls, so those
ranks burn a core each in the meantime. One try/except Exception returning the message
string (the function already has that contract for the timeout case) closes it.
Also: the child's default timeout=1800 is longer than the pytest.mark.timeout(900)
that wraps it, so it can never fire. Make it a fraction of the mark, or drop it.
M2 — CI cost: every partition test now runs a full serial solve in a child
test_1064 spawns eight children, including the 3-D spherical shell twice
(spherical3d, spherical3d_topo). Measured here at np=2 (warm gmsh cache):
| file | np=2 wall | tests |
|---|---|---|
test_1063 | 30 s | 3 |
test_1066 | 10 s | 2 |
test_1064 | 64 s | 9 |
test_1069 | 9 s | 7 |
The timeouts were raised 180 → 600/900 s to accommodate it, which is honest. Two things
to weigh: ranks 1..N-1 sit in MPI_Bcast while the child runs, and Open MPI busy-waits
by default, so on a 2-vCPU GitHub runner the child competes with a spinning rank for the
same core; and _CACHE is per-process, so the eight children are re-run for every fresh
pytest invocation. Neither blocks. If it bites, OMPI_MCA_mpi_yield_when_idle=1 in the
scrubbed child env, or an on-disk cache keyed by the mesh fingerprint, are the levers.
M3 — the dropped fallback in _sum_local_dofs_across_ranks tests the wrong predicate
dropped= (~summed.any(axis=1)) &values.any(axis=1)
summed[dropped] =values[dropped]The question is "was this DOF constrained out of the global vector?" The code asks "did
it come back all-zero?". Those differ on precisely the pathological case: a node whose
global contributions cancel exactly (opposed facets on a degenerate or
zero-thickness boundary) has its rank-local partial value restored — reintroducing
the partition dependence on the one input where it matters. The author states it is dead
today, which also means it is untested and the negative control cannot reach it.ssec.getConstraintDof(q) is the actual predicate; or delete the branch and let a future
constrained section fail loudly.
M4 — "a different rule, not a stale copy" overstates the fault_contact exclusion
The PR excludes fault_contact._fault_pair_nodes on the grounds that it "walks an
internal surface where both support cells exist by design and the orientation is
the ±side split. It is a different rule, not a stale copy of this one." Readingfault_contact.py:561-571, the loop is the same six lines as the block just
extracted from rotated_bc — same computeCellGeometryFVM, same unit-normalise with
the identical +1e-30, same flip against getSupport(f)[0]'s centroid, samefloat(vol) measure weight, same full-length dot — with exactly one difference: thegetSupportSize == 1 guard is dropped so the flip is unconditional.
That is a different guard, not a different rule, and it is a fourth copy of the six
lines whose duplication the PR itself calls out ("three copies drifting apart is exactly
how #564 happened twelve weeks after #560 fixed the first one"). facet_measure_and_normal
already returns exterior precisely so a caller can decide what to do about it, so
fault_contact can consume the helper and apply its own flip, keeping the
normalise/epsilon/measure conventions shared. Not a blocker — the code is correct today —
but the stated reason for leaving it out does not survive reading it.
M5 — _assemble_boundary_normal assumes one node per DMPlex point
accum[ssec.getOffset(q) // ncomp] is the row index only when every point carries
exactly ncomp DOFs. True for the degree-1 variable boundary_normal() builds — but
that path is existing = self.vars.get(f"_n_bd_{name}"), so a pre-existing variable of
another degree (checkpoint restore, user code) would be accepted and, for a P3 edge (two
nodes per point in 2-D), would accumulate onto the first node only and leave the second
at zero. One assert var.degree == 1 at the top, or take the row fromvar.coords-independent section arithmetic that handles multi-node points asboundary_flux._boundary_field_nodes already does.
Findings — low
- L1 —
boundary_flux._node_normalsis genuinely dead; the concave sign change does
NOT propagate. Verified: the sole call site isboundary_flux.py:638, guarded byif normal is not None, andboundary_flux_field(:691) routes through the sameboundary_flux(). Withnormal=Nonea vector solver takes the full-traction branch
and never calls_node_normals.boundary_normal_traction/dynamic_topography
live inrotated_bcand read the rotated constraint reaction, not this. So the
Rotated free-slip: weight the nodal normal by the facet measure the assembly integrates over (#560) #561 review's concave σ_nn sign flip is not re-applied a second time. The PR's
claim here is correct. - L2 —
facet_measure_and_normaldots full-length vectors in the flip test
where the old_assemble_boundary_normalsliced to[:cdim]. Identical whenevermesh.cdim == dm.getCoordinateDim(); a 2-D manifold embedded in 3-D would differ.
One line in the helper docstring. - L3 — "bit-identical" is used for the 2-D serial result, which is actually
1.11e-16. Immaterial numerically, but the PR leans on the word. - L4 — the test files assert absolute gates before the collective
serial_reference()call (test_1066relL2,test_1064nnodes/its/reason).
Every one of those quantities is collective or bcast today, so it is symmetric; but
the pattern is a latent hang generator and nothing enforces the invariant. A comment
at least. - L5 —
test_1069ismpi(min_size=2), so the corner test — which the file itself
calls "the regression the fix is most likely to introduce" — and the negative control
and the analytic oracle never run in the serial job. See B1. - L6 — the stakeholder list omits
systems/free_surface.py:635,645, which readsmesh.boundary_normal(self.surface)whenself.normal is None. - L7 —
_spherical3d_topography_diagnostics'scell_sizeargument is no longer
reachable from__main__(_DIAGNOSTICS[_kind]()takes no argv). Minor loss of a
debugging affordance the old block had.
What we verified that holds up
Measured in this worktree, sequentially.
Parallel test batch — all green, including odd rank counts.
| file | np=2 | np=3 | np=4 |
|---|---|---|---|
test_1063 | 3 passed | 3 passed | 3 passed |
test_1064 | 9 passed | 9 passed | 9 passed |
test_1066 | 2 passed | 2 passed | 2 passed |
test_1069 | 7 passed | 7 passed | 7 passed |
No xfails, no skips, no strict-xpass. The seven xfails are genuinely gone rather than
weakened: test_1064's and test_1066's rtols are unchanged from the pre-PR goldens
(H1 is confined to test_1063), the nonlinear iteration count is still exact equality
(rtol=0.0, atol=0), and the absolute checks that were doing real work (verr < 1e-3,relL2 < 0.10, nnodes == 49, reason > 0, its <= 25, datum relL2 < 1e-3) are all
retained and correctly relabelled.
Empty ranks and np=8.StructuredQuadBox(elementRes=(1,32)) gives Top exactly one
facet, so at np=8 seven of eight ranks own no facet of the boundary being normalised.
No hang; max‖n − (0,1)‖ = 0 at np=1, 2, 4 and 8. The curved case is stable to the digit
at every rank count:
| np=1 | np=2 | np=4 | np=8 | |
|---|---|---|---|---|
annulus Upper max‖n − r̂‖ | 3.005e-10 | 3.005e-10 | 3.005e-10 | 3.005e-10 |
annulus Lower max‖n − (−r̂)‖ | 6.255e-10 | 6.255e-10 | 6.255e-10 | 6.255e-10 |
No facet is double-labelled — independently confirmed. Per-rank labelled-facet counts
sum to exactly 54 (Upper) and 28 (Lower) at np=1, 2, 4 and 8, matching the serial totals.
The PR's "no de-duplication needed" is not an assumption: test_1069's facet-sum oracle
de-duplicates by centroid while the assembly does not, so a facet labelled twice would
double its weight and break the 1e-12 gate. That is a properly load-bearing guard.
The negative control fires (test_boundary_normal_oracle_fires_without_the_reduction
monkeypatches _sum_local_dofs_across_ranks to the identity and both oracles must exceed
1e-3), and it correctly skips at np=1.
The new sub-DM coupling survives deform(). The design note flagged deformed/adapted
meshes as a risk, and the new code resolves DOF rows throughdm.createSubDM(var.field_id) on the current DM where the old code used var.coords
and never touched the DM's fields — a genuinely new coupling to the remesh. Radial
scaling by 1.37 on Annulus(cs=0.15), worst‖n − r̂‖:
| np=1 | np=2 | np=4 | |
|---|---|---|---|
before deform() (Upper / Lower) | 4.0503e-10 / 7.6439e-10 | same | same |
after deform()'s eager refresh | 4.0503e-10 / 7.6439e-10 | same | same |
| after an explicit re-request | 4.0503e-10 / 7.6439e-10 | same | same |
Identical to the digit in all nine cells. The refresh path is sound.
The rotated_bc extraction is behaviour-preserving. Line-by-line, the newfacet_measure_and_normal reproduces the inlined block from bdb56713 exactly:
same computeCellGeometryFVM, same unit-normalise with the +1e-30, samegetSupportSize == 1 guard, same flip against the support-cell centroid, samewgt = float(vol). This is a pure extraction. (See L2 for the one edge case.)
The gap the PR admits is not a gap.tests/test_1064_constrained_spherical_shell_response.py
passes explicit normal=unit_r / normal=-unit_r to every add_nitsche_bc andadd_constraint_bc (lines 104-117), so it never reaches mesh.boundary_normal() and
cannot be affected by this change — provable by reading, no 35-minute LU run needed.
It is also excluded from CI by construction: scripts/test.sh deliberately does not
batch test_106*py. Risk from not running it: nil. We recommend the PR say that
instead of listing it as unverified.
The serial stakeholders — and a note on where they live. The consumers of the
default (assembled) normal are test_1060_nitsche_freeslip,test_1061_constrained_freeslip, test_1062_constrained_solcx,test_1065_nitsche_local_h, test_1065_rotation_gauge_freeslip,test_1066_stokes_jacobian_layout, plus test_1024_multiplier_schur_pc andtest_0056_projected_normals_deform. 41 passed, 0 failed in 123 s.
Worth flagging separately: most of those are test_106*py, which scripts/test.sh
deliberately does not batch (the comment at scripts/test.sh:82-84 excludestest_106*py / test_107*py pending an issue-#504 triage). So a green scripts/test.sh
does not exercise the default-normal path in serial at all — they have to be run by
hand, as above. That is a pre-existing CI gap, not this PR's, but it compounds B1: the
one batch that would have noticed a 3-D serial normal change is the one CI does not run.
scripts/test.sh (serial). Run end to end from the review worktree. Every batch that
had completed at the time of writing is clean — zero FAILED, zero ERROR lines:
| batch | result |
|---|---|
test_000*…test_02* | 129 + 43 + 168 + 27 passed |
test_05* + test_07* | 532 passed, 5 skipped, 9 xfailed, 1 xpassed |
test_08* | 603 passed, 13 skipped, 11 xfailed |
test_101* + test_102* | 143 passed |
test_105* onward (VEP/VE, diffusion, advection, named) | still running at time of writing |
The tail that had not finished (test_105*, test_11*, test_1450*, the named files)
contains no consumer of mesh.boundary_normal — we grepped the whole tree foradd_nitsche_bc / add_constraint_bc / boundary_normal( and every hit is in the
files listed above or in the stakeholder set run separately. We would still want the
tail green before merge, but it carries no exposure to this change.
Reproduction
./uw worktree create r568-review
cd .claude/worktrees/r568-review
git fetch origin pull/568/head:pr568-head && git reset --hard pr568-head
./uw build
export PATH="$PWD/.pixi/envs/amr-dev/bin:$PATH"
# parallel batch
for N in 2 3 4; do for F in tests/parallel/test_106{3,6,4,9}*.py; do
mpirun -n $N python -u -m pytest --with-mpi -q -p no:randomly $F; done; done
# probes (scratchpad)
python p1_serial_bitid.py # old kd-tree vs new, np=1
python p2_serial_oracle.py # both vs the facet-sum oracle, np=1
mpirun --oversubscribe -n 8 python p3_emptyrank.py
mpirun --timeout 180 -n 2 python p4_deform_collective.py {none,inner,outer}
Probe sources:/private/tmp/claude-501/-Users-lmoresi--Underworld-underworld3-pixi/64e34dd9-4af2-4774-9d55-4238a998eb6e/scratchpad/p{1,2,3,4}_*.py
Underworld development team with AI support from Claude Code
…rent rule The PR excluded fault_contact._fault_pair_nodes from the shared helper on the grounds that it "walks an internal surface where both support cells exist by design and the orientation IS the plus/minus side split - a different rule, not a stale copy". Review checked that against the source and it does not hold. The loop is the SAME six lines as the block extracted from rotated_bc: same computeCellGeometryFVM, same unit-normalise with the identical +1e-30, same flip against getSupport(f)[0]'s centroid, same float(vol) measure weight, same full-length dot. The one difference is that the getSupportSize == 1 guard is dropped so the flip is unconditional. That is a different GUARD, not a different rule - and it leaves a fourth verbatim copy of the six lines whose duplication this PR itself argues is the root cause pattern. So it consumes the helper. `orient="support0"` names the fault's rule explicitly: flip away from support[0] unconditionally, which on a split fault is the plus/minus side split and is coherent along the surface because every facet of the surface lists the same side first. The default `orient="exterior"` is the boundary rule - orient only where "outward from the domain" is defined. The docstring says which surfaces each is safe on and why the wrong choice makes neighbouring facets CANCEL in a measure-weighted sum. Behaviour-preserving by construction (the two branches are line-for-line what the callers had) and by measurement: tests/test_0846_fault_contact.py, test_0845_fault_split.py, test_0847_fault_api.py, test_0850_faults.py - 48 passed. Also records in the helper docstring that the orientation dot product is taken over dm.getCoordinateDim() components, which matters only for a manifold mesh where a caller slices the result to fewer. Underworld development team with AI support from Claude Code
…ormal or a hang Review of #568 found the new error path was worse than the defect it guards. Both halves measured, both fixed, both with a negative control. SILENT ZERO NORMALS. The `except Exception: accum[...] = 0.0` caught anything - including an IndexError from the new row arithmetic - and turned it into "this rank contributed nothing". Measured at np=2 with rank 1's facet walk raising: deform() returned cleanly on both ranks, nothing was printed, and rank 1's OWNED boundary DOFs came back with a ZERO normal (sum|n| 2.186 against a control of 21.306). The constraint direction over that part of the boundary is then the zero vector, with a converged solve. That is strictly worse than the 3.3 degrees this branch exists to remove, and indistinguishable from success. HANG ON AN ASYMMETRIC RAISE. The previous guard covered only the facet walk. Everything before `dm.createSubDM` - var.num_components, the zeros_like on var.data - and `subdm.getLocalSection()` sat outside it, and createSubDM is collective. Measured: rank 1 walked out through deform()'s `except: pass` and rank 0 blocked in the sub-DM collective until the launcher killed the job. One discipline applied twice. Every rank-local step now sets a failure flag instead of escaping; the flag is ALL-REDUCED before the reduction so every rank takes the same branch; then every rank raises, carrying the failing ranks' messages. deform() decides collectively too - its all-reduce sits on the exception path, so "every rank takes the same branch" is a property of that loop rather than an assumption inherited from the callee - and it WARNS instead of skipping in silence, because a swallowed refresh leaves a stale constraint direction on a moved boundary. Negative controls at np=2, both green: a failure inside the facet walk, and a failure before createSubDM, each raise on BOTH ranks with the originating rank's message, both reach the following barrier, and neither leaves a zero normal. A third injection that replaces the ENTIRE routine still hangs and cannot be defended against from the caller - rank 0 is inside createSubDM before any flag could be exchanged. What remains unguarded inside the routine is only createSubDM itself, the two destroys, the allgather on the raise path and the final collective pack: all collective, so all symmetric. A rank-local raise added anywhere else would reintroduce the hang, which is why the docstring says so. Drops the "the round trip lost this DOF, keep the local value" fallback in _sum_local_dofs_across_ranks. It asked "did it come back all-zero?" when the question is "was it constrained out?", and those differ on exactly the input that matters: a node whose GLOBAL contributions cancel would have its rank-local PARTIAL value restored - #564 again, on the one case the reduction exists to get right. Dead code, therefore also untested and unreachable by the negative control. getConstraintDof is the predicate if it is ever needed. Refuses a non-degree-1 variable rather than half-filling it: the row arithmetic assumes one node per DMPlex point, and boundary_normal() adopts a pre-existing `_n_bd_<name>` variable if one is registered, so a checkpoint restore or user code could hand it a P3 space whose edge carries two nodes. And discloses the SERIAL 3-D correction this branch makes - see the following commit for the coverage. Underworld development team with AI support from Claude Code
Undisclosed in the first version of this branch, and the most valuable thing in it. Facet contributions used to reach their DOFs by a kd-tree query for "the nodes nearest the facet centroid". On a TETRAHEDRAL boundary the three DOFs nearest a face centroid are not always that face's own three vertices, so the query picked up a neighbour and the assembled normal was wrong on a UNIFORM mesh at np=1, where there is no partition effect at all: SphericalShell(0.55, 1.0, cs=0.35), np=1, vs the global facet sum Upper old 4.712169e-02 new 1.922963e-16 Lower old 1.029976e-01 new 2.225049e-16 (1.03e-01 is 5.9 degrees) 2-D is untouched: annulus 1.241267e-16 old and new on Upper, box bit-identical - on an edge the two nearest DOFs to the midpoint are always its own two vertices. Reproduced here by running the pre-branch assembly verbatim alongside the new one in a single serial process, so the only variable is the DOF routing. The branch said "serial results are bit-identical". That was true of what had been measured (2-D) and false in 3-D, so mesh.boundary_normal() - the default constraint direction of add_constraint_bc and add_nitsche_bc, and of FreeSurface's wall-normal datum - has been returning materially wrong normals on every 3-D curved boundary in serial, and this branch corrects them. That belongs in the docstring and in the release notes, not in a diff. NOTHING COVERED IT, and the shape of the miss is worth recording: the analytic radial oracle CANNOT see it. At this resolution the honest faceting error is 8.8e-02 / 2.26e-01 and the worst node is the same before and after, so max|n - rhat| is identical either way. Only the global-facet-sum oracle catches it - and that oracle lived in a file marked mpi(min_size=2), so it never ran at np=1. Every other serial test of the default normal is on a box, where flat walls make the question vacuous. The mark is gone. test_1069 now runs at every rank count - 7 passed, 1 skipped in 8.7 s at np=1, with the one genuinely MPI-only test (the reduction's negative control) skipping itself through a guard the file already carried; 21 passed, 1 skipped at np=2, 3 and 4. A dedicated serial 3-D assertion is added rather than relying on the parametrised one, so this cannot quietly become parallel-only again, and scripts/test.sh runs the file in the SERIAL job as well - none of the serial batches reached this path before, and test_106*py, where most of the default-normal consumers live, is deliberately not batched at all. Underworld development team with AI support from Claude Code
Making the partition tests self-referential was right and it removed something that was doing real work. Self-reference proves the answer does not depend on the PARTITION; it says nothing about whether the answer is RIGHT. A rotated constraint that stopped constraining equally on every rank, an FMG hierarchy converging to the wrong place, a Zhong l=2 benchmark coefficient drifting - each passes a self-referential test. Eleven constants were deleted and only some were replaced by an accuracy check, so nothing pinned the annulus radial leakages, the custom-FMG answer, the Zhong coefficients, test_1066's energy, or test_1063's velocity and topography. The reason for deleting them was real - they are host-specific, because gmsh triangulates differently across platforms, and a mismatch then reads as a physics regression. But that is an argument for gating them, which is what the #564 investigation actually recommended, and this branch had already built mesh_fingerprint() and then not used it that way. So every golden comes back as an explicitly-labelled ACCURACY anchor at rtol=1e-2, in front of which accuracy_anchor() checks the mesh: same triangulation, the gate is live; different, it SKIPS with both fingerprints in the message instead of failing for the wrong reason. Partition independence is asserted either way, three to eight orders tighter. All eleven values are the pre-branch goldens, verified to reproduce on this host: iso 6.194547793955e-01, ti 3.925981604039e-01, annulus leakages 4.563841e-05 / 9.341699e-06, FMG 1.906961759626e-02, spherical3d 4.069689334228e-03, the six Zhong coefficients, nonlinear (8.069396188270e-04, 8), sigma (5.554578e-02, 0.998466), topo 2.553916470e-01, datum 6.6559607579. compare() now ASSERTS the two fingerprints match rather than only printing them. Its docstring promised "the day that stops being true it says so", which was only kept if the test failed for another reason first. Two more from the same review: * serial_reference deadlocked on any child-launch failure except a timeout. An OSError from subprocess.run, a truncated SERIALREF line, a MemoryError on capture_output all propagated out of rank 0 BEFORE the broadcast, leaving every other rank in MPI_Bcast - busy-polling a core each - until pytest's own timeout fired. _run_child now catches everything and returns the failure as the string the contract already had for timeouts. Its default timeout drops 1800 -> 600 s so it is shorter than the mark that wraps it and can actually fire. * the absolute gates that run BEFORE the collective serial_reference() call (nnodes, its, reason) are all collective or bcast today, so they are symmetric - but nothing enforced it and a one-rank failure there would hang rather than fail. Stated as an invariant where the next person will read it. Also restores the cell_size debugging argument to test_1064's __main__, which the dict dispatch had dropped. Underworld development team with AI support from Claude Code
lmoresi
commented
Aug 15, 2026
Response commits B1 — the serial 3-D defect is now the PR's headline, not an accident. Reproduced independently beside the pre-PR kd-tree assembly in one serial process, matching the review digit for digit:
B2 — the rank-local flag is all-reduced and every rank raises, carrying the failing ranks' messages. Control at np=2: a rank-1 facet-walk failure raises on both ranks and both reach the next barrier; Σ|n| never collapses (was 2.186 vs a 21.306 control, silently). B3 — H1 — both loosenings stated with the measurement that forces them: 4.1e-10 absolute on a H2 — all eleven anchors restored behind M4 — the reviewer was right: it was the same six lines with one guard dropped, so Verification: np=1 The CI gap this exposed is filed separately as #570: four coverage gaps in two days, each hiding a real defect, and Underworld development team with AI support from Claude Code |
lmoresi
commented
Aug 15, 2026
Re-review addendum — APPROVEAll three blockers cleared on measurement. B1 — the new serial 3-D guard genuinely discriminates. Restoring the pre-PR kd-tree routing and re-running the actual test functions at np=1: the dedicated serial 3-D shell test and the parametrised facet-sum [shell] case both FAIL at 4.712e-02, while [annulus] and the exact-radial test still pass. That is the right discrimination — 3-D catches it, 2-D does not move, and the analytic radial oracle stays blind, which is precisely why the facet-sum oracle exists. CI wiring confirmed live: the serial batch appears in the B2 — the attack does not land. A rank-1-only failure injected at each reachable point (facet walk, the guarded set-up before B3 — the disclosure is honest, not optimistic. Replacing the whole routine gives exit 241 with PETSc error 98 from the mismatched collective, and triggering it required replacing the bound method, which no caller can do. What remains unguarded is genuinely all collective. Recording that plainly so it does not resurface as a phantom blocker. H2 — the anchors fire here (fingerprint matches, zero skips at np=2 and np=4), and a same-mesh regression fails rather than skips: +3%, +3.4% (the #564 velocity move) and +2.4% (its topography move) all raise, while +0.5% passes inside the deliberate gate. The fingerprint discriminates round-off (1e-14, 1e-13 → same) from a re-triangulation (1e-3 → different), and Also confirmed Three non-blocking items now fixed in a follow-up commit: the Underworld development team with AI support from Claude Code |
The SolCx velocity-error assertion sat after accuracy_anchor(), which SKIPS on a host whose mesh fingerprint differs - so on such a host the accuracy check was dropped along with the anchor. Moved above it, with the reason stated. Also corrects the PR body: TWO tolerances moved in test_1063, not one - the velocity gate 1e-9 -> 1e-8 as well as the topography gate, and the velocity move is forced by this PR's own measurement (a [ti] spread of 4.1e-10 on 0.3926 is 1.05e-9 relative, which fails the old gate). Underworld development team with AI support from Claude Code
lmoresi
commented
Aug 15, 2026
Follow-up commit eee9781 takes the three non-blocking items: the SolCx accuracy assertion now sits BEFORE the fingerprint-gated anchor (it would otherwise be skipped on a host whose mesh differs — the anchor skips by design, the accuracy check must not), and the body now states that TWO tolerances moved in Underworld development team with AI support from Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Two PRs were cancelled at 1h00m and reported as failures when nothing had failed: the serial phase alone now takes ~55 minutes, so the parallel phase runs last and gets cut mid-run. A run with no additions at all (PR #568) took 55m07s, which means any new tests exceeded the cap. This is a stop-gap so work can land. The durable fix is splitting the batches across parallel jobs so wall-clock stops tracking total test time - filed separately. Underworld development team with AI support from Claude Code
Fixes the constrained half of #564: a boundary normal built from one rank's facets
Two independent things are in here, and they should be read separately.
mesh.boundary_normal()— the default constraint direction foradd_constraint_bcandadd_nitsche_bc— was assembled from each rank's ownboundary facets. On a curved boundary that puts up to 3.3 degrees of error into the
constraint direction at partition-seam nodes and moves a constrained free-slip
answer by 3.4 % between np=1 and np=2. Fixed by completing the sum across ranks.
rotated path is partition-independent to the 13th significant figure; the tests were
comparing against constants recorded on a different host. They are now
self-referential.
1. The solver fix
The mechanism
The nodal boundary normal is
Σ_f |f| n̂_fover every facet of the boundary that meetsthe node, normalised at the end. A boundary facet is labelled on exactly one rank,
so a node on a partition seam sees only some of its facets locally. Normalising that
partial sum does not give a slightly noisy normal — it gives the wrong one.
Mesh._assemble_boundary_normalaccumulated rank-locally, under aTODO(parallel)that prescribed exactly the missing reduction and then asserted the code was
"parallel-SAFE as-is (only the handful of partition-seam surface vertices get a
slightly-rotated unit normal)". That sentence was the bug. It is replaced by the
measurement.
This is the same defect class as #560 (fixed for
rotated_bc._boundary_velocity_nodesin #561), in a different copy of the same accumulation. There were three copies.
Measured, before
Worst nodal normal against the exact radial one,
Annulus(cellSize=0.12):Upper(54 nodes)Lower(28 nodes)5.8e-02 is 3.3 degrees. It does not shrink with more ranks because it is not round-off:
it is what you get by taking ONE of a vertex's two facet normals instead of the average
of both, so its size is set by the facet's angular span. (54 nodes over 2π is 0.116 rad
per facet; half of that is 0.058.)
Measured, after
Identical to serial to every digit at every rank count — 3.005063e-10 (Upper) and
5.488346e-10 (Lower) at np=1, 2, 3 and 4. Serial results are bit-identical to before.
Same for a 3-D
SphericalShell: bit-identical at np=1, 2, 4. (Its absolute erroragainst the analytic radial normal is 8.8e-02 on the outer boundary at
cellSize=0.35— that is honest faceting on a coarse triangulated sphere, not a defect, which is why
the 3-D test uses a global-facet-sum oracle instead of the analytic one.)
End to end —
test_1063, run at four rank countsVelocity L2 of the constrained free-slip annulus, this branch:
[iso][ti]Spread 1.2e-10 and 4.1e-10.
With only the cross-rank sum disabled — a bisection, not an inference:
[iso][ti]5.982807168537e-01is #495's number to every digit. The mean-stripped topographymoved 2.4 % at np=2 on the same runs.
The 3.4 % closes completely. There is no residual, so there is no second mechanism
behind that row.
The fix
local↔global scatter before normalising (ADD into the global vector, scatter back),
so every rank ends with a bit-identical normal. Work vectors are created from the
sub-DM rather than borrowed from the variable — the TODO records that a first attempt
SEGV'd on the lazily-built global vec, and that is what it hit.
facet is labelled on two ranks and none is labelled away from its owner. Measured at
np=2, 3, 4 on the annulus and the spherical shell, for both label sources — the
per-boundary label this routine uses and the consolidated
UW_Boundarieslabel.getSupportSize == 1guard. An internal boundary has two support cells and
support[0]is arbitrary;those facets are skipped (so
boundary_normal("Internal")returns zero — apre-existing limitation, now documented rather than silent).
sub-DM instead of a kd-tree lookup of the DOFs nearest the facet centroid. The
kd-tree is a heuristic that can mis-assign on a graded mesh, and — decisively — it
cannot be made to agree across ranks, because each rank's tree is built from its own
local coordinates. The cross-rank sum would have landed on different points on
different ranks.
Yes, a shared helper — one of three, not all five
utilities/facet_normals.facet_measure_and_normalnow owns the orientation rule, themeasure and the internal-boundary guard, and all three copies of the facet loop use it:
rotated_bc._boundary_velocity_nodes,Mesh._assemble_boundary_normal, andboundary_flux._node_normals— the last of which was still on the pre-#560 rule(orient against the mean of this rank's coordinates, which points into the domain on
a concave boundary) under a
TODO(BUG)from #561. Three copies drifting apart isexactly how #564 happened twelve weeks after #560 fixed the first one.
The cross-rank reduction is deliberately not shared. The two are genuinely
different shapes:
rotated_bcreduces a dict keyed by DMPlex point on the solver DM andneeds
_local_boundary_candidatesplus a constrained-DOF fallback; this one reduces adense array over the variable's local DOFs on a mesh sub-DM, where a node whose labelled
facets all live on a neighbour is simply a row that stays zero until the reduction fills
it, and where the mesh DM carries no essential-BC constraints. Forcing one function to
serve both would mean parameterising over the DM, the section, the field id, the node
enumeration and the fallback — more coupling than the ten lines it would save.
The other two flagged in #561 are left alone, with reasons:
FreeSurface._normal_directionis unconditionally+radial. It is not a facet loopand shares nothing with this; it is a sign-convention question against the post-Rotated free-slip: the vertex normal is a bisector but assembly is facet-wise, so a kinked boundary with unequal facets leaves the pressure gauge unpinned #560
σ_nn, and belongs with the free-surface work.
fault_contact._fault_pair_nodeswalks an internal surface where both supportcells exist by design and the orientation is the ±side split. It is a different
rule, not a stale copy of this one.
A second, unrelated defect found while testing — NOT fixed here
mesh.cell_size()is partition-dependent in its own right._get_mesh_sizesmeasures a cell by the distance from its vertices to the nearest centroid in a kd-tree
built from this rank's centroids, so near a seam the nearest centroid can simply be
absent. On
Annulus(cellSize=0.12)the field's sum is 26.0822 / 26.1211 / 26.1386 atnp=1/2/4, and its max moves at np=4.
It reaches users through
add_nitsche_bc(local_h=True), the default: the Nitschefree-slip annulus moves 6.6e-03 in velocity between rank counts, and that does not
shrink with solver tolerance (stable from 1e-9 to 1e-12). With
local_h=Falsethe samesolve agrees to 3.6e-10 at np=1…4, which is how we know the normal is clean and the
local h is what is left.
Marked
TODO(BUG)where it lives, with the numbers. Not fixed here:_get_mesh_sizesalso feeds
get_min_radius, the adaptivity metrics and the free-surface relaxation, andit needs its own benchmarking. It should get its own issue.
2. The test fix — the rotated rows of #564 are a TEST-DESIGN problem
This is a separate matter and this PR does not claim to fix a rotated solver defect,
because there is not one.
Running
test_1064's own_annulus_diagnostics()on one CI host at both rankcounts: np=1
1.897329151623790e-02, np=21.897329151623740e-02— agreement to the13th significant figure, with
leak_loandleak_upidentical to every digit. Bothdiffer from the recorded
GOLDEN_ANNULUSby exactly the same +1.676e-04.The whole discrepancy is that the goldens are constants recorded on macOS/arm64 while
gmsh builds a different triangulation on the Linux runner, and the files are
mpi(min_size=2)so CI has never run np=1 to notice. The assertion message said"differs serial vs np=N" when what it measured was "differs from a number recorded
elsewhere" — and that phrasing is what read as seven instances of one solver bug and
sent the investigation the wrong way for a day.
Every partition assertion in
test_1063,test_1064andtest_1066now comparesagainst a np=1 run of the same file in the same environment
(
tests/parallel/serial_reference.pyspawns a single-rank child, scrubbing thelauncher's
OMPI_*/PMIX_*so it runs as a singleton instead of trying to join theparent job). Whatever mesh the host generates, both sides of the comparison use it.
Each failure message carries both runs' mesh fingerprints — owned cell count and
∫1 dV, both partition-independent — so a future host difference is legible.Checks that are genuinely absolute are kept and relabelled as accuracy checks, with
a comment saying they are not partition checks: the analytic SolCx velocity error,
σ_nn's relL2 against the exact solution, the gathered top-node count, the Schur
iteration ceiling, the converged reason, and the datum residual. Conflating the two is
what this change exists to stop.
One tolerance moved on evidence:
test_1063's topography goes 1e-6 → 1e-5. It is readoff the multiplier, whose
[p,λ]Schur sub-block grinds into its 200-iteration cap onthis problem, and the
[ti]case sits at 6.4e-07 against the old gate — a 1.6× marginis not a tolerance. 1e-5 still leaves three orders below the 2.4 % it has to catch.
xfails removed — all seven
test_1063::test_constrained_freeslip_partition_independent[iso]test_1063::…[ti]test_1063::test_constrained_raw_gauge_partition_independenttest_1064::test_rotated_freeslip_annulus_partition_independenttest_1064::test_rotated_freeslip_spherical3d_partition_independenttest_1064::…spherical3d_topography_partition_independenttest_1066::test_rotated_datum_prescribed_normal_partition_independentThe first three close because of the fix. The last four close because they now measure
what they claim to.
grep -c xfail tests/parallel/test_106*.pyis 0 across the board.Tests
New:
tests/parallel/test_1069_boundary_normal_parallel.py(7 tests), andtests/parallel/serial_reference.pyas the shared np=1-reference helper.The new tests carry their own oracles rather than a recorded constant:
chord normals is exactly radial by symmetry, so
max‖n − ±r̂‖on the annulus needsno golden and no serial run. Gate 1e-6: four orders below the defect, four above the
discretisation floor;
the GLOBAL facet set, gathered and done in numpy. This never touches the section, the
scatter or the DM after the gather, so it cannot reproduce a plumbing bug in the
reduction. It is what covers the 3-D shell, where faceting error is honest;
vertex. This is the regression the fix could most easily introduce;
add_nitsche_bcend to end on a curved boundary with the default normal — theother exposed consumer, previously untested. Pre-fix it moved 6.6e-03 in velocity and
more than doubled the wall-normal leakage across rank counts.
Negative controls, both run. With
_sum_local_dofs_across_ranksreverted to a no-op(i.e. the pre-fix code), 7 of 10 assertions across
test_1063+test_1069fail atnp=2 — the three that pass are the
normal=unit_rgauge test (correctly unaffected, andthe same bisection the investigation used), the box corner test (flat walls are immune)
and the control itself. With a 1e-3 relative partition dependence injected into every
diagnostic, all 14 self-referential assertions across the four files fire. Neither
suite passes vacuously.
Runs
test_1069newtest_1063test_1064+test_1066tests/parallel/test_10*pytest_1018_rotated_freeslip,test_0056_projected_normals_deform,test_1060_nitsche_freeslip,test_1061_constrained_freeslip,test_1062_constrained_solcx,test_1065_nitsche_local_h,test_1065_rotation_gauge_freeslip,test_1024_multiplier_schur_pcscripts/test.sh --p 2tests/test_1064_constrained_spherical_shell_response.pyis the one stakeholder notrun to completion: it is
level_3 / slow / tier_c, is deliberately excluded fromscripts/test.sh's serial batches, and its 3-D LU solves were still running after 35minutes. It exercises
add_constraint_bcon a shell, so it is worth a run before merge.Also
docs/developer/subsystems/rotated-freeslip.mdgains a section on the other twofree-slip paths, the shared helper, the internal-boundary zero and the
cell_sizecaveat, so the doc that already explains this rule for the rotated path now says where
else it applies.
TODO(parallel)atdiscretisation_mesh.pyis gone, replaced by the measurement,as is the
TODO(BUG)onboundary_flux._node_normals' orientation (its remaining gap— no cross-rank sum on an unreachable branch — is stated precisely instead).
Commits
4d66044791384dd35575f886a4dc58c0The last one is worth reading on its own: completing the sum made
_assemble_boundary_normalcollective, anddeform()already called it inside atry: … except: pass. A rank-local failure there would have taken one rank out of thereduction and hung the rest — a swallowed error turned into a deadlock. The rank-local
guard now sits inside the collective, so the outer one can only ever see a symmetric
failure.
Fixes the constrained half of #564, and #495. The rotated rows of #564 are addressed as
the test-design problem the CI experiment showed them to be; no rotated solver change is
claimed.
Underworld development team with AI support from Claude Code