Reuse the rotated free-slip solver workspace across solves — reworked with a constants-aware key (#417, supersedes #418) - #543
Conversation
Repeated transient rotated free-slip Stokes solves rebuilt the rotation Q, the PtAP'd operator, the fieldsplit Schur KSP/PC and the GAMG hierarchy on every timestep — an allocator high-water problem (RSS growth to OOM, issue #417) and roughly half the per-step cost of the Zhong A1 production runs. Cache the workspace on the solver between solves, re-derived from the original PR #418 (bec76bb) at the seam of the rewritten unified Newton loop: - geometry tier (Q/Qt, constrained rows, fault pair blocks, custom-FMG prolongation) reused while the boundary/fault registration and DM are unchanged; - structure tier (Ahat, Schur pmat, nullspaces, KSP/PC) reused as objects with values refreshed in place — the loop's own between-iteration pattern; - an iteration-0 fast path skips Jacobian assembly / ptap / PCSetUp entirely when the operator coefficient state counters match (RHS-only timesteps); - the cache is forfeited for direct-LU, prescribed-datum and fault interface-law solves, and torn down by _reset() and the _build() full rebuild before the SNES/DM are destroyed; - expose the existing time= argument through the Stokes.solve wrapper; an explicit time vetoes the fast path (petsc_t bypasses every counter). Unlike the original one-shot linear path, a wrong fast-path verdict here cannot return a stale solution: the loop measures the true residual at every iterate and reassembles from iteration 1 on. Regression: RHS-only reuse (same Q/Ahat/KSP handles), viscosity-field invalidation with in-place refresh, and the time= veto, in test_1018_rotated_freeslip. Production evidence on the original mechanism: 310 guarded Zhong A1 steps, flat RSS (PR #418 thread). Underworld development team with AI support from Claude Code
…ey, the verdict goes collective The PR #418 review's unresolved finding: the reuse key was built from MeshVariable._state counters, which are BLIND to rampable UWexpression constants — the #416 contract lets a constant change value with no state bump, so a 2x viscosity ramp between solves reported "unchanged" and (on the original one-shot path) returned a bit-identical stale solution while the matrix-probe safety net was disabled by the very verdict it was meant to check. The reworked loop already made a stale verdict non-fatal (the true residual is measured every iterate and iteration 1 onward always reassembles), but the verdict itself must still be honest: - the operator key now includes the packed constants[] values the kernels will actually assemble with, plus the JIT bundle key (covers an in-place kernel rewire). Measured on the ramp probe: the naive key reported workspace_reused=True for a 2x constant ramp; this key reports False and reassembles. Over-invalidation on RHS-only constant changes is accepted — reassembly is the safe default; - if the constants manifest or the coefficient enumeration cannot be read, the fast path is forfeited outright — correctness first; - the fast path additionally requires a self-measured linear hint (last solve converged in <= 1 increment): for a nonlinear model the cached operator is the previous solve's tangent, and the skip would only trade an assembly for a wasted increment; - the match verdict is allgathered and must be unanimous before it gates any collective PETSc call — state counters follow rank-local writes, and a rank-divergent verdict is a deadlock; - on a detected change the stored signature is poisoned before the in-place refresh, so an exception mid-refresh cannot leave a stale key that later matches half-updated values. Regression: test_rotated_workspace_constant_ramp_invalidates (fail-before validated on the naive key: the reused flag lies there) with its own armed- fast-path negative control and a fresh-solver control at the ramped viscosity; test_rotated_workspace_deform_invalidates re-proves the mesh.deform teardown on the reworked cache. Underworld development team with AI support from Claude Code
…cache, interface laws opt out solve_with_fault drives the same rotated Newton loop, so the cross-solve workspace decision had to be made explicitly for the fault machinery: - FRICTIONLESS pair blocks are geometry (coincident-node pairing + fault normals live in Q, keyed by the fault registration in the geometry signature) — they cache. A warm repeat reuses the rotation; a cold re-solve rides the iteration-0 fast path; both match a fresh-solver control on the same mesh. - INTERFACE-LAW solvers (viscous / Coulomb / rate-state) opt out entirely: the interface tangent is reassembled per iterate at the current slip rates and the reaction-fed normal stress is Picard-lagged solver state — neither is keyable registration state, so cache_allowed excludes them at the top of solve_rotated_freeslip. Regression: test_fault_repeat_solve_composes_with_workspace_reuse covers both arms, with a fresh-solver control for the cached arm and the absent- cache assertion for the opt-out. Underworld development team with AI support from Claude Code
There was a problem hiding this comment.
Pull request overview
This PR introduces a cross-solve cache for the rotated strong free-slip Stokes solve, reusing the PETSc rotation/operator/KSP workspace across repeated solves to reduce rebuild cost and bound memory growth, while adding a constants-aware invalidation key (to catch rampable UWexpression constants) and a collective reuse verdict for MPI safety.
Changes:
- Add a persistent rotated-workspace cache (geometry + structure tiers) and an iteration-0 “fast path” that can skip Jacobian/PtAP/PCSetUp when the operator is provably unchanged.
- Extend invalidation to include packed
constants[]values + JIT bundle key (and make reuse verdict collective across ranks); addworkspace_reused/rotation_reusedreporting. - Add
time=pass-through onStokes.solve()and veto the fast path for explicit time evaluation; add focused regression tests and a design note.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/test_1018_rotated_freeslip.py | Adds regression coverage for workspace reuse, constants-ramp invalidation, and deform invalidation. |
| tests/test_0846_fault_contact.py | Adds coverage that fault contact (frictionless vs interface-law) composes correctly with the rotated workspace cache. |
| src/underworld3/utilities/rotated_bc.py | Implements cross-solve workspace caching, constants-aware operator signature, and collective reuse gating. |
| src/underworld3/systems/solvers.py | Exposes time= on solve() and forwards it to the underlying solver call sites. |
| src/underworld3/cython/petsc_generic_snes_solvers.pyx | Adds cache teardown hooks and vetoes the rotated fast path when time= is provided. |
| docs/developer/design/ROTATED_FREESLIP_LINEAR_REUSE.md | Documents the reuse tiers, safety net, invalidation rules, and validation approach. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| from underworld3.utilities._jitextension import _pack_constants | ||
| try: | ||
| manifest = getattr(solver, "constants_manifest", None) | ||
| values = tuple(float(v) for v in _pack_constants(manifest)) \ | ||
| if manifest else () | ||
| except Exception: | ||
| return None | ||
| jit_key = getattr(solver, "_current_jit_cache_key", None) | ||
| if jit_key is None: | ||
| jit_key = getattr(solver, "_last_jit_cache_key", None) | ||
| return (str(jit_key), values) |
… null before destroy Two minors from the #543 review. (m2) The persisted operator_sig now requires that this solve either reassembled Ahat or rode a key-matched fast path - a poisoned solve exiting at iteration 0 without assembling can no longer store a fresh key against unrefreshed values (the window was bounded by the always-reassemble net; now it is closed). (m4) Both teardown sites null the cache attribute before destroying, so an exception mid-destroy leaves objects unreachable rather than arming a double-destroy. Underworld development team with AI support from Claude Code
Adversarial review — PR #543: rotated free-slip workspace reuse (rework of #418, fixes #417)Reviewed independently of the implementer at f8c2b72 (3 commits on 689523f), The structural claim we held #418 on is now real: the safety net MERGE-BLOCKERSNone. One condition we would attach: gthyagi's A1 re-validation segment on Findings (severity-ranked)m1 (minor) — solver configuration latches into the cached KSP
m2 (minor) — persist can store a key describing values
|
| Change between solves | invalidates / forfeits? | err vs fresh control |
|---|---|---|
| second solver, same mesh | caches independent (distinct handles); interleaved drift 0.0 | 0.0; ¼-viscosity ratio 0.0 |
| add a Dirichlet wall | full rebuild, rotation_reused=False |
0.0 |
| add a second rotated boundary | full rebuild + boundaries in geometry sig | 0.0 |
| tolerance tightened 1e-4→1e-10 | honoured on the true residual (m1) | 1.8e-11 |
| nonlinear rheology switched on, η(v) | fast path defeated; linear_hint withdrawn after the 2-increment solve |
5.7e-16 |
| bodyforce expression REPLACED | rebuild/rewire; no stale answer | 3.4e-10 |
| rampable-constant 2× ramp (THE #418 killer) | their test: fast path defeated, halved velocity exact | (in test, passes) |
direct mesh.X.coords writes |
not a live path: the property returns scaled/wrapped arrays and writes never reach the PETSc coordinate vec; deform is the sanctioned route and tears the cache down (their test + probe 3) |
n/a |
2. Iteration-0 fast path (probe 2-G): after a fast-path solve, poisoned
the key to force a slow-path in-place reassembly of the SAME Mat: every
constrained row identical, worst abs diff 0.0, |Ahat| unchanged to all
digits. The skipped zeroRowsColumns/PCSetUp is genuinely redundant.
Poisoning order verified in code AND by probe 2-I (hand-poison, ramp, no
fast path, err 3.5e-11): an interrupt in the poison→refresh window can only
under-reuse.
3. Collective discipline (probe 4, np2): code audit — every verdict
input is registration state, packed-constants (global), or goes through
the allgather; exception paths inside both signature helpers swallow to
None symmetrically (→ forfeit). Probe: fresh / RHS×2 / rank-VARYING RHS
values / constant ramp — flags allgathered at every solve, rank-identical:
[(F,F),(T,T),(T,T),(T,F)], ramp defeated on both ranks, no hang, final
err vs fresh control 1.2e-10. (The rank-asymmetric-write variant is
unreachable — see the positive observation above.)
4. The structural safety net (probe 2-H): monkeypatched
_operator_constants_signature to a frozen value (equivalent to reverting
the blind-spot fix), ramped 2×: workspace_reused=True (the lie fires),
halved-velocity error 1.7e-11, exactly +1 increment (2 vs 1). The net
that replaced the dropped matrix probe does what the PR claims — a
corrupted key costs work, never an answer.
5. Fault composition (their test + probes 5/5b): fault set passes (30).
Changing the fault's normal= override between solves is caught by the
geometry signature (rotation_reused=False); a COLD post-change solve
matches a fresh override control bit-identically (0.0, floor between
two fresh controls also 0.0). For the record: a WARM start straight across
the normal change landed 6.3e-2 off while reporting converged — with the
cache already destroyed (pre-PR behavior identical), in a deliberately
flux-incompatible override configuration, and with the loop's
mass-conservation gauge warning printed loudly. Pre-existing rotated-loop
warm-start semantics, not a cache defect.
6. Teardown (probe 3): 30 solves with 5 interleaved mesh.deform
teardown/rebuild cycles: 25/30 fast-pathed, ru_maxrss slope
0.79 MiB/solve including the five full rebuilds (PR's pure-reuse figure
0.12; high-water RSS overstates); repeated _reset() /
_reset_rotated_solver_cache() idempotent, no double-destroy; σ_nn
traction recovery off a fast-path solve doubles with the load (1.3e-10) —
verified in code that the recoveries read only reaction+boundaries, so
the "result dict survives cache teardown" contract holds. Custom-FMG
prolongation deref-not-destroy is correct (shared coarse Mats; the rotated
Pfine/Qv fall to petsc4py GC).
7. Tests & negative control: test_1018 22 passed; fault set
0845/0846/0847/0848 30 passed. House-rule negative control: swapped
commit 1's rotated_bc.py (naive key) into the INSTALLED copy → the ramp
test fails exactly at the lying-flag assertion, restored and re-verified.
The regression test has teeth, and its own internal negative control (fast
path proven armed before the ramp assertion) fires. Full gate
-m "level_1 and tier_a" (–test_0050): 599 passed, 0 failed, 17 skipped
(MPI-gated), 1 xfailed, in 8:13.
Notes for the record
- PR base is 2 commits behind
origin/development; the divergence touches
the pyx by 6 lines (Boundary flux on degree-3 traces: per-slot DOF identity, true edge-node coordinates, and the consistent line mass (#459) #537), disjoint from this change — expect a clean
merge. - Commit 1 carries gthyagi's authorship, as it should.
time=semantics:petsc_tpersists on the DM after atime=solve; a
later no-time solve fast-paths against the operator assembled at that
time, which is CONSISTENT (operator and residual agree). The veto covers
the transition solve — the only place it must._pack_constantspacks 0.0 for an un-floatable constant, but signature
and kernels share the same packing, so the key cannot desync from the
assembly.- Suggest (non-blocking): an np2
ptest_for the reuse verdicts — the
parallel evidence currently lives only in probe scripts, and
tests/parallel/test_1064/1066predate the cache.
Verdict
APPROVE (with the A1 re-run segment requested of gthyagi, and m2/m3 as
cheap hardenings here or in a follow-up). The #418 failure class — a silent
stale answer — is structurally closed: under deliberate key corruption the
branch still produced the correct field at +1 increment, and every
mutation we threw at the key (BCs, rheology, bodyforce, fault normals,
tolerances, np2 rank-varying state) either invalidated, forfeited, or was
rescued by the always-reassemble net. Memory (#417) stays bounded through
deform/rebuild cycles.
|
Response commit 2f17743 takes m2 (operator_sig persisted only when Ahat provably holds the keyed values) and m4 (null-before-destroy at both teardown sites); test_1018 22/22 after rebuild. m1 (config latched into the cached KSP) and m3 (coefficient-enumeration reuse safe by teardown coincidence, not construction) are tracked as a follow-up issue. The attached condition stands: @gthyagi — when you have a slot, a 50-step guarded A1 restart segment on this branch would re-anchor your 310-step production trail against the reworked loop; the memory numbers here (+0.12 MiB/solve over 35 solves, 2.10x warm-solve speedup) match your original measurements. Underworld development team with AI support from Claude Code |
… the difference CI's first diagnosis-grade report on test_rotated_workspace_deform_invalidates said both solves converged from an IDENTICAL initial residual (|r0| = 0.02785134866629431 to every digit, |r| ~ 1e-11 for both) and still disagreed by 7.02e-02. Two solves that both drive the residual to machine zero on the same system can only differ in the null space, so this round measures the null space instead of arguing about it. Solver side: the rigid-rotation gauge decision was invisible. Whether a mode is admitted decides whether a component is projected out of the answer, and _mode_satisfies_constraints made that call silently, per mode, from the boundary normals. It now optionally records the constraint violation, the operator violation and the verdict; _finalize_rotated_solution collects one record per offered mode and the solve result carries it as "rotation_gauge". Default arguments unchanged, so nothing else moves. Test side, all of it reported in the failure message and all of it computed EAGERLY, because an instrument that only runs when the test fails is an instrument that has never been run: - the difference field is decomposed onto the rigid-body span (the same modes the solver considers, plus translations, Gram-Schmidt'd in the same order, built from nodal coordinates so it does not inherit the machinery it is measuring). If the difference lives in the span, one solve admitted a mode the other rejected and this is a #543 gauge bug; if it lives off the span, the two operators differ and something survived the deform; - per-solver constrained-row count, distinct-row count and boundary list; - a point-location tally around the deform, the post-deform solve and the control, answering every call TWICE - as shipped and with the rejection radius set aside - and reporting how many answers the radius changed. That is the number that decides whether #556 is implicated, taken on whatever mesh the machine actually built rather than argued from a local run. Three negative controls, so none of those numbers is unchecked: a pure rigid rotation must decompose with 4e-18 of it off the span and a random field with 0.9985 of it off (both asserted on the mesh under test), and squeezing the reach margin to 0.05 must make the radius comparison see answers change. Measured here: 84 constrained rows for both solvers, one rotation mode offered and rejected by both with identical violation 3.045e-01, gauge_removed False for both, and during the deform 8 location calls over 2270 points with 38 returning -1 and radius_changed = 0. The locator changes nothing on this platform. If CI reports otherwise, that is the finding. Underworld development team with AI support from Claude Code
…es them The test made two claims in one assertion. One is well posed and is what #543 wrote it for: the deform invalidated the workspace. The other is not: that a post-deform solve matches a fresh control to 1e-6, on a system that does not determine its own answer in one direction (#560). They are now separate. (a) stays hard: rotation_reused and workspace_reused both False, the locator's rejection radius followed the deform, both solves converged. (b) is narrowed, not relaxed. Rotated free-slip on a curved boundary loses the constant-pressure gauge, and the solution acquires a component along one unpinned direction whose amplitude is round-off. Measured: a coordinate change of two machine epsilons (4.44e-16) moves the velocity by 1.33e-01, and the move does not scale with the perturbation - 4.4e-16, 2.2e-15, 1e-14, 1e-12 and 1e-9 all give between 4e-2 and 2e-1. That is why this assertion was green on macOS (err exactly 0.0 in 81 consecutive runs across two PETSc toolchains and nine PYTHONHASHSEEDs) and intermittently red on CI: it passes only where the two assemblies agree bitwise. The unpinned subspace is exactly one-dimensional - five different perturbations move the answer along the same direction to cosine 1.000000, the normalised difference set has singular values [2.236, 4.4e-9, 3.4e-9, 2.3e-9, 1.7e-9], and removing the leading direction leaves 2e-9 of each difference. So the test measures that direction with one extra perturbed solve and requires the two solutions to agree in every OTHER direction, at the same 1e-6 it always used. The tolerance is untouched; it is the claim that is made honest. Two things keep it from becoming a rubber stamp. A negative control injects a 1e-3 discrepancy orthogonal to the unpinned direction and asserts it survives the projection (measured 1.000e-03 against a 1e-4 floor), so the projection cannot absorb a real disagreement. And the test branches on what it measures: if a 2-eps perturbation stops moving the answer - i.e. when #560 is fixed - the projection becomes a no-op and the solutions are compared directly again, with the branch reported in the failure message. Fixing #560 strengthens this test instead of breaking it. All the instrumentation stays: the gauge decisions, the constrained-row counts, the locator tallies and the rigid-body decomposition are what turned an unreadable CI failure into a filed defect, and they are the diagnostic for the next one. Verified: this test 10/10 in amr-dev and 10/10 in dev, the CI batch shape (tests/test_101*py tests/test_102*py) 137 passed in dev, full level_1/tier_a gate 627 passed 0 failed. Underworld development team with AI support from Claude Code
…nment-check the cell hint (items 1-3 of #551, fixes the #432 class) (#556) * A point the local mesh cannot own should be rejected, not walked 50 times The lost-point walk in _get_closest_local_cells_internal tried the 50 nearest cell centroids and only stopped when every lost point had been found. A point no local cell could own therefore paid all 50 rounds - 51 containment tests against 1 for an owned point, measured - and every point already located was re-tested on each of them, so one unfindable point charged the whole batch. In parallel the fraction of points a rank does not own is exactly what grows with rank count. Two bounds. A point inside a cell is no further from its nearest kd-tree control point than from that cell's centroid, which for a convex cell is within the cell's vertex reach; the largest local reach is now recorded with the kd-tree and a lost point beyond twice that distance is rejected before the walk starts. The factor of two is slack for the in-cell test's face tolerance and for badly shaped cells. And the working set shrinks: a point leaves as soon as a cell claims it, or as soon as the sorted neighbour distances pass the rejection radius. Measured on the #551 dossier probes: a foreign point costs 1.0 containment tests instead of 51.0 (0.53 us/point instead of 7.99 serial, 0.79 instead of 9.6 at np=4), and one unfindable point in a batch of 1000 adds 3 point-tests instead of 991 (2-D) or 12251 (3-D). The nearest containing centroid now wins, where before the winner was the LAST of up to 50 rounds and so depended on whether some unrelated point in the same batch was findable. Across a nine-set battery in 2-D and 3-D at np=1/2/4, 15 of ~450000 located cells change; every one is a point that both the old and the new cell contain. Addresses item 1 of #551. Underworld development team with AI support from Claude Code * The cell hint that bypasses DMLocatePoints must contain the point Serial simplex meshes assert "exact" location capability, which makes the UW3 cell hint authoritative and skips PETSc's DMLocatePoints entirely - while the hint they handed over was get_closest_cells, a nearest-CONTROL-POINT kd-tree lookup with no containment test at all. On a tetrahedron nothing downstream can rescue that: the only remaining guard is a componentwise box clamp on the reference coordinates, and the reference tet is not the reference box. A query on a shared edge was answered by extrapolating the basis of a cell that does not contain it. That is #432, a recurrence of #390. The serial-simplex branch now takes _robust_owning_cells, the containment- checked locator every other authoritative path already used, so the three branches collapse to one call. Points it cannot place come back as -1, which the C bypass treats as a non-claim; they surface in unlocated_mask and take the RBF fallback that is already plumbed. The alternatives were worse. Restricting the "exact" assertion pushes serial simplex meshes back onto DMLocatePoints, which is slower and re-opens the #390 class of silent drops the bypass was added to close. A barycentric clamp in C only pins a wrong cell's reference coordinates to that cell's boundary, which gives the edge value of the WRONG cell - right only for continuous fields, and no help at all when the nominated cell is not adjacent to the point. Measured cost: one extra containment test per point, 1.0 tests per point for interior, on-face and on-edge queries. Measured benefit: 3-D P1 evaluation at quarter-points along cell edges was off by 3.0e-01 at 17 of 1318 points and is now exact. Addresses item 3 of #551 and closes #432. Underworld development team with AI support from Claude Code * Classify and locate in one pass, so evaluation does not search twice points_in_domain located the points near the domain boundary, returned a boolean mask and threw the owning cells away; petsc_interpolate then located every interior point again. The near-boundary points were being searched for twice per evaluate call. Mesh._classify_points_in_domain returns both - the mask and the cells the classification actually looked up, with -1 meaning "not looked up" for an interior point and "not in the local mesh" for an exterior one. evaluate passes those to petsc_interpolate as cell_hints, which searches only for the entries still marked -1, and only on the DMInterpolation cache miss that needs them. A cache hit locates nothing at all, which the first version of this change got wrong: filling the whole hint array in the classifier made serial evaluate 29% slower because it searched on every call, cached or not. points_in_domain keeps its signature, its answer and its cost - it is now a one-line wrapper and does not search on the interpolator's behalf. Only the robust locator's answer is kept as a hint; the cell-wall test the serial classifier uses runs at a different face tolerance and its answer is a classification, not a hint. Measured on the #551 dossier probes: containment point-tests per located point inside evaluate fall from 2.28 to 1.10 at np=2, 3.35 to 1.20 at np=4 and 4.19 to 1.27 at np=8; evaluate wall time 0.0382 to 0.0361 s at np=4. Addresses item 2 of #551. Underworld development team with AI support from Claude Code * Pin the locator's two contracts: the hint contains the point, rejection is O(1) #432 and #390 have both been fixed and returned, and there was no regression test pinning on-edge or on-face queries. tests/test_0761_point_locator.py adds them in 2-D and 3-D. The oracle is the closed-form P1 value (1-t)*u_a + t*u_b along a cell edge, computed WITHOUT uw.function.evaluate so it cannot inherit the defect it is testing for - the same discipline test_0753's arbitrary-coarse-field reference uses, and for the same reason. Covers shared vertices, edge midpoints, edge quarter-points and 3-D face centroids. The performance guard counts containment tests per point rather than seconds. The count is what the algorithm does; a wall time is what the machine was doing at the time, and the house has been burned by timing tests before. Parallel wall-clock numbers belong in the PR, not in an assert. Negative controls, because a test that cannot fail proves nothing. The nodal field is MEASURED to vary by more than 0.1 between a cell edge and its midpoint, so a wrong-cell interpolant cannot pass by being smooth (a linear field cannot test neighbour selection). get_closest_cells is shown to nominate cells that do not contain the query, so the edge test is pinning something real - and only at 3-D quarter-points, since a midpoint is never misassigned and 2-D never is, which is why #432 is a 3-D report; the 2-D case skips with that count in its message. The containment counter is asserted to fire, so the bounds cannot pass by instrumenting nothing. Validated fail-before with the fixes stashed and the tree rebuilt: 3-D P1 evaluation at t=0.25 off by 3.036e-01 at 17 of 1318 points; a point outside the mesh costing 51.0 containment tests; one unlocatable point adding 991 (2-D) and 12251 (3-D) point-tests to a batch of 1000. Underworld development team with AI support from Claude Code * Fill the points the locator loses instead of returning NaN The RBF fallback in petsc_interpolate has never run. It iterates mesh.vars.values(), and mesh.vars is a weakref.WeakValueDictionary whose .values() is a GENERATOR, not a view: the dofcount loop a few lines earlier consumes it, so the fallback loop iterates nothing and the NaN that DMInterpolationEvaluate_UW writes for an unplaced point is returned to the caller. That did not matter until the previous commit in this branch, which routed serial simplex evaluation through the containment-checked locator. That locator returns -1 - the older nearest-control-point hint never did - so the dead rung became load-bearing on the most common configuration in the library. Measured on a graded 3-D simplex box (cellSize 1/8, deformed x -> x**4), 3000 interior queries: the merge base returns 0 NaN, this branch returned 1. Materialising the list fixes it: same probe, 0 NaN, and the point takes the RBF value (0.2802) the rung was always supposed to write. The same exhausted generator was read again by the continuity gate, so all() over it was vacuously True and the gate has never bound. Un-breaking it is a behaviour change, so it is measured rather than assumed. The gate only does anything on meshes whose measured location capability is "continuous" - warped hexes, the cubed-sphere class; simplex, quad, annulus and rectilinear hex boxes are all "exact", where the gate is a no-op either way. On a warped hex box carrying a P1 and a P0, binding it over EVERY variable on the mesh would cost the CONTINUOUS field a factor of 15 in accuracy (linear field, max error 8.0e-3 -> 1.2e-1 at 62 of 1500 interior points) because one discontinuous variable elsewhere took every evaluation off the authoritative path. Scoped instead to the variables the call actually asks for - which is what the surrounding comment always said the policy was - the continuous field is bit-identical to before and only the P0 moves, by up to 1.71 on a field of range 2, which is the O(jump) correction the gate exists to make. Also here, from the same review: - petsc_interpolate copies cell_hints before filling its -1 entries. np.ascontiguousarray hands back the caller's own array when it is already int64 and contiguous, and cell_hints is a documented keyword, so the fill was writing through somebody else's array. - _build_kd_tree_index_PIC and _build_kd_tree_index_DS are deleted. Both set _index without the new _local_cell_reach, which is the one way a stale rejection radius could outlive the geometry it was measured on; both have had zero callers for a long time and are already on the readability review's delete list (READ-31). - The tie-break change is written down where it can be found: which containing cell a shared vertex/edge/face query returns has moved, and for a discontinuous field the cell is the answer. - The absolute face-tolerance slab and the mesh-relative rejection radius are noted as the different scales they are, with the domain size at which they would cross (about 5e-6 across, measured clean at 1e-4 and at 6371). Underworld development team with AI support from Claude Code * Test the direction of the rejection radius that would be silent Nothing asserted that a point a local cell really does contain is never rejected by the new radius, which is the failure mode that would not announce itself: the point vanishes into the RBF fallback in serial, or is claimed by nobody in parallel. Four tests, all with their own negative control. A brute-force oracle - every query against every local cell, with the same containment predicate the locator uses - says which points are genuinely owned, and the reach margin is swept to prove the probe fires. Serial: 2-D {2.0: 0, 1.0: 0, 0.5: 5, 0.25: 16}, 3-D {2.0: 0, 1.0: 0, 0.5: 4, 0.25: 168} out of 800 interior points. The bound is tight at about 1.0, so the shipped 2.0 carries a factor of two over the first observable loss. 0 at the shipped margin on every rank at np2 and np4 as well. A P0 discontinuous field evaluated at shared mesh vertices pins the tie-break: the value must belong to one of the cells that contains the query, not a specific cell (that was never the contract, and pinning it would break on the next legitimate change). Every vertex in the set is contained by 18 to 44 cells and their values differ by more than 0.5, so a wrong cell is visible. The RBF fallback rung gets a fault-injection test: five points are forced to -1 and the result must be finite AND equal the RBF value AND leave the other 395 points bit-identical. Waiting for a graded mesh to lose a point naturally works, but how many it loses is a property of whatever gmsh produced that day. Confirmed fail-before by rebuilding with the one-line generator fix reverted: exactly the five injected points come back NaN. The reach invalidation was asserted in prose only. Deform a 2-D box by 50 and the stored reach must follow exactly, and every point in the expanded domain must still be located. Finally, test_the_classifier_hands_over_the_cells_it_located ended with its real assertion inside `if offered.any():`, and offered is ALWAYS empty in serial - 0 hints for 1465 interior points, against 462 of 462 at np4 - so the strongest claim in item 2's only test was a no-op in the default test run. It now asserts the serial reality (no hints, by design) and requires hints under MPI. Underworld development team with AI support from Claude Code * Make the deform-invalidation test say what went wrong when it fails PR #556 failed CI on test_rotated_workspace_deform_invalidates with "post-deform solve differs from fresh control by 1.16e-01", and the failure does not reproduce here. Not in the amr-dev toolchain (custom AMR PETSc), not in the dev toolchain CI itself uses (conda PETSc), not as a single test, not as the whole file, not in the exact CI batch shape (pytest tests/test_101*py tests/test_102*py: 137 passed in both environments), with or without CI's environment variables. Locally err is not small, it is exactly 0.0: one Krylov iteration, |r| = 8.267546030330813e-12 bit-identical between the post-deform solve and the fresh control. The locator work in this PR is measurably inert in this test: - the mesh is a StructuredQuadBox, and for a quad/hex mesh the merge base ALREADY located through _robust_owning_cells - item 3 changed the serial SIMPLEX path only, so the evaluate route here is unchanged; - there are 8 point-location calls during the test (from swarm.migrate -> points_in_domain and from global_evaluate, not from the assembly). Running every one of them twice, with the new rejection radius and with it disabled, gives identical cells: 0 differences out of 2270 points; - the deformed quad box measures location capability "exact" before and after the deform, so _hint_is_authoritative is True whatever the field continuity and the continuity gate cannot bind; - in serial the classifier hands over no cell hints at all, so item 2 is inert too; - the locator reach follows the deform (0.070710678 -> 0.073041055). Rather than guess at a fix for something that cannot be reproduced, the test now distinguishes the two things it could be. Both solvers assemble the same system on the same deformed mesh from a zero guess, so any deterministic solver owes them the same answer: if both report converged and the answers still differ, the OPERATORS differ and something survived the deform; if the convergence reports differ, the linear solves did. The failure message carries both solves' reason, iteration counts and residuals plus the locator reach before and after, and the reach invalidation is now asserted here as well as in test_0761 (the review's minor m1) because this is the only test in the suite that deforms a mesh between two solves. Underworld development team with AI support from Claude Code * Record which rigid-body modes the rotated gauge admits, and decompose the difference CI's first diagnosis-grade report on test_rotated_workspace_deform_invalidates said both solves converged from an IDENTICAL initial residual (|r0| = 0.02785134866629431 to every digit, |r| ~ 1e-11 for both) and still disagreed by 7.02e-02. Two solves that both drive the residual to machine zero on the same system can only differ in the null space, so this round measures the null space instead of arguing about it. Solver side: the rigid-rotation gauge decision was invisible. Whether a mode is admitted decides whether a component is projected out of the answer, and _mode_satisfies_constraints made that call silently, per mode, from the boundary normals. It now optionally records the constraint violation, the operator violation and the verdict; _finalize_rotated_solution collects one record per offered mode and the solve result carries it as "rotation_gauge". Default arguments unchanged, so nothing else moves. Test side, all of it reported in the failure message and all of it computed EAGERLY, because an instrument that only runs when the test fails is an instrument that has never been run: - the difference field is decomposed onto the rigid-body span (the same modes the solver considers, plus translations, Gram-Schmidt'd in the same order, built from nodal coordinates so it does not inherit the machinery it is measuring). If the difference lives in the span, one solve admitted a mode the other rejected and this is a #543 gauge bug; if it lives off the span, the two operators differ and something survived the deform; - per-solver constrained-row count, distinct-row count and boundary list; - a point-location tally around the deform, the post-deform solve and the control, answering every call TWICE - as shipped and with the rejection radius set aside - and reporting how many answers the radius changed. That is the number that decides whether #556 is implicated, taken on whatever mesh the machine actually built rather than argued from a local run. Three negative controls, so none of those numbers is unchecked: a pure rigid rotation must decompose with 4e-18 of it off the span and a random field with 0.9985 of it off (both asserted on the mesh under test), and squeezing the reach margin to 0.05 must make the radius comparison see answers change. Measured here: 84 constrained rows for both solvers, one rotation mode offered and rejected by both with identical violation 3.045e-01, gauge_removed False for both, and during the deform 8 location calls over 2270 points with 38 returning -1 and radius_changed = 0. The locator changes nothing on this platform. If CI reports otherwise, that is the finding. Underworld development team with AI support from Claude Code * Compare the deform-invalidation solves only where the system determines them The test made two claims in one assertion. One is well posed and is what #543 wrote it for: the deform invalidated the workspace. The other is not: that a post-deform solve matches a fresh control to 1e-6, on a system that does not determine its own answer in one direction (#560). They are now separate. (a) stays hard: rotation_reused and workspace_reused both False, the locator's rejection radius followed the deform, both solves converged. (b) is narrowed, not relaxed. Rotated free-slip on a curved boundary loses the constant-pressure gauge, and the solution acquires a component along one unpinned direction whose amplitude is round-off. Measured: a coordinate change of two machine epsilons (4.44e-16) moves the velocity by 1.33e-01, and the move does not scale with the perturbation - 4.4e-16, 2.2e-15, 1e-14, 1e-12 and 1e-9 all give between 4e-2 and 2e-1. That is why this assertion was green on macOS (err exactly 0.0 in 81 consecutive runs across two PETSc toolchains and nine PYTHONHASHSEEDs) and intermittently red on CI: it passes only where the two assemblies agree bitwise. The unpinned subspace is exactly one-dimensional - five different perturbations move the answer along the same direction to cosine 1.000000, the normalised difference set has singular values [2.236, 4.4e-9, 3.4e-9, 2.3e-9, 1.7e-9], and removing the leading direction leaves 2e-9 of each difference. So the test measures that direction with one extra perturbed solve and requires the two solutions to agree in every OTHER direction, at the same 1e-6 it always used. The tolerance is untouched; it is the claim that is made honest. Two things keep it from becoming a rubber stamp. A negative control injects a 1e-3 discrepancy orthogonal to the unpinned direction and asserts it survives the projection (measured 1.000e-03 against a 1e-4 floor), so the projection cannot absorb a real disagreement. And the test branches on what it measures: if a 2-eps perturbation stops moving the answer - i.e. when #560 is fixed - the projection becomes a no-op and the solutions are compared directly again, with the branch reported in the failure message. Fixing #560 strengthens this test instead of breaking it. All the instrumentation stays: the gauge decisions, the constrained-row counts, the locator tallies and the rigid-body decomposition are what turned an unreadable CI failure into a filed defect, and they are the diagnostic for the next one. Verified: this test 10/10 in amr-dev and 10/10 in dev, the CI batch shape (tests/test_101*py tests/test_102*py) 137 passed in dev, full level_1/tier_a gate 627 passed 0 failed. Underworld development team with AI support from Claude Code
Reuse the rotated free-slip solver workspace across solves (rework of #418, fixes #417)
This is the landing rework of #418 (gthyagi's "Reuse linear rotated free-slip
solver workspace", bec76bb), which went CONFLICTING after the rotated solve
loop was rewritten (#437, #458, #465/#471, #469, #493, #500, #502, #530/#534),
and which the 2026-07-27 adversarial review held on one structural finding.
gthyagi's commit is cherry-picked with his authorship preserved; the caching is
re-derived at the current seam rather than force-fitting the old text.
What was ported, and where it lives now
The original PR split a linear path from a nonlinear one and cached the linear
path's workspace whole — operator, KSP, and (implicitly) the solution state —
keyed on
MeshVariable._statecounters, with a matrix probe as safety net.Since the fork,
rotated_bc.solve_rotated_freeslipbecame ONE manualNewton/Picard loop for linear and nonlinear models alike, which already reuses
its operator and KSP context between its own iterations. The port extends
exactly that in-loop pattern across solves, split by what each piece
actually depends on:
fault registration, DM identity): the rotation
Q/Qt, constrained normalrows, fault pair blocks, custom-FMG prolongation.
place):
Ahatvia ptap-with-result, the Schur pmat viacreateSubMatrix-with-submat, the fieldsplit KSP/PC via a
setOperatorspoke. This is the identical operation sequence the Newton loop performs
between iterations, so it carries the production-validated risk profile.
when the operator key proves nothing operator-relevant changed AND the last
solve on this workspace behaved linearly (converged in ≤ 1 increment — a
self-measured hint, no up-front nonlinearity probe).
Nothing about the rotated BC's discrete equations changed. All current result
keys (
rnorm/rnorm0for the solve report, the #534 tolerance/reportplumbing, the #502 fault keys) are preserved;
rotation_reusedandworkspace_reusedare added.Teardown hooks:
_reset_rotated_solver_cache()runs from_reset()and fromthe
_build()full-rebuild branch before the SNES/DM are destroyed(mesh.deform funnels there). The in-place rewire fast path keeps the cache;
its new kernels are caught by the JIT-key half of the operator signature.
The cache is forfeited outright for direct-LU, prescribed-datum and fault
interface-law solves.
Stokes.solvegains thetime=pass-through from theoriginal PR; an explicit time vetoes the fast path (
petsc_tbypasses everycounter and constant).
The blind-spot fix (the finding that held the PR)
The review probed that a 2× viscosity ramp via a rampable UWexpression
constant (the #416 contract: value changes bump NO state counter) returned a
bit-identical stale solution flagged
workspace_reused=True, and that thesame "unchanged" verdict short-circuited the matrix-probe safety net that was
supposed to catch it. Both halves are fixed, structurally:
includes the packed
constants[]values the compiled kernels will actuallyassemble with, plus the JIT bundle key. If the manifest or the coefficient
enumeration cannot be read, the fast path is forfeited — correctness first.
(This deliberately over-invalidates on RHS-only constant changes;
RHS-only field changes — the production temperature pattern — still ride
the fast path.)
loop there is no separate one-shot linear path to poison: the loop measures
the TRUE residual (fresh kernels, current constants) at every iterate,
declares convergence only on that, and always reassembles from iteration 1
on. A wrong fast-path verdict therefore costs one extra increment — it
cannot return a stale solution. Measured on the naive-key port (negative
control, house rule): the ramp probe showed
reused=Truewith the WRONGflag but the CORRECT halved velocity (4.1e-11 vs a fresh control) — the
old code returned the stale field bit-identically. The key fix makes the
flag honest and removes the wasted work.
the match verdict is allgathered and must be unanimous before it gates any
collective PETSc call (a rank-divergent verdict is a hang, not a wrong
answer).
The matrix probe from the original PR is dropped: its role is subsumed by the
loop's exact-residual verification, which is stronger (it validates the
solution, not two matvecs) and cannot be disabled by its own trigger.
Fault composition (#502)
solve_with_faultdrives the same rotated loop, so the decision is explicitand tested:
normals live in
Qand are geometry, keyed by the fault registration inthe geometry signature. Warm repeat reuses the rotation; a cold re-solve
rides the iteration-0 fast path; both match a fresh-solver control.
cache_allowedexcludes them):the interface tangent is reassembled per iterate at the current slip rates
and the reaction-fed normal stress is Picard-lagged solver state — neither
is keyable registration state.
Verification
All serial runs sequential on the pr418-rework worktree (amr-dev env).
reused=False); error vs fresh control 4.8e-08–4.1e-11 across configs; fail-before validated: naive key reportsreused=TrueQ/Ahat/KSP handles assertedrotation_reused=False), post-deform solve matches fresh control < 1e-6 (test)[(F,F),(T,T),(T,T),(F,T)]×2 ranks; RHS scaling 9.7e-08, ramp scaling 4.8e-08test_fault_repeat_solve_composes_with_workspace_reuse)-m "level_1 and tier_a"(–test_0050)Production evidence (gthyagi)
The original mechanism carries a substantial production validation trail that
this rework inherits and that deserves explicit credit: 310 guarded Zhong A1
steps (
cellsize=1/8, 8 ranks, checkpoints 500→810 in guarded 50-stepsegments,
UW_MEMPROBE=1, 0.25 s RSS sampling, hard memory/time stops thatnever fired), flat warm RSS with periodic collective releases, a continuous
physical trajectory (Vrms 57.83→57.97, Nu_surf 2.694→2.706 over 810 steps),
and a ~1.92× per-step speedup over the pre-fix rate. Since the loop internals
moved under the cache in this rework, gthyagi may wish to re-run an A1
segment on the reworked branch (a 50-step guarded restart from an existing
checkpoint would do) before merge.
Commits
3eba6aa4— Reuse the rotated free-slip solver workspace across solves(Repeated rotated free-slip Stokes solves rebuild solver state and grow RSS until OOM #417) — author: Tyagi, cherry-pick of bec76bb re-derived at the
current seam.
78338211— Close the rotated-workspace blind spot: rampable constantsjoin the key, the verdict goes collective.
f8c2b726— Fault contact composes with the rotated workspace cache:pair blocks cache, interface laws opt out.
Underworld development team with AI support from Claude Code