Rotated free-slip: weight the nodal normal by the facet measure the assembly integrates over (#560) - #561
Conversation
…l by the facet measure A boundary node on more than one facet gets ONE nodal normal while the assembler integrates the boundary term facet by facet. We built that normal as the plain bisector of the adjoining facet normals, which is right only where the facets are equal. On a kinked wall with unequal facets the node's free tangential row keeps a residual sin(delta/2)*(|f1|-|f2|)/6, the exact constant-pressure vector stops being a null vector of the constrained rotated operator, PETSc removes the wrong direction from the right-hand side, and the pressure gauge goes unpinned - a silent mean(p) of order 1e4 with converged = True. The weight the assembly asks for is the boundary basis integral: the free tangential row collects sum_f (integral_f phi_i ds) * (t_i . n_f), which vanishes for every tangent only when the nodal normal is parallel to sum_f (integral_f phi_i ds) n_f. For simplicial P1/P2 velocity that integral is the facet MEASURE times a constant that does not depend on which facet it is, so the measure is the weight and the same argument covers 2-D (edge length) and 3-D (face area). In 3-D the node that sees the kink is the P2 edge-midpoint, |f|/3 on each of its two faces; the P2 vertex integral is identically zero, so that row is consistent whatever normal it is given. computeCellGeometryFVM already returns the measure - we were discarding it. Where a node's facets all carry the same normal (every flat wall, and the whole analytic-normal path, whose normal is a function of the node coordinate alone) the weights are one common positive factor, so the weighting is skipped and those results stay bit-for-bit unchanged. Fixes #560. Underworld development team with AI support from Claude Code
…efect mean(p) and the run-to-run velocity move are the round-off amplitude of an unpinned direction: over the issue's amplitude sweep, amplitude 0.05 moved by 7e-09 while 0.01 moved by 2.6e-01. They report the presence of the defect and not its size, and they can report a fix that is luck. |A z|/|A| - the constrained operator applied to the attached constant-pressure vector, normalised by sigma_max - scales as amplitude cubed and as h^3.3, so it is what these tests assert. Each check carries its negative control, run with the pre-fix bisector normal frozen into the test file: without it the assertions could pass on a metric that cannot see the defect at all. The skewed annulus is here as the second oracle - both arcs stay exactly circular and only the facet lengths change, so it separates the mechanism from the deformed-box symptom. Underworld development team with AI support from Claude Code
…lytic normal still owes The geometric path is now consistent with the assembly; an analytic normal= is a deliberate override that is tangent to the TRUE surface while the assembler still integrates over the straight facets, so on a strongly non-uniform curved boundary it keeps the consistency error the geometric path no longer has. Readers choosing between the two need that stated. Underworld development team with AI support from Claude Code
There was a problem hiding this comment.
Pull request overview
This PR fixes a rotated free-slip correctness issue on kinked/curved boundaries by changing how geometric nodal normals are accumulated so they match the facet-wise boundary assembly, restoring the constant-pressure nullspace compatibility and preventing an unpinned pressure gauge.
Changes:
- Update rotated free-slip geometric nodal-normal accumulation to be facet-measure weighted (edge length in 2D, face area in 3D), while preserving bit-identical behavior on coplanar facets.
- Add regression tests that capture the constrained rotated operator and assert the attached constant-pressure mode remains a null vector (with explicit negative controls using the pre-fix bisector rule).
- Document the measure-weighted normal rule and its implications for analytic
normal=overrides.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| tests/test_1018_rotated_nodal_normal.py | New regression suite validating constant-pressure nullspace compatibility; includes negative controls. |
| src/underworld3/utilities/rotated_bc.py | Implements measure-weighted accumulation for geometric facet normals in rotated BC construction. |
| docs/developer/subsystems/rotated-freeslip.md | Documents the measure-weighted nodal-normal rule and analytic-normal behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| self.nsp = [np.asarray(v.array_r).copy() | ||
| for v in kw["nsp"].getVecs()] | ||
| return self._orig(solver, Ahat, bhat, Q, Qt, normal_rows, **kw) |
| """DMPlex points carrying velocity DOFs on `boundary`, each with an outward | ||
| unit normal. Dimension-general (2D edges, 3D faces). |
Adversarial review — PR #561, rotated free-slip nodal normal (fixes #560)Reviewed at VerdictThe code change is right and we found no regression anywhere we looked. The derivation Request changes. Merge once MB-1..MB-3 are addressed. MERGE-BLOCKERSMB-1 — The fix does not survive partitioning, and nothing in the PR says so
Skewed annulus (
13 orders in serial; 4% at np=2 and np=4. On the deformed box at np=4 the fix is a Mechanism, pinned rather than inferred. At a seam vertex both adjacent exterior edges So this is a label-distribution gap, not an overlap gap. Consequences we measured on the Two things follow that the PR must not ship without.
This is not a regression (before ≈ after at np≥2), so we are not blocking the code. We are MB-2 — The PR adds documentation advice that is wrong in parallel
Measured, skewed annulus,
The analytic column is identical to seven digits at every rank count and byte-identical Worse, the same file now contradicts itself 25 lines apart: line 20-21 still says an Six further shipped locations still carry the old advice and are untouched by the PR. The
Given MB-1 the old advice is not actually wrong, so these are cheap to reconcile — but they MB-3 — The free surface IS configuration B, and its answers move by 24%The PR states:
That is wrong for the shipped Measured directly (res=20 simplex box, essential
Step 0 (surface still flat) is bit-identical — the PR's flat-wall claim holds in a real So this PR fixes the free surface, which is a better result than it claims. But HIGHH-1 — The scope claim is right for simplices and for all of 2-D; over-stated for 3-D hexesThe docstring says "the measure IS the right weight and the same argument covers 2D and
Tets return exactly to their flat control. Hexes do not: the remainder is 7x and 16x the For completeness on the other two scope axes we were asked about:
H-2 —
|
| claim | PR | ours | verdict |
|---|---|---|---|
| 2-D deformed box B, before | 5.902e-10 | 5.9015e-10 | ✓ |
| 2-D deformed box B, after | 2.653e-18 | 2.6241e-18 | ✓ |
| straight box 2-D bit-identical | 2.733e-18 both | 2.7437e-18 both, all digits | ✓ |
| straight box 3-D bit-identical | 2.361e-18 both | 8.0783e-19 both; normals 390/390 identical | ✓ |
| analytic override byte-identical | byte-identical | 1.9147e-13 and 4.7248e-06, identical before/after and at np=1,2,4 | ✓ |
| uniform annulus geometric changes | 2.843e-14 → 7.037e-19 | 9.5737e-14 → 1.1343e-18 | ✓ benign (M-2) |
| 3-D tet, amp 0.02 / 0.05 | 1.942e-06 / 4.813e-06 → 2.4e-18 | 5.972e-07 / 1.480e-06 → 8.3e-19 | ✓ same character |
test_1018_rotated_nodal_normal + test_1018_rotated_freeslip |
5 + 22 | 27 passed (143 s) | ✓ |
| free surface + spherical (1070/1071/1072/1011/1110) | 6+1s, 1, 2, 3, 1 | 13 passed, 1 skipped (222 s) | ✓ |
CI batch test_101* + test_102* |
142 | 142 passed (319 s) | ✓ |
| parallel rotated (1064 + 1066) | 11 at np=2 | 11 passed at np=2 and 11 at np=4 | ✓ (but see MB-1) |
CI batch test_08* (the fault suite) |
605 | 605 passed, 11 skipped, 11 xfailed (763 s) | ✓ |
Four CI batches run end-to-end here (test_101*+test_102*, test_08*, the free-surface
and spherical files, both rotated files) plus the parallel rotated suite at two rank counts.
Every count the PR quotes for those matched ours exactly. We did not re-run the other nine
batches; nothing we found suggests they would move.
Marker-collection gap: reproduced. pytest -m "level_1 and tier_a" over the whole
tests/ tree exits 1 having produced zero bytes of output — and it does so even with
--co -q, i.e. it dies in collection, not in the run phase. That matches the implementer's
report; we did not chase it, and we agree it deserves its own issue. Separately, another
session was running pytest -m level_1 -n 8 on this machine throughout, which is worth
knowing before anyone reads timings off this review.
Artifacts
- Review:
/private/tmp/claude-501/-Users-lmoresi--Underworld-underworld3-pixi/64e34dd9-4af2-4774-9d55-4238a998eb6e/scratchpad/r561_review.md - Probes and logs:
/private/tmp/claude-501/-Users-lmoresi--Underworld-underworld3-pixi/64e34dd9-4af2-4774-9d55-4238a998eb6e/scratchpad/r561/
—common.py(independent pre-fix control +|A z|capture, serial and PETSc-matvec),
probeA_repro_2d.py,probeB_scope_3d.py(tet vs hex),
probeC_parallel.py+analyse_par.py(cross-rank normals),
probeD_seam.py(label vs mesh),probeE_annulus_par.py(np sweep, both normal sources),
probeF_freesurface.py(held lid),probeG_bitident.py(tilted / simplex flat walls),
branch_check.py. - Worktree:
/Users/lmoresi/+Underworld/underworld3-pixi/.claude/worktrees/r561-review
(branchbugfix/r561-review, reset topr561-head).
Nothing was posted, pushed or merged.
Underworld development team with AI support from Claude Code
…oss ranks
A boundary facet is labelled on exactly one rank, so a node on a partition seam
sees only some of its facets locally: both adjacent exterior edges are in the
rank's local mesh but only one carries the label. Accumulating rank-locally gave
that node a different normal on each rank and made the answer rank-count
dependent - on a deformed box at np=4 the measure weighting was a bit-for-bit
no-op, and a UNIFORM annulus, the case everyone treats as clean, measured
1.4e-05 at np=4 against 5.7e-15 in serial. The seam defect does not need a kink,
it needs a partition.
The weighted contributions are now summed through the DM's own local-to-global
scatter before normalising, so every rank computes a bit-identical normal at a
shared node. That is exact without de-duplication because no boundary facet is
labelled twice and none is labelled away from its owner - both measured. The
reduction is collective: a rank owning no facet of the boundary still takes part.
Completing the sum exposed two more defects that only a cross-rank sum can see:
* the outward-orientation test pointed away from the mean of THIS RANK's
coordinates, so two facets meeting at a seam node could be oriented
oppositely and CANCEL in the sum. It now points away from the facet's own
support cell - local geometry, no global reference, and also correct on a
non-convex domain where the coordinate mean sits in the hole;
* a rank can OWN a boundary node every one of whose labelled facets lives on a
neighbour, so enumerating nodes from the labelled subset missed it and it
never got a constraint row. The node list now comes from the local mesh's
exterior facets.
Measured, |A z|/|A|_F on the skewed annulus: np=1 1.541e-07 -> 6.624e-20, np=2
1.611e-06 -> 6.623e-20, np=4 1.392e-05 -> 6.690e-20, np=8 6.713e-20. Shared
boundary nodes now agree bitwise across ranks (max |dn| was 1.30e-01 at np=2 and
1.63e-01 at np=4).
The analytic-normal path is deliberately NOT reduced: it evaluates a function of
the node coordinate, so every rank already computes the same value and summing
copies would rescale it by a rank-count-dependent factor. It stays byte-identical.
The co-planar shortcut is gone with the same change. It decided co-planarity from
the facets ONE RANK could see, which is exactly the quantity the seam splits, and
it was never needed: an axis-aligned wall has facet normals with exactly 0/+-1
components, so the weighted sum normalises to the same floats as the unweighted
one whatever the weights. Measured 68/68 nodes identical on a structured quad box,
68/68 on a 2-D simplex box, 390/390 in 3-D. A flat but TILTED wall does move by
one ulp, so the claim is now "axis-aligned", not "flat".
Also corrects the scope claim: the measure is the EXACT weight for simplicial
facets and for any 2-D facet at any degree, but on a non-affine 3-D quad facet it
is only leading order - a deformed hex box retains 3.4e-17/8.3e-17 against a
5.1e-18 flat control where tets return exactly to theirs.
Underworld development team with AI support from Claude Code
…e exposed than a wall fault_contact._fault_pair_nodes accumulated unit facet normals and normalised - verbatim the pre-#560 code, in a second copy that the first fix did not touch. The same weighting applies, and the algebra says the fault is worse off than a wall rather than protected by being an interior surface: * the pair block writes MEAN rows on the Plus point and JUMP rows on the Minus point and constrains only the jump-normal row, so the jump-tangential (slip) rows are free - that freedom IS the zero-shear-traction condition; * a constant pressure gives F- = -F+ exactly, the two sides being geometrically identical with opposite outward normals, so it CANCELS COMPLETELY in the mean rows - and DOUBLES in the jump rows; * the free slip row therefore collects sqrt(2)*p*sin(delta/2)*(|f1|-|f2|)/6: the #560 residual times sqrt(2), which breaks the pressure gauge AND injects a pressure-driven spurious slip at every kink node. On a deep fault that scales with the lithostatic pressure. The existing leak diagnostic contracts the jump with the same normal that defined the constraint, so it is true by construction and cannot see this. Straight and planar faults and the whole analytic-normal override (normal=, "trace", "surface") are unaffected - one normal per node makes the weights a common positive factor. Fault pair nodes are rank-local by construction (a seam-touching fault is redistributed onto one rank before the split), so unlike the wall path this needs no cross-rank sum. Underworld development team with AI support from Claude Code
…n folklore
Five places told users to prefer an analytic normal on a curved boundary, and the
first revision of this fix added a sixth passage arguing the opposite, so the
tree contradicted itself in two files 25 lines apart. Re-measured now that the
geometric path is partition-independent, |A z|/|A|_F on an annulus:
boundary np geometric (default) analytic X/|X|
uniform arcs 1 6.6e-20 1.1e-14
uniform arcs 4 6.7e-20 1.1e-14
skewed 1 6.6e-20 3.1e-07
skewed 4 6.7e-20 3.1e-07
The two normals answer different questions and that is now the wording: the
geometric one is consistent with the ASSEMBLY - the straight-facet integral the
code actually evaluates - and the analytic one is consistent with the GEOMETRY,
the true surface the facets only approximate. After the measure weighting the
default is exact at every rank count; an analytic override keeps a consistency
error that grows with facet non-uniformity and that this fix does not remove.
Reconciled in place across the subsystem doc, the accumulator's own docstring,
the add_rotated_freeslip_bc docstring, CLAUDE.md and the adapt-on-top-faults
skill, rather than leaving a sixth statement to be reconciled later.
Underworld development team with AI support from Claude Code
…rkaround
Three fixes to the regression tests, and one deletion that the fix earned:
* the analytic-normal block had no key-set guard, so an empty stratum - a
relabelled mesh, a typo'd arc, a rank owning no arc - would have passed it
with ZERO comparisons. It is the only test exercising the analytic path;
* the frozen bisector control looked the real function up at call time, so
installing it over the module attribute and passing an analytic normal
self-recursed. It now binds the real function at import. No current test
triggered it, purely by ordering accident, and the obvious next test does;
* the bit-identity claim said "flat walls" and is only true for AXIS-ALIGNED
ones - a flat wall tilted 30 degrees moves 27 of 68 nodes by one ulp.
test_rotated_workspace_deform_invalidates goes back to full strength. It had been
comparing the two solves with the unpinned direction projected out, measured by
an extra Stokes solve, because #560 left a direction the operator did not pin.
That probe now reads 9.9e-15 instead of 1.3e-01, so the projection, the probe
solve and the pre-fix prose stated as current fact are all deleted and the
solutions are compared directly at the original 1e-6 tolerance - as the test's
own else-branch instructed.
Underworld development team with AI support from Claude Code
|
Response commits MB-1 fixed — and it was hiding two more defects. The weighted sum is now completed across ranks through the DM's own local↔global scatter (ADD, then scatter back), collective on every rank including those owning no facet. Fixing it exposed: (a) the outward test was rank-local — it oriented facets away from the mean of that rank's coordinates, so two facets at a seam node could be oriented oppositely and cancel in the sum; now oriented away from the facet's own support cell, which also fixes non-convex domains where the coordinate mean sits in the hole; (b) a rank can own a node whose labelled facets all live on neighbours and got no constraint row at all — the node list now comes from the local mesh's exterior facets. The co-planar shortcut is gone: it decided co-planarity from the facets one rank could see, which is exactly what a seam splits.
Read the uniform annulus first: the configuration everyone treats as clean was the worst one in parallel. The seam defect does not need a kink, it needs a partition. Shared-node normals now agree bitwise across ranks (was max |Δn| 1.30e-01 at np=2, 1.63e-01 at np=4); MB-3 — the free surface is configuration B, and Crameri is safe. Reproduced the review's numbers exactly ( H-2 — MB-2 — re-measured after MB-1 and written as the measurement, reconciled in place across all six locations rather than adding a seventh statement: geometric 6.6e-20 at every rank count; analytic 1.1e-14 (uniform) / 3.1e-07 (skewed), unchanged by the fix. The advice now says the two normals answer different questions — assembly-consistent versus geometry-consistent. H-1 — docstring now claims exact for simplices and all of 2-D, leading-order on non-affine 3-D quads. M-3/L-1/L-2/M-1 all addressed ("flat" corrected to "axis-aligned"; the deform test's now-redundant projection removed). Verification: 27 rotated · 13 parallel at np=2 and np=4 · faults 6/8/6/10 · free surface 6+1skip/1/2 · One gap found and not fixed here: the nondimensional→Myr time scale is recorded nowhere ( Underworld development team with AI support from Claude Code |
Adversarial re-review — PR #561, response commits (fixes #560)Re-reviewed at VerdictThe parallel mechanism is sound, and we verified it rather than accepting it. We What we are blocking on is again what the PR does not say, plus one wiring gap. The Request changes. Merge once MB-1 and MB-2 are addressed — both are edits, not MERGE-BLOCKERSMB-1 — The new outward test flips σ_nn on every concave boundary, silently
Annulus (
Three things follow, none of which is in the PR body, the docstrings or a test.
We think the new behaviour is the right one: it is what the docstrings have always MB-2 — The parallel test that guards this fix is not run by the project's test script
The second line is commented out, and We confirmed the test is a real fail-before guard: with the merge-base modules installed HIGHH-1 —
|
| mesh | np | candidates/rank | of which not on the domain boundary |
|---|---|---|---|
| skewed annulus | 2 | 78, 78 | 10, 10 (12.8%) |
| skewed annulus | 4 | 48, 48, 48, 44 | 12 each (25–27%) |
| 3-D tet box | 4 | 170, 178, 190, 190 | 34, 32, 31, 43 (16–23%) |
The result is still correct, but not for the stated reason: those points receive no
contribution from any labelled facet, so if w.any() drops them. That filter is the
load-bearing safety property and it is documented as an aside about constrained DOFs.
Fix the docstring to say what actually holds ("support 1 over-collects at the overlap
fringe; the zero-sum test discards the extras"), because the next person to touch the
node set will trust the sentence that is there now.
H-2 — The constrained-DOF fallback is justified for one consumer and used by two
_sum_facet_normals_across_ranks:303-307 falls back to the rank-local contribution when
a node's velocity DOFs are constrained out of the global vector, on the grounds that
"build_rotation skips such nodes anyway". That is true — build_rotation:402 drops
l2g < 0. But boundary_normal_traction:2009 consumes the same list, reads
n̂·r_c at every returned node including those, and hands them to _desmear with
partial_reaction=False (overwrite-by-coordinate). If such a node is shared, two ranks
overwrite the same coordinate with normals summed from different facet subsets.
This configuration is shipped, not hypothetical — essential BC on Bottom + rotated
free-slip on the other walls is tests/test_1070::_case2. We measured it (simplex box
1.0 × 0.25, cellSize=0.25/6): 2 nodes with l2g < 0 at np = 2, 3, 4 and 8, at
every rank count. We could not make them disagree — at the partitions we got, the two
constrained corners were never shared, so all 75 node normals stayed bit-identical across
ranks and against serial. So this is a documented-reason gap rather than a demonstrated
defect: either extend the comment to cover boundary_normal_traction, or drop
constrained nodes from the returned list outright (nothing downstream wants them).
MEDIUM
M-1 — The subsystem doc still carries the claim the same commit series corrected
f6e27044 correctly narrowed "flat walls are unchanged to the last bit" to
axis-aligned in rotated_bc.py:153-156 and in
tests/test_1018_rotated_nodal_normal.py:227. docs/developer/subsystems/rotated-freeslip.md:40-41
still says:
Flat walls are unchanged to the last bit: every facet there shares a normal, so the
weighting cancels.
Both halves are now wrong: "flat" should be "axis-aligned", and the reason describes the
deleted co-planarity shortcut ("the weighting cancels"), not the surviving mechanism
(0/±1 components normalising identically). One-line edit, but it is the user-facing
document and it contradicts the docstring 100 lines away.
M-2 — The boundary setup got 1.5–5.7× more expensive, and no longer scales down
3-D tet box, cellSize=0.12 (7505 local facets, 1689 labelled boundary facets), all six
rotated boundaries, best of 3:
| np | BEFORE (a25dd1e4) |
AFTER (PR) | ratio | of which _local_boundary_candidates |
|---|---|---|---|---|
| 1 | 9.5 ms | 14.3 ms | 1.51× | n/a (skipped at size 1) |
| 2 | 4.8 ms | 23.2 ms | 4.87× | 6.4 ms (28%) |
| 4 | 2.5 ms | 14.3 ms | 5.71× | 3.7 ms (26%) |
The before column halves with each doubling of rank count; the after column does not,
because _local_boundary_candidates walks the entire local facet stratum (with a
getTransitiveClosure per support-1 facet, in Python) and the scatter is over the whole
DM vector — both O(local mesh) per boundary, where the old code was O(boundary facets).
Mitigated in practice by the rotation cache (rotated_bc:1061-1068, and its verdict is
made collective by an allgather), so this only bites when the geometry changes — but
that is exactly the free-surface / adaptive loop. Worth a note; not worth blocking. The
candidate walk can be restricted to the closure of the labelled facets' vertices' stars
if it ever matters.
M-3 — The fault rank-locality claim is verified in 2-D only
We tested the configuration the claim has to survive: the spanning fault
([0.06,0.08] → [0.94,0.92], the geometry ptest_0845 uses precisely because it crosses
any contiguous partition), built through the user path mesh.add_fault, with
add_frictionless_fault_bc + solve_with_fault.
| np | pair nodes | shared across ranks | normals differing from serial | local facet-count mismatches |
|---|---|---|---|---|
| 1 | 63 | — | — | — |
| 2 | 63 | 0 | 0 (max |dn| 0.0) | 0 |
| 3 | 63 | 0 | 0 | 0 |
| 4 | 63 | 0 | 0 | 0 |
Not one pair node is shared at any rank count, and every node sees exactly its serial
complement of fault facets locally — so the measure weighting is complete rank-locally
and 91dc8fda needs no cross-rank sum, as claimed. build_rotation:471-482 also keeps
an explicit RuntimeError if a pair ever straddles. Claim verified. The residual is
that we did not repeat this in 3-D (ptest_0848); the 3-D splitter uses the same
redistribution, but the PR asserts the property for both and only 2-D is measured here.
LOW / NITS
- L-1. Cross-rank identity is exact; serial-vs-parallel is exact except one 3-D ulp.
On the 3-D tet box at np=4, 4 of 618 (wall, node) pairs differ from the serial normal by
3.469e-18 — the scatter's ADD order is not the serial dict's. Every other geometry
and rank count is 0.0 to the last bit, and cross-rank agreement is 0.0 everywhere,
which is what the new test asserts. Nothing to fix; worth knowing before someone writes
a serial-vs-parallel bit-identity assertion. - L-2. Test number collision.
tests/parallel/test_1067_rotated_nodal_normal_parallel.py
andtests/test_1067_newton_cold_start.pyboth claim 1067. - L-3. Deleting the shortcut moved the tilted-wall count. Round 1 measured 20 of 68
nodes moving by 1 ulp on a 30°-tilted flat wall (shortcut present); with it gone we
measure 27 of 68, max |dn| unchanged at 1.570e-16 — which is the number
fc81ba32's message quotes. Magnitude unchanged; the claim is stated correctly. - L-4. A node whose weighted normals cancel is now silently dropped
(return ... if np.linalg.norm(v) > 0.0). Pre-fix such a node reached the SVD and got
r=0, i.e. also no constraint — so no behaviour change, but the drop is now invisible
where it used to be traceable. - L-5.
_boundary_stratum_is(...).getSize()on a null IS segfaults — we hit it in our
own probe at np=4. The PR code guards it correctly everywhere; a helper returning an
empty IS instead of NULL would stop this recurring (this is the Stokes_Constrained segfaults at np>1 in the interior-multiplier section reduction #291 class). - L-6.
test_1067's two tests both build and solve the annulus (2 Stokes solves for
what is really one measurement);_ConstantPressureResidualduplicates the serial
file's capture with a different normaliser, correctly flagged in its own docstring.
What we verified of the response's own claims
Independently reproduced in our worktree; verified, not accepted.
| claim | response | ours | verdict |
|---|---|---|---|
| no boundary facet labelled twice | asserted | 0 duplicates, np=2/3/4/8, on 4 geometries | ✓ |
| none labelled away from its owner | asserted | 0, all runs (checked against the point SF) | ✓ |
| labelled facet count = serial | — | 66/66, 40/40, 258/258, 36/36 at every np | ✓ |
| reduction is collective (rank with no facet) | asserted | at np=8, 6 of 8 ranks own no Left facet, 7 of 8 no Right, one owns no Top — no hang, complete normals |
✓ |
| shared-node normals bitwise identical | asserted | max |dn| = 0.0 at np=2/3/4/8, geometric and analytic | ✓ |
| no node loses its constraint row | asserted | rows = serial exactly (132/132, 84/84, 618/618, 73/73), 0 missing, 0 duplicated | ✓ |
| skewed annulus |A z|/|A|_F | 6.62e-20 (1), 6.62e-20 (2), 6.69e-20 (4) | 6.624e-20, 6.623e-20, 6.690e-20; and np=3 6.629e-20, np=8 6.713e-20 | ✓ |
| uniform annulus was the worst in parallel | 5.71e-15 / 1.49e-06 / 1.40e-05 → ~6.7e-20 | 5.711e-15 / 1.485e-06 / 1.396e-05 → 6.62/6.78/6.72e-20 | ✓ |
| non-convex orientation now correct | asserted | inner-arc radial component −1.000000 on all 48 nodes, 0 wrong-signed | ✓ (see MB-1) |
| shortcut deletion changes no axis-aligned node | 68/68, 68/68, 390/390 | 68/68, 68/68, 390/390, max |dn| 0.0 | ✓ |
| fault pair nodes rank-local | asserted | 63/63, 0 shared, 0 differing, np=2/3/4 on a spanning fault | ✓ |
| Crameri unchanged to 10 s.f. | 360.01 … 463.86 | 360.01, 376.51, 392.31, 404.88, 419.65, 433.80, 450.58, 463.86; rel. diff before/after 7.9e-11 … 3.1e-10 | ✓ |
test_1067 at np=2/4 |
13 parallel | 2 passed at np=2, 3 and 4; fails on merge-base modules (negative control fires) | ✓ |
| rotated parallel suite | — | 12 passed at np=2 and at np=4 (1062+1064+1066) | ✓ |
test_101*+test_102* |
27 rotated | 142 passed (197 s) | ✓ |
test_08* (faults) |
6/8/6/10 | 605 passed, 11 skipped, 11 xfailed (348 s) | ✓ |
| free surface + spherical (1070/1071/1072/1011/1110) | 6+1skip/1/2 | 15 passed, 1 skipped (117 s) | ✓ |
One parallel failure, and it is not this PR.
tests/parallel/test_1063_constrained_freeslip_parallel.py::test_constrained_freeslip_partition_independent[iso]
and [ti] fail at np=2 and np=4 — velocity L2 0.6194547793955 (golden) vs
0.5982807168536982 (np=2), a 3.4% partition dependence in the multiplier free-slip
path. We attributed it rather than reporting it: with a25dd1e4's rotated_bc.py and
fault_contact.py copied over site-packages the failure reproduces to the last digit,
and the whole PR touches only those two modules plus one .pyx docstring.
Stokes_Constrained never calls _boundary_velocity_nodes. Pre-existing; needs its
own issue. (The same swap makes test_1067 fail, which is how we know it took effect.)
Carried forward from round 1 — not re-verified
Cleared last round and untouched by the response commits, so we did not re-measure:
- the derivation itself, and the serial
|A z|numbers for configs A / B / T / T2 — the
scope wording is now corrected exactly as H-1 asked (exact for simplicial facets and
all of 2-D, leading-order on non-affine 3-D quads), and the deform test now passes at
full strength inside the 142-test batch; - the 3-D hex caveat (H-1) — the docstring now states it; not re-measured;
- the analytic path's serial consistency error (3.082e-07 skewed / 1.9e-13 uniform);
- L-3 (
nsp[0]ordering), L-4 (test cost), L-6 (no 3-D coverage in the serial file).
Re-checked because a response commit touched them:
- straight-wall bit identity — the co-planarity shortcut is deleted, so we re-ran it
(68/68, 68/68, 390/390, 27/68 tilted at 1 ulp); - analytic-path byte identity — re-verified across ranks and against serial, since the
path now shares the node-set code with the reduced one; - the uniform-annulus geometric change (round-1 M-2) — re-measured, now 5.711e-15 →
6.621e-20 serial, and partition-independent; - round-1 M-3's two test holes — both closed (
assert fixed and fixed.keys() == ...on
the analytic block;_real=rbc._boundary_velocity_nodesbound at import); - round-1 M-1 — the projection, the probe solve and the pre-fix prose are gone; the
surviving mentions at:404-407and:515-525are correctly written as history.
Artifacts
- Re-review:
/private/tmp/claude-501/-Users-lmoresi--Underworld-underworld3-pixi/64e34dd9-4af2-4774-9d55-4238a998eb6e/scratchpad/r561_rereview.md - Round 1:
.../scratchpad/r561_review.md - Probes and logs:
/private/tmp/claude-501/-Users-lmoresi--Underworld-underworld3-pixi/64e34dd9-4af2-4774-9d55-4238a998eb6e/scratchpad/r561/
—pa_audit.py+pa_report.py(label / orientation / node / row audit, 4 geometries
× np 1–8),pb_sign.pyandpc_concave.py(the concave sign flip and σ_nn),
pd_cand.py(spurious candidates),pf_crameri.py(+crameri_before.json,
crameri_after.json),pg_fault.py(spanning fault, np 1–4),ph_cost.py,
probeE_annulus_par.py(metric sweep),probeG_bitident.py,common.py(the
independenta25dd1e4control), logsg_101x.log,g_08.log,g_fs.log,
g_par2.log,g_rot{2,4}.log. - Worktree:
/Users/lmoresi/+Underworld/underworld3-pixi/.claude/worktrees/r561-review
(branchbugfix/r561-review, reset topr561-head2=f6e27044). The site-packages
module swap used for the negative control was restored and byte-compared against the
worktree source before the final runs.
Timing caveat: another session was running pytest -m level_1 -n 8 on this machine during
part of this review, so read M-2's absolute milliseconds as best-of-3 under contention;
the ratios were measured back to back.
Nothing was posted, pushed or merged.
Underworld development team with AI support from Claude Code
… that guard it Three things the re-review caught, none of them a re-derivation. THE SIGN CONVENTION. Orienting away from the facet's own support cell is the DOMAIN's outward normal on any boundary - and on a CONCAVE one (an annulus or shell inner arc, the CMB) that is the opposite of what UW3 produced before #560, which oriented against the mean of the mesh coordinates and so pointed INTO the domain there. Measured on the annulus: nodal radial component on Lower +1.000000 -> -1.000000, and boundary_normal_traction("Lower") -5.233110e-02 -> +5.233110e-02, magnitudes identical to every digit. dynamic_topography_field is h = -sigma_nn/(drho g) on top of that, and the sign of a non-zero prescribed wall-normal datum follows it too. The new sign is the right one - it is what the docstrings have always claimed - so this is stated and pinned rather than reverted. An analytic normal= is NOT reoriented: the override means "use exactly this direction", and silently flipping it would change the meaning of a user's datum. So X/|X| on an inner arc is inward-of-domain and disagrees in sign with the default, and the docs now say so and tell users to pass -X/|X| if they want the domain convention. Audited: NO recorded golden moves - every curved-boundary golden in the suite goes through an explicit normal=, and the two geometric-path goldens are on a box Top and are sign-normalised in the assertion anyway. INTERNAL BOUNDARIES. The support-cell flip is now guarded by getSupportSize == 1. An internal boundary's facets have two support cells and support[0] is whichever the DMPlex ordering lists first, so flipping against it would orient neighbouring facets of the same surface oppositely and they would CANCEL in the measure-weighted sum. Untested and unused today (rotated free-slip is never applied to an internal boundary in the suite), but it was a defect this change introduced. Both sibling implementations guard the same way. THE PARALLEL TESTS NOW RUN. scripts/test.sh ran exactly one parallel glob, tests/parallel/test_075*py; the test_10*py line was commented out, so test_1017 and test_1062..1068 - the whole rotated / constrained / MG parallel set, including the partition-independence guard this fix depends on - executed at NO rank count in CI. Enabled. That surfaces a genuine pre-existing failure in test_1063_constrained_freeslip_parallel (#495, 3.4% partition dependence in the MULTIPLIER free-slip path, which never calls _boundary_velocity_nodes), so it is marked xfail(strict=False) with the measured numbers rather than papered over - the other five tests in that file now actually run. Also: _local_boundary_candidates documented what it really returns (support 1 over-collects at the overlap fringe - 13-27% of its output is interior - and the zero-sum test in the caller is what discards the extras, which makes that test load-bearing rather than an aside); the constrained-DOF fallback comment now covers boundary_normal_traction, which consumes the same list; the frozen bisector control notes that it differs by SIGN as well as weighting on a concave boundary; the subsystem doc's "flat walls" claim corrected to "axis-aligned" with the surviving reason; and the parallel test renamed 1067 -> 1068 to stop colliding with test_1067_newton_cold_start. Two stale copies found and marked rather than silently fixed: boundary_flux._node_normals still carries the old rank-local orientation (dead - its only caller guards it with `if normal is not None`), and FreeSurface._normal_direction is unconditionally +radial, which would fight the now -radial sigma_nn if an inner free surface were ever built. Underworld development team with AI support from Claude Code
|
Response commit MB-1 — sign reconciled, and no golden moves. The geometric normal keeps the corrected sign ("outward" = out of the domain, which on an inner arc points toward the centre); The analytic override is deliberately not reoriented: Consumer audit: every curved-boundary golden already goes through an explicit Pinned by A defect the audit found in my own change, now fixed: the support-cell flip had no MB-2 — the parallel solver tests now run. HIGH — Verification: Marked, not silently fixed: two stale copies of the old orientation rule carry Underworld development team with AI support from Claude Code |
…aced (#564) Uncommenting tests/parallel/test_10*py in scripts/test.sh made twenty parallel solver tests run in CI for the first time and turned up seven failing partition-independence assertions. A baseline probe settles the attribution: at this PR's merge base with ONLY that one line changed, the same seven fail with numbers identical to every digit. None of them is caused by #560/#561. They are also wider than #495, which recorded this for the multiplier free-slip path alone. The ROTATED path is affected too - annulus 1.7e-04, 3-D spherical 1.1e-03, spherical topography 5.9e-03, prescribed datum 9.4e-06 - and every one of those rotated cases passes an explicit analytic normal= whose values are bit-identical before and after the nodal-normal fix, so the #560 mechanism is definitively not the cause. #564 carries the full table; #495 is now one member of the family and the two existing xfails point at both. All seven are xfail(strict=False) with their own measured serial-vs-np=2 numbers in the reason, so a reader who hits one lands on the issue rather than guessing. strict=False because they PASS locally on macOS/arm64 - the dependence is partition- and geometry-specific, and CI's meshes expose what this machine does not. Locally the batch reads 20 passed, 2 xfailed, 5 xpassed at np=2 and np=4. The batch stays ENABLED. That is the point: it is a net gain of twenty parallel tests that had never executed at any rank count, and it keeps #564 visible on every run instead of dormant in the tree. Re-commenting the line to get a green board would hide a real defect. Underworld development team with AI support from Claude Code
|
Response commit We built Filed as #564 — and it is wider than #495: the ROTATED path is partition-dependent too (annulus 1.7e-04, spherical 3-D 1.1e-03, spherical topography 5.9e-03, prescribed datum 9.4e-06), not just the constrained/multiplier path. Those rotated cases pass an explicit analytic All seven are now The batch stays enabled, which is now the more valuable half of this PR:
Underworld development team with AI support from Claude Code |
Weight the rotated free-slip nodal normal by the facet measure
Fixes #560.
The mechanism, in two sentences
A boundary node that sits on more than one facet gets one nodal normal, while the
assembler integrates the boundary term facet by facet — and the normal we built was
the plain bisector of the adjoining facet normals, which is only right when those facets
are the same size. On a kinked wall with unequal facets the node's free tangential row
therefore keeps a residual
sin(Δ/2)·(|f₁|−|f₂|)/6, the exact constant-pressure vectorstops being a null vector of the constrained rotated operator, PETSc projects the wrong
direction out of the right-hand side, and the pressure gauge is left unpinned — a silent
mean(p) ≈ 1e4offset and a velocity whose last 13% is set by round-off, withconverged = True.The weight, derived from the assembly
For a constant pressure
pthe boundary term the assembler produces in the row ofvelocity basis function
φ_iisp ∮ φ_i n ds, summed facet by facet. In the rotatedframe the free tangential row therefore carries
which vanishes for every tangent only when the nodal normal is parallel to
Σ_f ( ∫_f φ_i ds ) n_f. So the weight is the boundary basis integral, not the facetcount. For simplicial P1/P2 velocity that integral is the facet measure times a
constant that does not depend on which facet it is, so the measure is the correct
weight and the same argument settles 2-D and 3-D:
∫_f φ_i ds|f|/62|f|/3, one facet only|f|/3|f|/3Two things worth stating because they are not obvious. In 3-D the node that sees the kink
is the edge-midpoint, not the vertex: a boundary edge is shared by exactly two faces,
while the P2 vertex basis function integrates to exactly zero over a triangle, so the
vertex row is consistent regardless. And the constant in front of
|f|cancels out of theparallelism condition, which is why the measure works while the basis integral itself
does not (it would give a zero normal at every 3-D P2 vertex).
computeCellGeometryFVMalready returns that measure — the change is to stop discardingit. On a node whose facets all carry the same normal the weights are one common
positive factor, so the code skips the weighting there and keeps the historical unweighted
sum bit-for-bit.
Before / after
10×10
StructuredQuadBox,penalty=0,tolerance=1e-9,petsc_use_pressure_nullspace=True, rotated free-slip on all four walls; the annulus rowsare
radiusOuter=1.0,radiusInner=0.55,cellSize=0.1, both arcs rotated. Measured withthe issue's own probes (
probe1_taper.py,probe2_nullspace.py,probe4_sweep.py,probe7_areaweight.py,probe8b_annulus.py), unchanged, before and after.|A z|/|A|beforemean(p)before(The uniform-annulus principal angle moves from 3.5e-07 to 6.5e-07 deg. Both are the
noise floor of a dense SVD on a 2-D near-null subspace whose second vector is a rigid
rotation; the column to read on that row is
|A z|, which drops by five orders.)The skewed annulus is the row that matters most: it keeps both boundaries exactly
circular and only moves the nodes to non-uniform angles, so nothing changes but the facet
lengths. Before the fix it acquires a misalignment thirty times worse than the deformed
box (6.5e-02 deg) and simply does not express it, because the skew also lifts the second
singular value out of machine zero and weakly pins the mode. After the fix, on the
geometric path, that misalignment collapses to 3e-07 deg — the fix addresses the
mechanism, not the symptom.
3-D, from the same argument, measured on a tet box (
cellSize=0.25, rotated free-slip onall six faces, top and side walls curved by
z → z + amp·z·sin(πx), N=2474):|A z|/|A|beforemean(p)before3-D is not "unverified by analogy": the face area is the weight the derivation asks
for, and it is measured. The 3-D defect is three orders of magnitude larger than the 2-D
one at the same amplitude, which is what the table above shows and what the P2
edge-midpoint argument predicts.
Amplitude sweep (shape B, res=10) — the convergence character, which is the point of the
|A z|metric:Before,
|A z|grew as amplitude³ (kink angle × facet-length difference, exactly the law).After, it does not respond to the deformation at all. The run-to-run (2-eps) velocity move
over the same sweep falls from as much as 2.6e-01 to 4e-15 everywhere.
Do not judge this on
mean(p)mean(p)and the velocity move are the round-off amplitude of an unpinned direction:in the pre-fix sweep, amplitude 0.05 moved by 7e-09 while amplitude 0.01 moved by 2.6e-01.
They carry the presence of the defect and not its size, and they can report a fix that is
luck.
|A z|/|A|— the constrained operator applied to the attached null vector, normalisedby
σ_max— scales as amplitude³ and as h³·³ and is what the regression tests assert.What is bit-identical
a common factor, and the code takes the unweighted branch. Configuration A above is
identical in every digit, including
mean(p)and the 2-eps move.normal=as a sympy matrix or a constant array). Itsnormal is a function of the node coordinate, not of the facet, so it also takes the
unweighted branch.
probe8b_annulus.py, which runs the analytic radial normal on theuniform and skewed annulus, is byte-identical before and after.
normal="trace"and the fault-contact pair normals never reach this function(
utilities/fault_contact.pyhas its own node walk) and are untouched.The analytic path is therefore left with the consistency error the geometric path no longer
has — the last row of the table. That is deliberate:
normal=is a user override that says"use exactly this normal", and it is the right call on a genuinely curved boundary. It is
now stated in
docs/developer/subsystems/rotated-freeslip.md, and the general guard theissue's §8 proposes (attach the operator's actual null vector, or assert that whatever is
attached is one) is left as follow-up.
What changes for existing results
boundary_normal_traction/dynamic_topographyproject the nodal reaction onto this samenormal, so on a kinked geometric boundary the recovered σ_nn now uses the
measure-weighted normal. That is the assembly's own conjugate direction, so it is the more
correct choice; on flat walls and on analytic normals nothing moves. The free-surface
benchmarks are configuration C (the deforming boundary carries a traction, the rotated
free-slip lives on the straight side walls) and the spherical/annulus relaxation work is
configuration E with an analytic normal, so neither is touched — confirmed by running them.
Tests
New
tests/test_1018_rotated_nodal_normal.py(level_1 / tier_a, one level_2 sweep):control;
(frozen in the test file) and asserts it is ≥ 1e-11 — without it the assertions could pass
on a metric that cannot see the defect;
the two rules do differ on the kinked top (otherwise the comparison is vacuous);
Fail-before, run against the unmodified branch:
4 failed, 1 passed. The four that failare the four that assert the fix; the one that passes is the negative-control test, which
patches the bisector normal in explicitly and therefore passes on both sides — that is the
point of it. After the fix:
5 passed.Verified
Everything below on the fixed build, in the worktree's own environment, run sequentially.
tests/test_1018_rotated_nodal_normal.py(new)tests/test_1018_rotated_freeslip.pytest_1070,test_1071,test_1072test_1011_stokesSph,test_1110_advDiffAnnulustest_0845/0846/0847/0848mpirun -n 2 --with-mpi):test_1064_rotated_freeslip_parallel,test_1066_rotated_datum_parallelscripts/test.sh— every CI serial batchThe CI batches specifically:
test_00[0-4]*129;test_005[1-9]*+test_006[0-1]*43;test_01*168;test_02*27;test_05*+test_07*532;test_08*(which is where thefault suite lives) 605;
test_100[0-9]*30;test_101*+test_102*(the rotated free-slipsuite and the new file) 142;
test_105*83;test_1100*5;test_1110*+test_1120*4;test_1450*3; named tests 74.scripts/test.shfinished on "All tests passed".One caveat, stated because it is a gap rather than a result. The whole-tree marker
invocation
pytest -m "level_1 and tier_a"never reaches its run phase on this machine:it sits in collection and then exits 1 having printed only the session header. That is
not caused by this change — the identical command, run in a separate worktree on
unmodified
development(ffbcf98b), produces byte-identical behaviour. It is apre-existing property of collecting the whole
tests/tree in one process and deservesits own issue; every test that gate would have selected is covered by the batches above,
which is how CI runs them.
Underworld development team with AI support from Claude Code