One owner for the geometric-MG option bundle; rotated free-slip picks up a mesh-owned hierarchy (#468, #467) - #471
Conversation
…rotated path (#438) - v_n = u_n now carried through the manual Newton/Picard loop: accepted iterates are kept feasible (affine snap in _impose_normal_constraint), so increments stay homogeneous (n.delta = 0) and the datum never touches the tangent, the increment RHS, the custom-FMG prolongation or the nullspace handling. - A COLD start imposes the datum through the FIRST increment's affine lift (zeroRowsColumns x/b at the rest-state tangent, exactly the linear one-shot's treatment): snapping the zero state onto the datum manufactures a boundary-strip strain state whose shear-thinning tangent cannot descend (measured: first Newton step 4 orders too large, no line-searched alpha improves). Warm starts take the exact affine snap directly. - Collective datum-activity flag in BOTH solve paths: branching on the rank-local datum_map desyncs the ranks' collective sequences at np>1 (the two zeroRowsColumns variants scatter differently; the lift skips the line-search collectives). The linear path carried the same latent pattern, tolerated only because annulus partitions happen to give every rank a piece of the outer arc. - Regression: nonlinear power-law annulus with a u.n = cos(theta) datum imposed to machine precision through genuine Newton iteration (test_1018). Tests: test_1018 serial 18/18; test_1066 np2; test_1070 serial + np2 (7); test_1071 serial. Underworld development team with AI support from Claude Code
…t bare MPI The rank-consistent datum-activity flag now rides the x-hat lift vector's collective norm (PETSc-before-MPI house rule): x-hat carries the prescribed rotated-frame datum for the zeroRowsColumns lift anyway, so one object does both jobs and the hand-rolled ownership loop collapses into _set_rows_local. Removes the mpi4py allreduce introduced in the previous commit. The deeper cure is a surface submesh (the 1D-manifold gap, #202 lineage): a boundary-field datum would make this flag a field norm and retire the hand-rolled surface bookkeeping in this layer - workstream C. Underworld development team with AI support from Claude Code
…d solves; datum via conds (#438, #403) One constraint, one path: the linear one-shot is deleted and every rotated solve runs the manual Newton/Picard loop. A linear model converges after its first increment (the cold-start affine lift IS the linear solve), so the up-front _residual_is_nonlinear probe leaves the dispatch entirely - two Jacobian assemblies saved per linear solve (test_1018: 60s -> 55s). The probe survives only as a lazy guard in the picard + pure-Newton corner, where a linear model now ignores the meaningless warmup instead of raising. - solver._rotated_use_lu becomes an increment-solver choice (direct LU per Newton increment, serial PC-free diagnostic; naive pressure pin extracted to _naive_pressure_pin with its TODO(BUG)). Verified against the iterative path to 6e-10. - One result-dict shape: reaction = F(u) always (equals A.u - b exactly for linear residuals); the linear-only A/b keys and their consumer fallback in boundary_normal_traction retire. ksp_its is always the per-increment list (test_1018 3D assertion moved to max-per-increment). - add_rotated_freeslip_bc(conds, ...) accepts the non-zero wall-normal datum (number or scalar expression, value-first; #403 item 2) and stores it in _rotated_freeslip_datum; vector values rejected. Tests and FreeSurface now use the public argument instead of poking the attribute. - FreeSurface: the penalty-fallback NotImplementedError catch in _solve_consistent is gone - consistent_constraint="strong" now runs through the SNES rotated path for nonlinear rheologies. Linear solutions change at solver-tolerance level only (agreed relaxation of bit-preservation); an occasional cheap polish increment can follow the lift when the first residual check sits at the tolerance boundary (free-surface plume wall-time unchanged). Tests: test_1018 serial 18/18; test_1066 np2; test_1070 serial + np2 (7); test_1071 serial; LU-vs-iterative and lazy-probe smokes. Underworld development team with AI support from Claude Code
…ll modes Two defects surfaced by the TI fault smoke (#438 acceptance): 1. The step-norm exit reported converged=True on tiny increments while the residual was still O(1) (a stiff power-law tangent at the regularisation floor produces tiny steps far from the solution). The exit is now VERIFIED against the problem's intrinsic scale ||F(0)||: a warm start at the solution passes; a stagnating crawl goes back through the line search and either progresses (bounded by max_it) or ends on the stall exit honestly. 2. _mode_satisfies_constraints admitted rigid-rotation modes that are PINNED by an essential BC on another boundary (it only checked the rotated constraint rows). The admitted mode was projected out of every increment RHS, leaving an irreducible residual component: Newton converged superlinearly to rel ~2e-5 and floored there (measured on the essential-inner + rotated-outer power-law annulus). Modes are now also verified as null vectors of the ASSEMBLED operator (||J.m|| against the velocity diagonal scale), which catches any pinning without enumerating BC types; the null space is built after the first Jacobian assembly to make that possible. Same fix protects the post-solve rotation-gauge removal. With the fix the same solve converges to rel 2e-13 in 11 iterations and correctly leaves the gauge in place. TI fault-bearing smoke (power-law background, weak-plane band, cos(theta) datum) now passes: 24 its to rel 6e-8, datum at the quadrature floor, sigma_nn recovery clean. Tests: test_1018 serial 18/18 (incl. gauge-removal cases), test_1066 np2 (reference unchanged - the linear-case imprint of the spurious projection is below the 1e-6 test tolerance), test_1070 serial (7) + test_1071. Underworld development team with AI support from Claude Code
…scale convergence for warm starts - FreeSurface._solve_consistent now warm-starts from the free solve's fields: the consistent solution is the free solution with a small material-boundary datum correction, and the free solve has already converged this step. Starting there keeps a power-law tangent at physical strain rates - a cold start puts it at the regularisation floor, where the Newton line search stalls at O(0.1) relative residual (measured, acceptance attempt 2). With the warm start the first acceptance step converges in 3 iterations. - The rotated loop's convergence reference is now max(r0, ||F(0)||): rtol relative to a warm start's own (small) initial residual demands ever-more absolute accuracy the better the guess (the warm-start rtol trap). The rest-state residual is the intrinsic forcing scale; cold starts unchanged (their r0 IS that scale). The step-norm verification and the non-convergence warning read the same reference. Acceptance (#438) evidence in ~/+Simulations/FreeSurface/annulus_fs_convection/b_snes_datum_acceptance/: power-law annulus FS convection with consistent_constraint="strong" through the SNES rotated path holds the material boundary at the strong-datum level (err 5.3e-3 vs penalty 3.7e-2; net flux 1.4e-4 vs 2.0e-3) at matched steps; TI fault-bearing smoke passes (24 its to rel 6e-8). Open finding recorded there: deformed-ring datum compatibility floors the interior residual at rel ~2e-3 (honestly reported unconverged; suspected arc-length-vs-FE-quadrature demean gap - the principled fix is a trace-mass-weighted demean). Tests: test_1018 18/18 serial; test_1070 serial+np2 (7); test_1071; test_1066 np2. Underworld development team with AI support from Claude Code
…ith the PETSc Jacobian checker Regroup note (#438 B-session): TI power-law under consistent_jacobian=True shows an early trajectory identical to the frozen tangent and Picard-paced convergence (24 its vs ~11 isotropic) - consistent with the dC/d(eps_II) director-orientation terms being dropped or mis-oriented in the JIT-lowered tangent, while the isotropic terms survive (isotropic Newton is superlinear through the same rotated machinery, which also argues the rotation algebra itself is transparent). Discriminating harness (native -snes_test_jacobian vs rotated directional-derivative probe, with an isotropic control) is staged in the #438 acceptance run directory; not yet run - joint item. Underworld development team with AI support from Claude Code
#457 The PETSc Jacobian checker (bounded-curvature protocol; at 1e-12 regularisation the FD reference is invalid for power-law and every model reads the same large mismatch) convicts the TI anisotropy tangent on the NATIVE path: ||J-Jfd||/||J|| ~ 2e-3-5e-3 in developed flow vs an FD-limited 4e-6 isotropic control. The rotated machinery is exonerated. Evidence and harness recorded in #457. Underworld development team with AI support from Claude Code
…inear datum test - docs/developer/subsystems/rotated-freeslip.md: the unified rotated path - constraint semantics, datum-through-Newton design (feasible iterates, cold-start lift), convergence reference scale, operator-verified null modes, parallel rules, reaction/sigma_nn hand-off. Linked from the developer index. - Changelog entry for the #438/#403 work at the conceptual level. - tests/parallel/test_1066: nonlinear (power-law) rotated datum at np>=2 - guards the collective-sequence class through the Newton loop (lift, line-search residuals, mode verification). Passes np2 and np4. Underworld development team with AI support from Claude Code
… refusal Conflict in the rotated free-slip dispatch only: development added the armed wall-clock-guard refusal (#442) around the old two-path dispatch; this branch had unified the dispatch into one Newton loop. Resolution keeps BOTH: the guard re-check (an armed guard would be silently inert on the rotated path, which runs its own Krylov loop outside self.snes) followed by the single unified call. Development's _record_convergence_status hook after the rotated report (#441 warm-start flag) auto-merged and stands. Underworld development team with AI support from Claude Code
…ust apply
The rotated free-slip path drove its custom-FMG velocity block with `preonly`
("a full-MG cycle per Schur application, by design"). PCFieldSplit forms the
Schur complement S = A11 - A10 A00^-1 A01 and applies A00^-1 through that same
velocity KSP, so `preonly` replaces A00^-1 with a single multigrid cycle and
hands the pressure Krylov a different system S~ != S, preconditioned by a 1/mu
mass matrix built for S.
Measured (annulus, weak plane reaching the constrained boundary,
eta_1/eta_0 = 1e-3, transversely isotropic): under `preonly` the pressure
residual falls 4.4e4 in ~16 iterations then STAGNATES at a floor ~3.1e-7 and
burns the remaining 184 iterations of its cap for nothing, every outer
iteration -- 9 outer iterations, 2.77 s. Under FGMRES it converges 1.1e8
monotonically in 17 pressure iterations -- 1 outer, 0.67 s (4.1x). The isotropic
control moves the same way (5 -> 1), so this is the Schur application and not the
anisotropy. SolCx box + 2-level barycentric hierarchy (the test_1018 FMG
configuration): 6 outer -> 1.
The Schur application is bitwise reproducible under both settings (measured), so
the floor is NOT "the operator changes between applications" -- S~ is a fixed
linear operator, just the wrong one. The precise origin of the floor is not
isolated.
Wrap the FMG in FGMRES: max_it 200 matches the GAMG fallback and the native path,
rtol 0.1 x tol matches the GAMG fallback (the native path asks 0.033 x tol).
Found while auditing whether the rotated path preconditions the ROTATED operator.
It does: an algebraic identity probe against independently formed references
(block the native Jacobian, then rotate -- the opposite order to the solver) shows
the velocity sub-KSP operator, the velocity sub-PC operator and every Galerkin
multigrid level operator match the rotated reference exactly, while differing
from the un-rotated reference by 7e-2 to 2.3e-1. The Schur preconditioner
correctly comes un-rotated from the native Pmat: Q is the identity on pressure,
and the p-p block of the rotated operator is identically zero. No un-rotated
matrix reaches the preconditioner.
Also:
- warn when the velocity sub-KSP exhausts its cap. It returns KSP_DIVERGED_ITS,
which KSPCheckSolve deliberately does not escalate, so FGMRES can degrade
silently where `preonly` could not fail.
- report `velocity_pc`, `schur_pre`, `velocity_pc_type` (PETSc's own view of the
sub-PC) and `vel_its_last` / `pres_its_last` in `_rotated_freeslip_info`. The
outer count alone hides a degraded inner solve, which is how this went
unnoticed. The `_last` names are deliberate: these are last-application
samples, not the summed work axis solver_health uses -- wiring the rotated path
into SolverInstrumentation.sub_reports() is the proper fix and is not done here.
- guard the FMG configuration in test_1018 against a return to `preonly`, and
assert the velocity sub-PC really is multigrid rather than restating our own
bookkeeping.
- fix a pre-existing failure in test_1064: `int(info["ksp_its"])` on a value the
unified rotated loop made a list, so the 3D spherical partition-independence
test errored at np>1 before reaching its assertions.
Tests: test_1018 18 passed; test_1064 + test_1066 10 passed at np2 and np4;
TI fault smoke passes (24 nonlinear its, datum relL2 4.4e-05).
Underworld development team with AI support from Claude Code
…ent the Krylov axes
The rotated path was looser than the native Stokes path on both fieldsplit
sub-solves, with no recorded reason:
rotated (was) native
velocity rtol 0.1 x tol 0.033 x tol
pressure rtol 1.0 x tol 0.1 x tol
House policy is that defaults err on the side of robust generality, with callers
loosening guardrails at their own risk. Matching native costs ~17% wall clock with
IDENTICAL outer iteration counts, measured on both velocity-block routes (custom
FMG and the GAMG fallback) and both rheologies (isotropic and transversely
isotropic). It also drops the TI fault smoke from 24 to 15 nonlinear iterations —
tighter inner solves feed a cleaner Newton step.
A too-loose inner solve does not degrade gracefully: it walks back toward the
`preonly` failure this branch already fixed (velocity rtol 1e-1 -> 6 outer
iterations, 1e-2 -> 4, 0.033 x tol -> 1). Cheaper remains available to anyone who
measures their own configuration; it is not the default.
Also documents the three axes that keep getting argued as one, in the solvers
subsystem page (which listed "preconditioner selection missing" as a known gap):
1. Flexible vs non-flexible Krylov. The question is whether the PRECONDITIONER is
stationary, not whether the operator is symmetric — the velocity block is SPD,
so cg is admissible on symmetry grounds and still fails, because GAMG with
mg_levels_ksp_converged_maxits is a non-linear application. Settled by #147.
2. `preonly` vs an iterative wrapper. Not a Krylov-taste question but a positional
one: a preconditioner application may be `preonly`, an operator inverse
underneath a Schur complement may not, because it defines the system the
pressure Krylov solves rather than merely conditioning it.
3. Inner tolerance. The genuine judgment call, decided here by the guardrail
policy.
Plus how to detect a degraded sub-solve: the outer count hides it, an exhausted cap
returns KSP_DIVERGED_ITS which KSPCheckSolve does not escalate, and
KSPGetIterationNumber on a sub-KSP is a last-application sample rather than work.
Tests: test_1018 18 passed; test_1064 + test_1066 10 passed at np2 and np4; TI
fault smoke passes (15 nonlinear its, datum relL2 4.4e-05).
Underworld development team with AI support from Claude Code
…ov are one design Provenance and framing from Louis. The Stokes configuration descends from the Citcom solver of Moresi & Solomatov (1995), whose central choice is that the inner solves are DELIBERATELY inexact. Two consequences follow and must hold together: 1. Inexact inner solves perturb the search directions, so the outer/Schur Krylov must be FLEXIBLE (fgmres or similar) — a non-flexible method's residual recurrence is invalidated by a search direction that drifts. 2. Inexact is not unbounded: an inner solve must still converge WELL BELOW the tolerance demanded of the outer solve. The 0.033 and 0.1 factors are that margin — a safety margin, not a tuned constant. So what looked like two independent axes (flexible-or-not, how-tight) is one decision: flexibility buys tolerance of inexactness, the margin bounds how inexact. Arguing them separately is why the question never settled. `preonly` is then the degenerate case — no tolerance at all, so no margin for the flexible outer Krylov to work with, which is exactly the stagnation floor measured on this branch. This also sharpens the previous commit's justification. The rotated path ran its outer KSP at rtol = tol and its pressure sub-solve ALSO at rtol = tol: the inner solve permitted to be no better than the answer it feeds. That is the invariant broken outright, not a tuning inconsistency with the native path. Recorded in the solvers subsystem page and at the point in rotated_bc.py where the margin is set. The size of the factors is noted as inherited convention with no derivation recorded beyond "well below the outer tolerance". Tests: test_1018 18 passed. Underworld development team with AI support from Claude Code
…rarchies on the rotated path (#468, #467) Three routes reach a multigrid Stokes velocity block — native (PETSc interpolation between refined DMPlex levels), custom-P on the standard solve path, and custom-P through the rotated free-slip path. They are the same preconditioner reached three ways, not alternatives: custom-P is mandatory wherever native cannot go, namely rotated BCs (the DM-coupled hierarchy cannot express a per-node rotation) and adapt() children (no DMPlex refinement relation). The option bundle was written in two places and had drifted (#468). The native path had been moved to a measured gmres+sor smoother; custom_mg._configure_pcmg never received that update, and never SET mg_levels_ksp_max_it at all, so the smoother inherited whatever last wrote that options prefix — 3 left behind by the GAMG bundle on the standard path, PETSc's own PCMG default of 2 on the rotated path. The same function smoothed differently depending on what had run before it. utilities/multigrid_options.py now owns both bundles (geometric MG and the GAMG fallback) and all three writers read from it. Each bundle DERIVES the stale keys it must clear — every key a sibling bundle sets that it does not — instead of carrying a hand-maintained delete list; the derivation reproduces all three previous hand-written lists exactly. The rotated route's svd coarse solve is a named variant of the shared bundle (coarse="svd") rather than a call-site override, because the Galerkin-coarsened rotated block inherits the rigid-rotation null space where redundant/LU hits a zero pivot (#306). The #276 single-field injection fragility deliberately stays out of the bundle: it is a routing decision, not an option value. Separately (#467), mesh.adapt() leaves a coarse tail on its refinement child so every solver on an adapted mesh gets geometric MG with no per-solver call. The rotated path never consulted it — the standard path's injection hook runs after the rotated dispatch has already returned — so an adapt child under rotated free-slip fell back to GAMG, indistinguishable from having no hierarchy at all. That is the adapt-on-top-faults workflow's own configuration. custom_mg.build_transfers is now the shared "which hierarchy does this solver get?" rule for both paths, with the same opportunistic barycentric->RBF fallback and degrade-to-GAMG on failure. Measured (same operator, RHS and coarse solve; two-level hierarchy, i.e. where the native measurement says the gmres margin is SMALLEST): route pre-fix unified custom-P standard (isotropic annulus) 5 vel its 4 vel its custom-P rotated (TI, eta1/eta0 = 1e-3) 11 vel its 5 vel its 0.683 s 0.392 s (1.74x, linear solve timed in isolation) Also here: the rotated FMG branch now drops its option keys after setup, as the KSP's own keys already were. The per-solve prefix is unique, so those keys previously accumulated in the global database once per timestep. A new test covers the failure mode that introduces — a later setFromOptions finding pc_type gone and abandoning the multigrid — across eight Newton increments. Tests: tests/test_1021_mg_option_bundle.py reads the smoother configuration back off the LIVE PCs for all three routes and asserts they agree, with the coarse difference asserted rather than tolerated (an options-database assertion would not have caught the drift, which was a key nobody wrote). Verified to fail when the pre-fix bundle is reimposed. The mesh-owned pickup is covered serially there and at np>1 in tests/parallel/test_1064, where the transfers are built cross-partition. Validation: test_1014/1015/1016/1017/1018/1020, test_0753/0835/0836/0840 pass; full "level_1 and tier_a" sweep 526 passed with 2 pre-existing failures (#470). Parallel np2 and np4: test_1017_custom_mg_parallel 6 passed, test_1064_rotated_freeslip_parallel 9 passed, test_1066_rotated_datum_parallel 2 passed. No goldens moved — GOLDEN_ANNULUS_FMG is a converged-solution quantity, so a better preconditioner reaches the same answer, and the iteration guards are upper bounds a better smoother only helps. Underworld development team with AI support from Claude Code
Adversarial reviewWe reviewed our own diff before marking this ready. Findings, most serious first. 1. The smoother we unified on costs wall clock on the standard route, at every depth we measuredThis is the weakest point of the change and it deserves the top of the list rather than a footnote. #468 asked for the native smoother bundle on the custom-P route. We measured what that costs. Outer Stokes KSP timed in isolation (setup warm), nested annulus hierarchy, linear solve, η contrast 1e6, varying only the velocity-block smoother on the live PC:
gmres/4 wins on iterations everywhere and loses on wall clock everywhere, by a margin that grows with depth — the opposite of the depth behaviour the native comment records. Decomposing at 2 levels: richardson/3 → richardson/4 is 2.382 → 2.456 s (the fourth sweep, ~3%); richardson/4 → gmres/4 is 2.456 → 3.437 s (gmres itself, ~40% per cycle). Custom-P uses Galerkin RAP coarse operators built from barycentric transfers, denser than the native nested ones, so the smoother is a larger share of the cycle here than on the route the original measurement came from. We do not think this refutes that measurement. It was taken on the Spiegelman notch with the consistent-Newton tangent — a non-symmetric operator, where richardson is stationary and degrades. The sweep above is linear and symmetric: richardson's best case. The complementary evidence is the rotated TI case in the PR body, which is anisotropic and where gmres/4 wins 11 → 5 iterations and 0.683 → 0.392 s. The gap we could not close: an attempt to measure the non-symmetric case on the standard route ( The call we took: unify on gmres/4 anyway. Richardson's failure on a non-symmetric operator is a robustness failure; gmres's cost on a symmetric one is a speed cost, and defaults err toward robust generality. The consistent-Newton tangent is the recommended setting for nonlinear rheologies, so the symmetric regime is the linear case only. But this is a defaults decision with a measured price on the linear path, and it is @lmoresi's to confirm, not ours. 2. We widened the scope past what #468 asked for#468 is about the geometric-MG bundle. We also brought the GAMG fallback into the owner, because We did not touch the 3. The rotated path now deletes its whole option bundle after setup — a new silent failure modePreviously the FMG branch deleted one key ( Deleting the rest is only safe if nothing re-reads them: a later 4.
|
…ymmetric under the consistent tangent
The comment this PR moved into utilities/multigrid_options.py carried a claim we
inherited without checking, and which the Layer 3 section of
design/nonlinear-solver-homotopy-warmstart.md states outright: that richardson
degrades because the consistent-Newton tangent makes the velocity block
NON-SYMMETRIC.
It does not. For an isotropic eta(eps_II) the Newton term is
2 (eta'/eps_II) (edot(u):edot(du)) (edot(u):edot(v))
a rank-one outer product a (x) a with a = edot(u) — symmetric under swapping du
and v. eta depends on grad v only THROUGH its symmetric part, so both factors
project onto the same tensor and nothing is left to break the symmetry.
Measured as ||A - A^T||_F / ||A||_F on the assembled velocity block
(velocity_block_symmetry.py in the rotated_pmat_audit study), against a linear
calibration of 5.1e-17, with a control on the size of the Newton contribution so
a null result cannot be vacuous:
rheology asym Newton term
linear (calibration) 5.1e-17 —
shear-thinning, isotropic 5.3e-17 2.1e-01
power-law, isotropic 5.3e-17 4.0e-01
Drucker-Prager, pressure-dependent yield 5.1e-17 5.1e-02
transverse isotropic, PICARD tangent 7.2e-02 —
transverse isotropic, Newton tangent 8.9e-02 2.0e-01
The consistent tangent contributed 5-40% of ||A|| in every nonlinear case and the
block stayed symmetric anyway. Non-symmetry appears only under transverse
isotropy — and there it appears under the PICARD tangent too, where a
frozen-coefficient form int edot(du):C:edot(v) is symmetric by construction. That
is a defect signature, not a property of the tangent: linear TI with anisotropy
fully active is symmetric at 6.3e-17. Reported on #457.
The Spiegelman-notch contraction measurement (0.75 richardson vs 0.56 gmres at
four iterations, margin growing with depth) is unaffected — it is a measurement,
not a derivation. The mechanism that fits it AND fits the cost we measured on
well-conditioned problems is operator CONDITIONING: at eta contrast 1e26 the
SOR-preconditioned spectrum is spread far enough that a stationary iteration
stalls where a Krylov smoother adapts its polynomial.
Also records the cost, which was previously unmeasured on this route: nested
annulus, LINEAR (symmetric) velocity block, eta contrast 1e6, outer KSP timed in
isolation, gmres/4 against richardson/3 — iterations x1.45 / x1.60 / x1.20 at 2 /
3 / 4 levels, wall clock x0.86 / x0.77 / x0.55. gmres wins iterations everywhere
and loses wall clock everywhere. It stays the default on both routes because the
failure it avoids is worse than the cost it carries, and it carries that cost
exactly where the problems are easy; the number is recorded so the guardrail can
be removed deliberately.
No behavioural change — comments, docstrings and docs/developer/subsystems/solvers.md.
A TODO(DESIGN) marks the design doc's Layer 3 claim rather than rewriting it.
Underworld development team with AI support from Claude Code
Follow-up: the smoother's recorded mechanism is wrong (2550d9a)@lmoresi queried the "non-symmetric" framing in finding 1 above. He was right, and it does not survive measurement. Correcting it is now part of this PR — comments and docs only, no behavioural change. The claim. Both the bundle comment we moved into On paper it is wrong. For an isotropic η(ε̇_II) the Newton term is Measured, | rheology | asymmetry | Newton term The third column is the control — a null symmetry reading is vacuous if the consistent tangent contributed nothing. It contributed 5-40% of Non-symmetry appears only under transverse isotropy, and there it appears under the Picard tangent too, where A trap worth recording: petsc4py's What this does and does not change. The Spiegelman-notch contraction measurement (ρ 0.75 richardson vs 0.56 gmres, margin growing with depth) is untouched — it is a measurement, not a derivation. Only the explanation attached to it was wrong. The mechanism that fits both it and the cost in finding 1 is operator conditioning: at η contrast 1e26 the SOR-preconditioned spectrum is spread far enough that a stationary iteration stalls where a Krylov smoother adapts its polynomial. That predicts the notch result and predicts that gmres loses on the well-conditioned problems we measured. It also dissolves the gap we flagged in finding 1. We were looking for a "standard path × non-symmetric operator" case to justify gmres there. That cell does not exist for isotropic rheologies — the standard-path block is symmetric whether the tangent is Picard or Newton. So the discriminator is not symmetry but conditioning, and the honest position is:
The design doc's Layer 3 claim is marked with a Underworld development team with AI support from Claude Code |
Scope note: this does not un-gate geometric MG for single-field solvers (#478)A parallel session flagged that geometric FMG is currently gated off. Checking what this PR does and does not close: the gate is real, it is the #276 single-field lockout, and #471 does not remove it — deliberately, as the one design question the brief asked us to settle. Measured off the live PC after setup:
Stokes is fine. Every single-field solver gets algebraic multigrid at any depth, even when geometric is requested explicitly, via We kept that out of the shared bundle owner on the grounds that it is a routing decision — which route a solver may take — not an option value, and so belongs with the route choice rather than with the settings. That reasoning still holds. But it does mean the headline "all three routes now agree" is scoped to the routes a solver is allowed to reach, and for scalar and vector solvers there is only one. What this PR does change for them: the sanctioned workaround ( Filed as #478 with a proposed fix, because #471 makes it small: Repro: Underworld development team with AI support from Claude Code |
The multigrid bundle was applied wholesale on every rebuild. The only escape was the `_pc_user_override` latch, and that latch keys on `pc_type` ALONE — so of the bundle's ten keys, setting any of nine was silently discarded, and setting the tenth rescued all ten. Measured, asking for chebyshev / max_it 6 / coarse svd on the Stokes velocity block: what the user set result the three bundle keys only OVERWRITTEN (gmres/4/redundant) the three keys + fieldsplit_velocity_pc_type=mg honoured the three keys + preconditioner="fmg" OVERWRITTEN Note the inversion in the last row: explicit `preconditioner="fmg"` skips the latch entirely, because that mode "always applies". The more clearly a user stated their intent, the less control they had. And the workaround for the first row — "also set pc_type to the value it already has" — is undiscoverable. Making this PR the single owner of the bundle would have entrenched all of it, which is why the fix belongs here. `MGBundle.apply` now takes an `owned` record and leaves alone any key whose current value is not the one UW3 last wrote — in the stale-key clear too, since a user-set key is not ours to remove. Ownership is RECORDED, never inferred from the value: inference fails as soon as a second internal writer touches the same key, which is exactly how the `tolerance` and `strategy` setters defeated an earlier attempt at this (#477). Every internal writer of a bundle key therefore goes through the new `SolverBaseClass._push_managed_option`, so anything unrecorded is the user's by construction. The record is keyed by the GLOBALLY-QUALIFIED option name. The solver writes through a prefixed Options view (`Solver_N_`) while `custom_mg._configure_pcmg` reads the global database using the live PC's own full prefix; recording the unqualified name made every key look user-owned over there and the bundle silently stopped applying. That regression was caught by the defaults arm, not by any test — which is the whole argument for keeping a defaults arm. Tests: `test_user_set_bundle_keys_are_honoured` (parametrised over auto and explicit `fmg`) asserts the two keys the user set survive a rebuild AND that the key they left alone still carries the measured default — respecting one key must not abandon the bundle. `test_unset_bundle_keys_keep_the_managed_defaults` is its control: without it the first test could pass trivially by never applying the bundle, which is precisely how the first attempt failed. Validation: fmg_config_diff shows the three routes still agreeing on every row but the deliberate rotated `svd`. 68 passed across test_1014/1015/1016/1017/1018/1020 and test_0753/0835; `level_1 and tier_a` 526 passed with the two pre-existing #470 failures; parallel np2 and np4 green (6 / 9 / 2 passed). Found by the solver configuration audit, `docs/reviews/2026-07/solver-configuration-reachability-audit.md`. Underworld development team with AI support from Claude Code
…483/#484 F-1 is fixed in #471 (5a390ef). F-3/F-4 filed as #483, F-6/F-7/F-8 as #484, F-5 raised on #475. Adds the two lessons from implementing F-1: the ownership record must be keyed by the globally-qualified option name (an unqualified record made the bundle silently stop applying on the custom-P route), and the defaults arm is what caught that — the second ownership regression in two sessions caught by the arm that was supposed to be unchanged. Underworld development team with AI support from Claude Code
…dle to MGSettings `solver.strategy` has been a public property accepting "default", "robust" and "fast" since it was written, validating your input and then configuring all three identically — its docstring said "(Reserved)". A property that checks a value and then ignores it is the same defect class as #477 and #478: the failure is invisible because the solve still converges. This fills it, and it turns out the machinery this PR built is exactly what was missing underneath. TWO LAYERS, which is the design point: solver.strategy the named intent public, already existed MGSettings the option values utilities/multigrid_options.py solver.petsc_options the escape hatch public, outranks both (F-1) `geometric_mg_bundle(smoother=...)` gains the variant axis, from the two regimes measured in this PR's own work — neither dominates: "robust" = gmres/4 survives an operator a stationary smoother stalls on. Spiegelman notch (eta contrast 1e26, 4 levels) per-V-cycle contraction 0.56 vs richardson's 0.75, margin growing with depth; TI rotated annulus 11 velocity its -> 5, 1.74x. "fast" = richardson/3 cheaper per cycle where the operator is benign. Linear, SYMMETRIC annulus at eta contrast 1e6: beats robust on wall clock at every depth (x1.16, x1.30, x1.82 at 2, 3, 4 levels) while taking more iterations. "default" stays "robust", so nobody's results move. "fast" is the documented opt-out for the cost the adversarial review on this PR quoted and could not otherwise offer a way out of. ALSO REMOVES DEAD CODE THE MEASUREMENT EXPOSED. The `strategy` setter wrote the whole GAMG velocity bundle plus `pc_mg_type=kaskade`, the latter carrying a comment warning against changing it without benchmarking. Every one of those writes was DEAD: `_apply_preconditioner_options` runs later, at `_build`, and is the single writer of that block — it overwrites them with the geometric bundle on a refined mesh, or the GAMG bundle ("additive") without one. Measured in both orders and with `preconditioner="fmg"` set before and after: the live PC was mg/FULL every time, so `kaskade` never once took effect. The strategy's effect on the velocity block now runs through `_mg_smoother_variant`, which selects a bundle variant rather than racing the bundle writer. `MGBundle` -> `MGSettings` (internal, no call site outside the module and custom_mg): "MG" is what the module actually holds, and "Bundle" said nothing. Tests: strategy selects a real variant; "default" reproduces the framework default exactly (the control — without it, filling the axis could silently move results); a user-set option still beats the strategy while the strategy keeps the keys the user left alone (the two layers compose in the right order). Validation: three routes still agree bar the deliberate rotated svd; 79 passed across the MG, adaptivity and solve-report suites; `level_1 and tier_a` 526 passed with the two pre-existing #470 failures; parallel np2 and np4 green (6 / 9 / 2). Underworld development team with AI support from Claude Code
Asking "what am I actually running?" required knowing which nine PETSc option keys to look up, and `_pc_managed_value` / `_pc_user_override` were private. That is the gap the configuration audit filed as #484 — of twelve preconditioner fallbacks, ten left no readable state — and `solver.strategy` is the natural place to close it for this block, since the strategy is what the user set. The getter now returns the strategy NAME that also reports what it resolved to: >>> stokes.strategy 'default' — geometric multigrid (2 levels), full cycle, smoother gmresx4 + sor, coarse redundant/lu >>> stokes.strategy == "default" True `_StrategyName` subclasses `str` deliberately, so comparisons, formatting and serialisation are unchanged — nothing that used `solver.strategy` before behaves differently. Verified nothing in src/ or tests/ compares it to a string in the first place; they only set it. Three properties of the report matter more than its wording: - It NAMES a user override rather than absorbing it, so the summary cannot hide the difference between what the strategy asked for and what is running. - It REFUSES to report before it knows. The preconditioner resolves at the first solve; until then the report says so instead of presenting the constructor defaults as the answer. The first version of this did present them, which is precisely the stale-but-authoritative summary this reporting exists to prevent — hence the new `_pc_resolved` flag. - `solver.preconditioner_settings` is the same information as a dict, so a test can assert on it instead of parsing prose or inferring from timings. `multigrid_options.describe()` does the formatting, next to the values it describes, so a new bundle variant cannot be added without the report following it. Validation: 14 passed in test_1021; 49 passed across test_1014/1017/1018/1020/1058; `level_1 and tier_a` 526 passed with the two pre-existing #470 failures; parallel np2 and np4 15 passed each. Underworld development team with AI support from Claude Code
…tes, and the strategy value broke pickle
Two defects found by an adversarial pass over this PR as a whole, both introduced by
its own last two commits.
1. `strategy="fast"` was honoured on the NATIVE route and silently ignored on BOTH
custom-P routes. `_configure_pcmg` had no smoother argument, so the standard and
rotated paths always got "robust" whatever the user asked for. Measured:
native route ('richardson', 3) as asked
custom-P standard ('gmres', 4) IGNORED
custom-P rotated ('gmres', 4, svd) IGNORED
This is precisely the drift class this PR exists to eliminate — a setting
honoured on one route and dropped on the two that matter most, since custom-P is
mandatory for rotated BCs and adapt() children. It survived because the only test
of the strategy axis used a refined mesh, i.e. the native route.
`_configure_pcmg` now takes `smoother=` and every install path passes
`solver._mg_smoother_variant`. Its docstring says what happens if you forget.
2. The strategy value broke `pickle`, `copy` and `deepcopy` with a TypeError.
`str.__reduce_ex__` reconstructs via `cls(value)` with ONE argument, which a
two-argument `__new__` cannot accept. `__reduce__` now carries the summary. The
claim in 13bd82c's message that "serialisation is unchanged" was wrong, and UW3
has a serialisation system that would have hit it.
Tests for both, each written to fail on the old code: `test_strategy_reaches_every
_route` asserts richardson/3 on all three routes (and that the rotated coarse solve
is still svd), and `test_strategy_value_pickles_and_copies` round-trips the value.
Validation: three routes still agree bar the deliberate rotated svd; 79 passed
across the MG, adaptivity and solve-report suites; `level_1 and tier_a` 526 passed
with the two pre-existing #470 failures; parallel np2 and np4 17 passed each.
Underworld development team with AI support from Claude Code
Holistic adversarial review — all six commitsThe earlier review covered 1.
|
| route | asked fast |
got |
|---|---|---|
| native | richardson/3 | richardson/3 |
| custom-P standard | richardson/3 | gmres/4 |
| custom-P rotated | richardson/3 | gmres/4 (+svd) |
And it is dropped on the two routes that matter most — custom-P is mandatory for rotated BCs and adapt() children, which is the argument in this PR's own description.
Why it survived: the only test of the strategy axis used a refinement=1 mesh, i.e. the native route. A three-route feature tested on one route. The parity test (test_all_three_routes_share_one_bundle) did not catch it either, because it compares the three routes to each other at the default strategy, where they agree.
Fixed: _configure_pcmg(smoother=...), every install path passes solver._mg_smoother_variant, and test_strategy_reaches_every_route asserts all three.
2. The strategy value broke pickle, copy and deepcopy
_StrategyName.__new__(cls, name, summary) takes two arguments; str.__reduce_ex__ reconstructs via cls(value) with one. So every one of these raised TypeError:
pickle -> TypeError: __new__() takes exactly 3 positional arguments (2 given)
copy -> TypeError: ...
deepcopy -> TypeError: ...
13bd82ce's commit message asserted "comparisons, formatting and serialisation are unchanged". Two of three were true. UW3 has a serialisation system that would have hit this. Fixed with __reduce__; test round-trips through all three.
3. Checked and clean
- A custom-P install does not report spurious user overrides. The ownership record is keyed by fully-qualified name while
preconditioner_settingsreads through the prefixed view;_user_overridden_pc_optionsreturns()after aset_custom_fmgsolve, so the two prefix conventions do line up. This was the specific worry after the F-1 regression, which was the same class of mistake. - The defaults arm is unchanged at every step — the three routes still agree on every row but the deliberate rotated
svd. That arm has now caught one regression in this PR (F-1's unqualified record) and is the reason the ownership work is trustworthy at all. describe()degrades honestly on a partial settings dict (?rather than aKeyError), and refuses to report before resolution.
Residual risk we are accepting, stated plainly
preconditioner_settingsreturns strings, because that is what the PETSc options database holds (mg_levels_ksp_max_itis"4", not4). Tests assert against strings. Honest but slightly awkward; typing it would mean duplicating each key's type._pc_resolvedis set even when the user-override latch early-returns. Defensible — the resolution decision was made, it was "leave it alone" — but the report then describes options UW3 did not choose. It does label them as user-overridden.- The rotated route's option keys live under a per-solve prefix (
rotfsN_) that no user can address, so a user override cannot reach the rotated velocity block at all. Pre-existing, out of scope here, and worth its own issue. strategy="fast"is not measured on the rotated route. The variant is now plumbed there and asserted, but the fast/robust wall-clock comparison was only measured on standard/native. The regime argument should carry over; it is not verified.
Scope
Six commits: bundle owner + #467 pickup; mechanism correction; F-1 ownership; strategy fill + rename; reporting; these fixes. Larger than "one owner for the bundle", and @lmoresi has signed off on keeping it whole on the grounds that the evolution is legible. Each commit after the first came from a measurement or a review finding rather than from scope drift.
Validation on the final state: level_1 and tier_a 526 passed with two pre-existing #470 failures; 79 passed across the MG/adaptivity/solve-report suites; parallel np2 and np4 17 passed each; no goldens moved.
Underworld development team with AI support from Claude Code
Diagnostic report against development @ 0437958. No code changes. Five defects of the same shape had been found independently over a few weeks (#276/#478, #467, #468, #477, #425). Every one degrades to something that SUCCEEDS, so none presented as a failure. This audit looks for the rest along three axes: option-key reachability, silent substitution in fallback branches, and accepted-but-inert solver properties. Findings routed to #471 (F-1, fixed), #478, #483, #484, #486, and a coordination note on #475. Scalar and vector solver families came back clean, so the whole class is localised to the saddle-point path. Records the method and its traps, including three ways the instrument was wrong before it was right - the negative control being what caught the worst of them. Underworld development team with AI support from Claude Code
…squash #465 landed on development as a squash, so this branch's history still contains its pre-squash commits and they now read as conflicts against development's tree. Four files, eight hunks, every one of them this branch's own intentional replacement of a line #465 had written: src/underworld3/utilities/rotated_bc.py 2 hunks (module docstring; the _build_rotated_custom_Pl rewrite that added the mesh-owned pickup) docs/developer/subsystems/rotated-freeslip.md 2 docs/developer/subsystems/solvers.md 3 docs/developer/CHANGELOG.md 1 Resolved to this branch. Verified rather than assumed, because a squash conflict is where content goes missing quietly: every hunk inspected individually, no conflict markers anywhere in the tree, and all four contributors' work confirmed present in the merged source — #465's fgmres velocity sub-KSP and datum machinery, #471's build_transfers and multigrid_options, and #487's datum-contract tests. Validation on the merge result: `level_1 and tier_a` 530 passed, 0 FAILED (the first fully green sweep — #487 cleared the last two); 50 passed across test_1021/1018/1017/1014; the three routes still agree on every row but the deliberate rotated svd coarse solve. Underworld development team with AI support from Claude Code
Closes #468. Closes #467. Stacked on #465.
The problem
Three routes reach a multigrid Stokes velocity block, and they are the same preconditioner reached three ways, not alternatives:
refinement >= 1, ordinary BCsDMCreateInterpolationbetween refined DMPlex levelsset_custom_fmg, or anadapt()child's mesh-owned coarse tailrotated_bcP̂ = Q_v·Pcustom-P is mandatory wherever native cannot go — rotated BCs (the DM-coupled hierarchy cannot express a per-node rotation) and
adapt()children (no DMPlex refinement relation). So the drifted bundle was on the route that matters most.#468 — the bundle was written in two places. The native path had been moved to a measured
gmres+sorsmoother;custom_mg._configure_pcmgnever received that update, and never setmg_levels_ksp_max_itat all, so the smoother inherited whatever last wrote that prefix: 3 on the standard path (left over from the GAMG bundle) and 2 on the rotated path (PETSc's own PCMG default). The same function smoothed differently depending on what had run before it.#467 —
mesh.adapt()leaves a coarse tail on its refinement child so every solver on an adapted mesh gets geometric MG with no per-solver call. The rotated path never consulted it (the standard path's injection hook runs after the rotated dispatch has already returned), so an adapt child under rotated free-slip reportedvelocity_pc == "GAMG"— indistinguishable from having no hierarchy at all. That is theadapt-on-top-faultsworkflow's own configuration.The change
src/underworld3/utilities/multigrid_options.pyowns both bundles (geometric MG and the GAMG fallback); all three writers read from it. Two design points worth review:setFromOptionsre-reads a leftover. Deriving it from the union reproduces all three previous hand-written lists exactly — which is the check that it is the right abstraction, and it is self-maintaining when a key is added.geometric_mg_bundle(coarse="svd")), not a call-site override, so the one legitimate per-route difference is visible in the same place as everything else. The other native/custom-P asymmetry — native FMG being unusable for single-field solvers becauseDMCreateInjectionis unreliable on a refined DMPlex (FMG (preconditioner="fmg"/"auto") fails with PETSc err62 (injection) for scalar solvers on a refined Plex hierarchy #276) — deliberately stays out of the bundle: it is a routing decision, not an option value, and belongs with the route choice.custom_mg.build_transfersis now the shared "which hierarchy does this solver get?" rule for both the standard injection hook androtated_bc._build_rotated_custom_Pl, with the same opportunistic barycentric→RBF fallback and degrade-to-GAMG on failure.Also: the rotated FMG branch now drops its option keys after setup, as the KSP's own keys already did. The per-solve prefix is unique, so those keys previously accumulated in the global options database once per timestep.
Measured
Same operator, RHS and coarse solve. Two levels — where the native measurement says the gmres margin is smallest.
Reproduce:
~/+Simulations/rotated_pmat_audit/bundle_unification_gain.py(iterations, cold solve each repeat) andsmoother_drift_cost.py(the linear solve timed in isolation, setup warm).fmg_config_diff.pynow shows the three routes agreeing on every row except the deliberatecoarse_pc = svd.Tests
tests/test_1021_mg_option_bundle.pyreads the smoother configuration back off the live PETSc objects for all three routes and asserts they agree, with the coarse difference asserted rather than tolerated. An options-database assertion would not have caught the original drift — the drift was precisely a key nobody wrote. Verified to fail when the pre-fix bundle is reimposed.Also covered: the mesh-owned pickup and its negative control; the rotated FMG PC surviving eight Newton increments (the failure mode the option cleanup introduces); and the same mesh-owned pickup at np>1 in
tests/parallel/test_1064, where the transfers are built cross-partition.Validation
Serial:
test_1014,test_1015,test_1016,test_1017,test_1018,test_1020,test_0753,test_0835,test_0836,test_0840all pass. Fulllevel_1 and tier_asweep: 526 passed, 2 failed — both pre-existing and unrelated (test_0641still asserts a non-zero rotated datum raisesNotImplementedError, which #458 implemented; filed as #470).Parallel np2 and np4:
test_1017_custom_mg_parallel_mpi6 passed,test_1064_rotated_freeslip_parallel9 passed,test_1066_rotated_datum_parallel2 passed.No goldens moved. The brief anticipated re-recording; none was needed.
GOLDEN_ANNULUS_FMGis a converged-solution quantity at 1e-6 rtol, so a better preconditioner reaches the same answer, and the iteration guards (its <= 25,max(ksp_its) <= 3) are upper bounds a better smoother only helps.Underworld development team with AI support from Claude Code