Nonlinear solver: automatic warm start, model-advertised yield homotopy, and an inf-safe power-mean yield law#441
Conversation
…ta constant Add the power-mean soft-min yield smoother and make the soft-min softness delta a constants[] atom (runtime-rampable with no JIT recompile) on the ViscousFlowModel base class, so the visco-plastic subclasses inherit one implementation. This is the generalisable, scalable substrate for a yield homotopy toward the sharp Min surface. Base class (ViscousFlowModel): - _combine_yield(eta_ve, eta_pl): the shared viscous/plastic combination, keyed on yield_mode -- "min" (exact hard Min), "harmonic", or "softmin" (the delta soft-min in the family chosen by yield_smoother). Development's tri-modal yield_mode semantics are preserved exactly (unlike the source branch, which collapsed "softmin" into "min"). - _get_yield_softness / _get_yield_offset: delta and the onset offset held as constants[] UWexpression atoms (the offset defined symbolically in terms of delta -- one symbol in the stress tensor, so no tensor blow-up -- so one delta update repacks both). Ramping delta via the atom + solver._update_constants() forces no recompile. - yield_smoother property/setter: "sqrt" (default; overshoots tau_y in the transition) or "powermean" (undershoots tau_y -- eta_eff <= Min always -- overflow-safe on geodynamic viscosity ranges). Selecting "powermean" from delta=0 bumps delta to 1 (s=1 harmonic mean), since delta=0 is the singular Min limit. Models (Stage 1 -- isotropic, test-covered): - ViscoPlasticFlowModel (Drucker-Prager): defaults to yield_mode="min" == today's exact hard Min (zero behaviour change), and GAINS yield_mode / yield_softness knobs so it can opt into the softmin / power-mean homotopy -- the model the hard-case DP homotopy needs. - ViscoElasticPlasticFlowModel (VEP): its inline sqrt soft-min routed through _combine_yield; delta moves float -> atom (value-identical); yield_softness setter syncs the atom. Zero behaviour change at stock settings: _combine_yield at default settings is bit-identical to the previous inline law (delta merely moves from a baked float to a constants[] symbol evaluated to the same number). Power-mean and runtime-delta are opt-in. The distrusted in-solve enable_yield_homotopy ramp driver is intentionally NOT part of this PR. TI-VEP / TI-VEP-Split (4 anisotropic sites) are a Stage-2 follow-up. Tests: tests/test_1055_yield_smoother.py -- delta=0 exact Min; power-mean undershoot + overflow-safety + ->Min; smoother validation/default; runtime delta ramp no-recompile; DP opt-in. level_1/tier_a gate green (402 passed, 0 failed). VEP _combine_yield verified bit-identical to the prior float law at defaults. Underworld development team with AI support from Claude Code
…th floors The Drucker-Prager viscosity guarded its yield floor with `if yield_stress_min.sym != 0:`, but the unset default yield_stress_min is a -inf-valued Parameter (a UWexpression atom), which passes `!= 0`. So every yield became `Max(<-inf parameter>, yield_stress)`. That -inf parameter cannot be resolved by sympy's fuzzy `is_ge`, so canonicalising the outer Max for the consistent-Newton tangent recursed without bound (is_ge -> _n2 -> (a-b).evalf(2) -> is_ge ...). The guard now uses the model's own -inf "unset" sentinel, so the spurious wrapper is not applied; a yield_stress_min of exactly 0 is now honoured as a real floor. Also, because a hard Max floor reintroduces a non-differentiable kink under the consistent tangent, route both DP floors (shear_viscosity_min, yield_stress_min) through `_apply_floor`: exact hard Max in yield_mode="min" (unchanged), and a delta-rounded smooth maximum in the smooth modes so the whole effective viscosity stays differentiable (the same delta that regularises the yield transition). Add public uw.maths.smooth_max / smooth_min primitives (regularised like delta_function) - the Newton-safe way to write a physical rounded tension cap, smooth_max(C + sin(phi)*p, 0, eps), in place of sympy.Max(sigma, 0). Tests (test_1055): a consistent-Newton Jacobian build on a pressure-dependent Max(C+sin(phi)*p, 0) yield (regression for the recursion), and a smooth_max/ smooth_min contract test. Underworld development team with AI support from Claude Code
…tion (reference impl) The trusted homotopy for hard viscoplastic (Drucker-Prager) yield: hold delta constant for a full nonlinear solve to tolerance, warm-start the next (smaller) delta from the converged state, march down and settle at the smallest feasible delta. Extracted from the proven Spiegelman harness (convergence.py:: run_continuation). delta lives in constants[], so each step is a recompile-free PetscDSSetConstants update. This is the reference implementation the design doc (nonlinear-solver-homotopy-warmstart) folds into stokes.solve(homotopy=True). It orchestrates solves only; the caller configures the tangent/preconditioner/ smoother (see the driver's Notes and the design doc's trap list). NOT the in-SNES delta-ramp, which is proven non-viable (see the design doc). Underworld development team with AI support from Claude Code
…r yield homotopy PROPOSED design for a dedicated, benchmarked implementation session. Captures: - why setup is the real defect (fiddly config = API regression), with the trap list; - the evidence: multi-solve delta-continuation converges the hard case, the in-SNES delta-ramp does NOT (proven on the proven config: DIVERGED_LINEAR_SOLVE, ~2h) and the mechanism why (sharpen delta only from a converged, well-conditioned iterate); - the three-layer design: automatic Picard-on-cold warm-start + has_solution property + zero_init_guess auto-detect; constitutive-model-advertised single-parameter homotopy behind solve(homotopy=True) with residual-guided auto-descent; a non-symmetry-safe multigrid smoother default under the consistent tangent; - staged implementation plan and open verification points (free-surface chain, mover reset, single-Picard-step-lands-in-basin, benchmark the default changes). Underworld development team with AI support from Claude Code
… Picard warm-up (Layer 1a) Layer 1a of the nonlinear-solver automatic warm-start / single-parameter yield homotopy design (docs/developer/design/nonlinear-solver-homotopy-warmstart.md). SolverBaseClass.has_solution — a public, read-only warm-start status flag. Set True only after a solve whose SNES converged (reason > 0); reset to False initially, after a diverged solve, and on a structural rebuild (the is_setup=False invalidation used by remesh / adapt / mesh-mover). It survives coefficient-only changes (viscosity, yield softness delta, BC values, time step), which route through _update_constants / a direct function rewire, so parameter continuation and time-stepping warm-start correctly. Recorded by _record_convergence_status() at the end of every solve() — scalar, vector, multi-component, Stokes, and the rotated free-slip path (from its info dict, since that path runs its own KSP loop rather than self.snes). Stokes cold-start warm-up — a single Picard step (reusing the existing picard=1 nrichardson machinery) is taken automatically on a cold (zero_init_guess) solve under the consistent-Newton tangent, the opt-in nonlinear-yield regime that needs a defect-correction step into the Newton basin. The default (frozen) tangent path is bit-identical and no default is flipped — the zero_init_guess auto-detect flip is the separate, benchmarked Layer 1b. Additive and low-risk. Regression test test_0201 covers the has_solution lifecycle (fresh / converged / structural-reset / coefficient-survival / mesh-deform) and that the cold consistent-Newton warm-up converges. Underworld development team with AI support from Claude Code
…rk Layer 1a landed Seed a `nonlinear-solver` skill capturing the working recipe for hard viscoplastic Stokes convergence — automatic warm-start plus the MULTI-SOLVE δ-continuation (not an in-SNES ramp), the consistent-Newton tangent, and the non-symmetry-safe smoother — together with the CONFIG TRAP LIST (the setup mistakes that each produce a different failure a few steps in) and the diagnostic tell (get_snes_diagnostics linear_iterations ≈ 1 = no linear work). It records the proven-dead in-SNES δ-ramp as a do-not, superseding the enable_yield_homotopy advice in the plasticity-solvers skill for the hard case, and cross-links plasticity-solvers for the yield-law maths and tangent-per-model. Mark Layer 1a landed in the design doc with the as-built scoping decisions (warm-up gated on the consistent-Newton tangent; rotated path records via its info dict; has_solution resets in the is_setup setter). Underworld development team with AI support from Claude Code
…ost V-cycle (Layer 3) Layer 3 of the nonlinear-solver design. The geometric-FMG option bundle now uses a non-symmetry-safe Krylov smoother sized for a DEEP hierarchy, rather than the stationary richardson+sor that preceded it: mg_levels_ksp_type richardson -> gmres mg_levels_pc_type sor (unchanged) mg_levels_ksp_max_it 4 (unchanged) mg_levels_ksp_norm_type (new) none -> fixed-cost V-cycle Measured on the Spiegelman notch (Drucker-Prager, eta contrast 1e26, V=10) with a NESTED hierarchy built by uniform refinement of an ultra-coarse 492-cell base, so the multigrid actually has depth. Per-V-cycle contraction rho of the velocity block at xi=0.01, at the SAME four smoother iterations: 3 levels: richardson 0.722 gmres 0.686 (5% better) 4 levels: richardson 0.746 gmres 0.560 (25% better) The gmres margin GROWS with depth, because a deeper cycle applies the smoother on more coarse operators, each non-symmetric under the consistent-Newton tangent. A two-level cycle is a coarse-grid correction rather than a V-cycle and is not worth special-casing, so the default is set for the deep case unconditionally — no level-count gate and no consistent_jacobian gate. Four smoother iterations, not more: per unit work gmres/4 (rho^(1/4) = 0.87) beats gmres/8 (0.478^(1/8) = 0.91). norm_type=none makes the smoother run exactly max_it iterations with no residual norm and no early exit, so every V-cycle costs the same. The resulting cycle is non-stationary; the velocity block is already fgmres (flexible), which is what tolerates that, so no other change is needed. Not fixed by this: at the hard small-xi corner the inner solve fails under every smoother tried (richardson outright, gmres with rho > 1 at 4 levels). That is operator conditioning, and the lever for it is the delta/xi continuation (Layer 2), not the smoother. Verified: test_1014 (contract updated to the new bundle + the fixed-cost assertion), FMG checkpoint hierarchy, Stokes cartesian/spherical/nullspace/rotated-freeslip and the solver smoke suite (34 passed), plus ptest_0004 FMG hierarchy at np=2. Underworld development team with AI support from Claude Code
…pth method The Layer 3 design section predicted a one-line smoother swap gated on consistent_jacobian. The benchmark changed the shape of the fix, so record what was actually measured and what landed: - the gmres-over-richardson margin GROWS with multigrid depth (5% at 3 levels, 25% at 4), because a deeper cycle applies the smoother on more non-symmetric coarse operators — so a comparison run on a 2-level hierarchy is misleading; - therefore the default is unconditional (no level-count or tangent gate), since a two-level cycle is a coarse-grid correction rather than a V-cycle and is not worth special-casing; - four smoother iterations, not more, on a per-unit-work basis; - the small-xi corner is NOT fixed by any smoother (richardson fails outright, gmres gives rho > 1 at 4 levels) — that is operator conditioning and belongs to the delta/xi continuation. The skill gains a "Multigrid depth" section with the method: build depth from an ultra-coarse NESTED base (492 cells + uniform refinement) rather than a fine mesh, never a non-nested hierarchy, and probe the fieldsplit_velocity_ sub-KSP because solve_report records the Newton rate and is blind to the smoother. Its trap-list row for the non-symmetric smoother is now "this is the default" rather than a manual fix. Underworld development team with AI support from Claude Code
…py=True) (Layer 2)
Layer 2 of the nonlinear-solver design. A hard Drucker-Prager problem does not
converge from a cold start on the sharp yield surface; the route that works is a
multi-solve continuation in the yield softness delta. This makes that the API rather
than a hand-built driver.
The constitutive model ADVERTISES the homotopy, exactly as it already advertises
stress-history support:
supports_yield_homotopy False on Constitutive_Model, True on the viscoplastic
and (TI-)VEP models.
_yield_homotopy_control() switches the model into its smooth delta-parameterised
mode (softmin + power-mean) and returns a
YieldHomotopyControl: a model-owned delta setter, the
tangent to pair with it, and the delta atom.
The model owns what the parameter MEANS: the isotropic models update delta as a
constants[] atom (no recompile) while TI-VEP rebuilds, and the elastic models ask for
the frozen/Picard tangent because the consistent yield tangent across the elastic
stress-history block is indefinite and fails the linear solve outright.
stokes.solve(homotopy=True, homotopy_options=...) runs the continuation and returns
the march summary. Each delta-step re-enters solve() normally, so nothing recurses;
a VEP model's timestep is forwarded to the inner solves. Refusing an unsupported
model is explicit (there is no yield surface to sharpen).
The march itself is now residual-guided: a step that converges well inside its
iteration budget earns a bigger next step, a step that nearly exhausts it earns a
gentler one, and a failed delta is reverted and retried more gently before the march
settles. Measured on a small viscoplastic box it walks delta from 1.0 to 7.8e-4 in
four solves from a COLD start.
Cold-start safety: _yield_homotopy_control floors the strain-rate invariant with the
vanishing constant, because tau_y/(2 edot_II) is 0/0 at v=0 and the first residual is
otherwise NaN. Carries a TODO(BUG) — the hazard is not homotopy-specific, any cold
solve of a viscoplastic model with a finite yield stress hits it, and the floor
arguably belongs in the model.
Verified: new test_1057 (advertisement, control contract, tangent per model, refusal,
and an end-to-end march), plus yield-smoother, VEP stability, VE Stokes, has_solution
and multigrid regressions (34 passed).
Underworld development team with AI support from Claude Code
…NaN correction Record what Layer 2 actually became: solve(homotopy=True) is the entry point, the model-owned delta setter is what keeps delta from being reset by a later _get_yield_softness(), the march reverts and retries a failed delta before settling, and a VEP model's timestep is forwarded to the inner solves. Correct a claim in the design and in the skill's trap list: starting delta LARGE does not, on its own, make a cold plastic start safe. The singularity is tau_y/(2 edot_II) inside the plastic viscosity, which is 0/0 at v=0 and NaNs before the soft-min ever combines it with the viscous branch. The strain-rate invariant has to be floored. Underworld development team with AI support from Claude Code
…yielded points)
A cold (v=0) viscoplastic solve died with DIVERGED_FNORM_NAN in yield_smoother=
"powermean", while the hard-Min and sqrt soft-min forms converged. The cause is not a
division by zero in the model: at edot_II = 0 the plastic viscosity tau_y/(2 edot_II)
is +inf, and infinity propagates CORRECTLY through Min and through the sqrt form to
give the viscous branch. It is specific to the power-mean algebra, which computed its
harmonic-mean prefactor as
N = eta_ve * eta_pl / (eta_ve + eta_pl) -> inf/inf = NaN
Written instead as the algebraically identical
N = eta_ve / (1 + f), f = eta_ve/eta_pl
the infinite-eta_pl limit is finite and correct (N -> eta_ve). No strain-rate floor,
no change to any converged result — the yield-smoother accuracy tests are unchanged.
This is NOT only a cold-start concern: eta_pl is infinite at every rigid (unyielded)
point, which is a physically real state in a viscoplastic flow, so the old form could
poison a converged solve wherever a plug formed. A cold start is just the case where
every point is unyielded at once.
Supersedes the strain-rate floor added with the Layer 2 homotopy control, which was
compensating for this bug rather than fixing it; that floor and its TODO(BUG) are
removed.
Regression: test_1057 parametrises a cold viscoplastic solve over all three yield
modes; the powermean case failed (reason -4) before this fix.
Underworld development team with AI support from Claude Code
…t a strain-rate floor The design note and the skill trap list both said a cold plastic start needs the strain-rate invariant floored. Measurement says otherwise: hard Min and the sqrt soft-min cold-start cleanly because eta_pl = +inf propagates correctly to the viscous branch. Only a harmonic mean written as a product over a sum breaks (inf/inf), and the fix is to write it as eta_ve/(1+f). Steer readers away from the floor, which hides the problem, and note that the singularity is a property of rigid/unyielded points rather than of cold starts. Underworld development team with AI support from Claude Code
… vs warm (Layer 1b) Layer 1b, the benchmarked default change. solve(zero_init_guess=...) was True by default, so a solve() in a loop silently re-zeroed the initial guess every iteration and threw away a perfectly good warm start. It is now tri-state: None (default) auto-detect: cold when has_solution is False, warm when True True force a fresh start, discarding any existing solution False insist on warming from the current field values The onus flips from "flag when you have a solution" to "flag when you want to throw one away". Detection is safe by construction: guessing cold when a solution was available costs one iteration from a good starting point, while the harmful direction — warming off stale field data — cannot happen, because has_solution is cleared both by a structural rebuild (remesh / adapt / mesh-mover) and by a diverged solve. Design gates checked before flipping: - Free-surface chain-of-solves (the pattern flagged as most at risk): its repeated bare solve() is a LINEAR Poisson mesh-displacement solve with constant diffusivity, so a warm start cannot change the answer it converges to. - Mover/adapt reset: already covered by has_solution's is_setup hook and its regression test. Measured consequence, and the reason this is a real (if small) behaviour change: warm and cold agree only to the CONVERGENCE TOLERANCE, not bitwise, because an iterative solve stops anywhere inside the tolerance ball and a warm start enters it from a different direction. The difference scales with the tolerance — 2.4e-5 at tol=1e-4, 7.4e-10 at 1e-8, 2.0e-14 at 1e-12 — so a test with a threshold tighter than its own solver tolerance could shift. Verified: core solvers, Stokes cartesian, multigrid, deform/rebuild, solver smoke (50 passed) and the repeated-solve suites where this bites hardest — advection- diffusion cartesian and annulus, VEP stability, VE Stokes, yield smoother, transient Darcy (21 passed, 1 pre-existing xpass on a test its own header marks fragile). Underworld development team with AI support from Claude Code
…claims Mark the design IMPLEMENTED and Layer 1b DONE, with the measured caveat that warm and cold agree to the convergence tolerance rather than bitwise. Remove the remaining places where the skill still said a cold plastic start NaNs unless large delta or a strain-rate floor saves it. Neither is true: eta_pl is simply infinite at zero strain rate and the soft-min carries that to the viscous branch. The trap list already carries the accurate version. Underworld development team with AI support from Claude Code
…ic smooth_max Maintainer ruling (2026-07-26): a negative yield stress is meaningless — tau_y is compared against the second invariant of the stress — so yield_stress_min defaults to 0 rather than the -oo "unset" sentinel. The +/-oo defaults elsewhere in Parameters exist so sympy can cancel an unused term away; that trick is wrong for this one. This closes two defects found by adversarial review: * A pressure-dependent Drucker-Prager yield C + sin(phi)*p goes negative in tension. Hard Min merely hid the resulting negative eta_pl; the power-mean the homotopy switches to raised a negative base to a non-integer power and returned NaN, so solve(homotopy=True) failed on the first residual for exactly the class of model the feature targets. * _apply_floor's rounding scale is relative (delta * floor) and collapsed at a zero floor, so honouring yield_stress_min = 0 gave a zero-width smooth_max — the hard kink with a 0/0 derivative. The floor is applied through uw.maths.smooth_max everywhere, including yield_mode= "min" where eps = 0 makes it exactly Max. That matters for more than smoothness: smooth_max is pure arithmetic on its operands, so the floor stays the symbolic Parameter the JIT routes through constants[] and sympy is never asked for an ordering test. sympy.Max(<UWexpression>, x) cannot resolve that comparison and recurses until the stack dies — the fragility noted when the DP sentinel guard was fixed, which the old -oo default had merely hidden by skipping the floor. Turning the floor on by default made it fire on every yielding model, so it had to be fixed rather than dodged. Guards now test the DISABLING sentinel (!= -sympy.oo) instead of != 0, which would have skipped the floor at the new default. That also fixes the VEP and TI-VEP models, where the != 0 guard silently ignored a user-set yield_stress_min = 0. Left open, marked TODO(DESIGN): a genuinely rounded tension cap (Griffith / parabolic) needs an ABSOLUTE stress scale, which _apply_floor's signature cannot supply. At floor = 0 the cutoff is therefore still a corner, and eta_pl is exactly zero in tension. Regression: test_1057 asserts the zero default and that a yield stress driven negative by tension leaves the viscosity finite in both hard-Min and power-mean modes. Underworld development team with AI support from Claude Code
…motopy march Review report added at docs/reviews/2026-07/nonlinear-solver-homotopy-adversarial-review.md (four independent adversarial reviewers plus an author pass; every finding re-verified against source). Codes below refer to it. C1 - iteration budgets are real again. entry_maxit/step_maxit were pushed through petsc_options, which SNES_Stokes_SaddlePt.solve overwrites with its hardcoded snes_max_it=50 via setFromOptions immediately before solving, so both documented knobs were silent no-ops and the residual-guided step logic was comparing the real iteration count against a budget that never applied. They now go through the same _difficulty_probe / _difficulty_max_it hook estimate_difficulty uses, which is applied after setFromOptions and restored in a finally. C2 / M2 - a reverted iterate is a CONVERGED iterate, so the march now says so. The failed inner solve cleared has_solution, so the retry's zero_init_guess resolved cold and re-solved a sharper delta from zero, defeating the revert entirely; and a march that settled through the failure exit handed back a good solution with has_solution=False, so the caller's next solve threw it away. C3 - refuse the march on a stress-history (VEP) solver. Each delta-step re-enters the full time-integration tail, so an N-step march advanced the elastic stress history by N*dt for one requested dt. Failing loudly beats silently corrupting the history; the interaction needs designing before VEP can use this. M1 - zero_init_guess is resolved AFTER the _force_setup block in all four solve bodies. Resolving first meant solve(_force_setup=True) warm-started off the has_solution flag that the very same call was about to clear. M3 - the entry solve can warm-start again: has_solution is read before the rebuild that clears it, instead of one line after. M4 - the march is wrapped in try/finally, so an exception no longer leaves the tangent, the iteration budget and the model's delta in march state. M5 - consistent_jacobian is restored to the caller's value. m1, m3, m5, m6 - pprint no longer prints a stray rank argument (the retired pprint_old signature); the retry log names the delta that actually failed; delta0 is validated; and max_steps caps a march that keeps easing off. Tests strengthened where the review showed they would pass with the feature deleted: the march must now descend (steps > 1, settled < delta0, reached_dmin) rather than merely run once; _force_setup cold-start is driven through the public solve(); and new cases cover the tangent restore and the VEP refusal. Still open from the review: M6 (power-mean cannot reach exact Min - docs overstate it), M7 (TI-VEP ignores yield_smoother), M9 (Newton tangent at zero strain rate), the remaining minors, and parallel coverage. Underworld development team with AI support from Claude Code
M6 - MEASURED AND MOOT, as the maintainer predicted. The power-mean's sharpness s = 1/(delta + 0.001) saturates at 1000, so it is not exactly Min at delta = 0. What that costs in practice: on a 45%-yielded box (a case a cold hard-Min solve cannot solve at all), the exact hard-Min residual at the settled power-mean solution is 6e-10 of the initial residual, an order of magnitude INSIDE the 1e-8 solver tolerance -- and a hard-Min solve warm-started from it converges in 0 nonlinear iterations, i.e. PETSc already considers the exact problem solved. The docstring now states the limit honestly along with the measurement instead of claiming exactness. Two things worth recording from getting there. Sharpening the law does NOT help: with the floor at 1e-6 (s up to 1e6) the achievable residual got WORSE, because the law becomes too stiff to solve before it becomes more accurate. And an earlier reading of this measurement said the opposite -- it normalised by the last warm solve's initial residual, which is itself tiny, instead of the physical ||F(v=0)||. M7 - transverse-isotropic VEP now uses the same yield envelope as the isotropic models. Four sites reimplemented the harmonic / soft-min / hard-Min choice inline, hardcoding the sqrt family and reading delta as a baked float, so yield_smoother was silently ignored there and delta could not be ramped without a recompile. They all call _combine_yield now, and the model's yield_softness setter keeps the constants[] atom in step. M9 - the design requirement is met rather than approximately met. A viscoplastic Jacobian is NaN at zero strain rate (the residual survives, its derivative does not), so the warm/cold machinery must make that state unreachable. It covered an explicit cold start but not a nominally WARM solve whose solution had never been written -- the design's stated "secondary signal", never implemented. _solution_is_trivially_zero supplies it, using the collective vector norm so every rank decides alike. The "continuation" tangent needs no guard: it opens on a Picard stage by construction. The M9 test is verified non-vacuous: with the previous guard restored, the zero_init_guess=False case dies inside PETSc's KSPSetUpOnBlocks on the NaN operator. Also proven for the first time, and the reason these were reachable at all: the homotopy had never been exercised on a genuinely yielding problem. Earlier cases (including my own tests) sat at 0% yielding -- a uniform body force in a box is just hydrostatic. On a properly sheared box, solve(homotopy=True) converges at 45% yielding where a cold hard-Min solve gives DIVERGED_MAX_IT, which is the feature doing its job. Underworld development team with AI support from Claude Code
m2 - a diverged LINEAR rotated free-slip solve no longer records success. The linear
helper returns only ksp_reason (a linear solve can fail; _warn_if_ksp_diverged exists
for exactly that), so the .get("converged", True) fallback claimed convergence and the
next solve would warm-start off the bad iterate. It now falls back to ksp_reason > 0,
which is what the sibling _capture_rotated_report already did -- the two diagnostics
had been able to disagree.
m7 - solve(homotopy=True) forwards the per-solve arguments it can honour (timestep,
evalf, order, debug, debug_name, divergence_retries) to every step of the march
instead of dropping them, and REFUSES the two that contradict it: the march decides
cold-vs-warm per step and manages its own warm-up, so zero_init_guess and picard now
raise rather than being silently ignored.
m8 - resolved by C3: the base dispatch's missing solve_kwargs only mattered for a
stress-history model on a non-overriding subclass, and the march refuses those now.
m9 - YieldHomotopyControl and SolveReport are exported from underworld3.systems.
Both are named in public docstrings (the control= parameter, solver.solve_report) and
were deep-import-only, which Charter §6 forbids.
m11 - the Darcy and TransientDarcy solve docstrings described the pre-flip default.
m12 - test_1055_solve_report -> test_1058 (1055 collided with the yield smoother
tests; 1056 was already taken twice, 1058 was genuinely free).
PARALLEL COVERAGE (Charter §11) - ptest_0202 exercises all four layers at np>1, which
nothing did before: has_solution and the tri-state resolution must agree across ranks
or the solve collectives they gate would split (a hang, not a wrong answer); the
homotopy march's branches all derive from collective SNES state; and the new
gmres/sor fixed-cost smoother has to work with the redundant-LU coarse solve, which
test_1014's serial option-string assertions cannot show. Green at np=2 and np=4, with
the SAME settled delta and step count at both -- the march is partition-independent.
Full level_1/tier_a gate: 480 passed.
Underworld development team with AI support from Claude Code
…n the homotopy capability
strainrate_inv_II_min was DECLARED on ViscoPlasticFlowModel but never used -- setting
it had no effect at all, while the elastic models (ViscoElasticPlastic,
TransverseIsotropicVEP) have always applied it. It now enters the plastic viscosity the
same way: eta_pl = tau_y / (2 (edot_II + edot_min)), capping eta_pl at
tau_y/(2 edot_min) and so bounding the viscosity CONTRAST -- the knob the xi-style
regularisation needs, and a prerequisite for testing whether xi can open cases the
delta-homotopy alone cannot reach. Default 0 = off, so the unregularised law is
unchanged.
Two CAPABILITY tests, so the homotopy cannot regress at the user level into "runs
without error but no longer rescues anything":
* test_homotopy_rescues_a_solve_the_cold_start_cannot_do asserts BOTH halves on one
problem -- the cold sharp solve must fail AND solve(homotopy=True) must converge,
with a guard that the fixture is genuinely yielding (>20%) so the comparison cannot
go vacuous.
* test_rate_regularisation_is_wired_into_the_plastic_viscosity would have caught the
ignored parameter above.
The fixtures use a horizontally VARYING body force. A uniform one is hydrostatic --
pressure balances gravity, nothing moves, the strain rate is zero and the yield law
never engages -- which is why every earlier viscoplastic fixture in this file sits at
0% yielding and why the homotopy went so long without being exercised on a case that
actually yields.
Underworld development team with AI support from Claude Code
A working backup of constitutive_models.py was swept into 39be0ab. It is a duplicate of a source file, so it would shadow real hits in greps and pattern scans forever. Underworld development team with AI support from Claude Code
🔍 Architectural Review ChecklistThank you for submitting an architectural review! Reviewers should validate: Design & Architecture
Implementation
Testing & Validation
Documentation
Review Process: See CODE-REVIEW-PROCESS.md Approval: This PR merge = Review formally approved |
|
This is the adversarial review this branch was put through before being flagged ready, posted in full per the standing rule that reviews are public. Four independent reviewers, each given one dimension and told to break the change rather than praise it, plus an author pass. Everything below is fixed on the branch except where marked open. Adversarial review — nonlinear solver warm-start + yield homotopy (4 layers)Branch Method: four independent adversarial reviewers, each given one dimension and Verdict: NOT ready to flag ready. Three critical and seven major defects, most of CRITICALC1 —
|
| δ | 1e-2 | 1e-3 | 1e-4 | 1e-6 | 0 |
|---|---|---|---|---|---|
| s | 91 | 500 | 909 | 999 | 1000 |
constitutive_models.py:989 and :1031 state "Both approach exact Min as δ → 0".
False for the power-mean family: δ=0 gives a finite power-mean (~0.2 % below true
Min), not the sharp surface. Since the entire homotopy premise is "march δ→0 to
reach the sharp yield surface", the contract needs restating: with powermean you
converge to a slightly smoothed yield law. Corollary (minor): the march happily
descends below δ≈1e-4 where nothing changes — a demo run reached δ=2.4e-10 in 9 steps,
and settled_delta then reads far sharper than the law actually is.
M7 — TransverseIsotropicVEPFlowModel advertises the homotopy but ignores half the control
It returns supports_yield_homotopy = True (constitutive_models.py:3800) and
inherits _yield_homotopy_control(), but never calls _combine_yield: its yield law
is inlined (:3485, :3578, :3986, :4049) reading self._yield_softness as a raw
float and hardcoding the sqrt family. So yield_smoother = "powermean" is a silent
no-op there — and the power-mean's large-δ harmonic limit is the stated justification
for the march's well-posed cold entry. Its yield_softness setter also never syncs
_yield_softness_expr, so the control.delta atom handed back is meaningless for
this model.
MINOR
- m1 —
uw.pprint(0, …)(yield_continuation.py:168,187,194) uses the retired
pprint_old(ranks, …)shape; the current signature ispprint(*args, proc=0, …),
so the literal0is printed. Confirmed empirically: every march line reads
0 [yield-continuation] δ=…. - m2 — diverged linear rotated free-slip records success.
pyx:8456does
.get("converged", True), butsolve_rotated_freeslip()returns only
ksp_reason/ksp_its(rotated_bc.py:379), so the default fires even when
_warn_if_ksp_divergedjust warned. Provably inconsistent with the sibling line:
_capture_rotated_reportderivesconverged = reason > 0from the same dict, so
solve_report.converged is Falsewhilehas_solution is True. - m3 — the retry log prints the wrong δ as having failed (
:194recomputes
d/step, which is the last good δ). - m4 —
failuresis a whole-march counter, never reset on success, soretries
is not "per failed δ" as documented. - m5 —
delta0is unvalidated (downis).delta0 <= 0reaches
s = 1/(δ+0.001)and divides by zero at δ = −0.001. - m6 — no cap on δ-steps:
stepis clamped to ≤0.95, so a hostile march can take
~135 solves fromdelta0=1.0todmin=1e-3with nomax_stepsescape (compounded
by C1's 50-iteration steps). - m7 —
solve(homotopy=True)silently dropspicard,divergence_retries,
evalf,order,_force_setup,debug, and evenzero_init_guess. - m8 — two dispatch points with different behaviour:
pyx:8399forwards no
solve_kwargs, so aSNES_Stokes_SaddlePtsubclass that doesn't overridesolve
would give a VEP model inner solves with notimestep. - m9 — Charter §6:
YieldHomotopyControlandSolveReportare named in public
docstrings but not exported fromsystems/__init__(deep-import-only). - m10 — Charter §4: three undocumented exception swallows in
_capture_solve_report(pyx:1239-1250). - m11 — stale docstrings after the flip:
SNES_Darcy.solvestill says
"If True (default)";SNES_TransientDarcy.solvesays "(default True)". - m12 — duplicate test number:
test_1055_solve_report.pyand
test_1055_yield_smoother.pyare both new on this branch;test_1057then skips
1056. - m13 — latent, pre-existing: the
yield_stress_min != 0guard fixed on the DP
model survives atconstitutive_models.py:2030(VEP) and:3534(TI-VEP). Same
class of bug; per Charter §2 it wants a# TODO(BUG):rather than a silent fix.
Test quality (Charter §8) — the review's sharpest hit on the author
Three of the new tests would pass with the feature deleted:
test_0201::test_zero_init_guess_is_tristate_and_auto_detectsasserts the private
_resolve_zero_init_guessback to itself; it never drives the publicsolve()path,
so it is blind to M1 (the_force_setupordering bug) — the exact defect it should
have caught.test_0201::test_repeated_default_solve_agrees_to_solver_tolerancepasses unchanged
if the flip is reverted (two cold solves of a linear Poisson also agree), and never
asserts the second solve was actually warm.test_0201::test_cold_warmstart_under_consistent_newton_convergesclaims to exercise
the automatic Picard branch but uses a linearViscousFlowModel, which converges
cold regardless; it passes with the warm-up line deleted.test_1057::test_solve_homotopy_marches_and_reportsasserts onlysteps >= 1and
settled_delta <= delta0, both satisfied by an implementation that does one solve
at δ₀ and stops — it never asserts the march descended.
No parallel coverage was added for any of the four layers (Charter §11). Layer 3 is
the notable gap: test_default_fmg_bundle_is_parallel_safe is a serial test
asserting option strings, and never exercises the new gmres/norm_type=none
smoother at np>1 where it interacts with the redundant-LU coarse solve.
Constitutive maths — added by the fourth reviewer (most severe of the four)
C4 (CRITICAL) — the power-mean returns NaN wherever the yield stress goes negative
constitutive_models.py:1005-1017. With a pressure-dependent Drucker–Prager
τ_y = C + sinφ·p, tension drives τ_y < 0 ⇒ η_pl < 0 ⇒ f < 0, and a = 1+f,
b = 1+1/f are then negative bases raised to the non-integer power -s ⇒ NaN
(reproduced against the real UW3 expression: η = nan, all Jacobian entries NaN,
where yield_mode="min" returns a finite floored 0.001). The old hard-Min path
degraded gracefully; the new smooth path does not.
This is not a corner case for this feature: _yield_homotopy_control() flips the
model into powermean unconditionally and without checking that the yield stress is
bounded below, so solve(homotopy=True) NaNs on the first residual for any
pressure-dependent DP model that lacks a lower bound — i.e. the exact target problem.
(The Spiegelman driver wraps sympy.Max(C + sinφ·p, 0) by hand, which is why the
hard-case study never hit it.)
C5 (CRITICAL) — _apply_floor(value, 0) has a rounding scale of exactly zero
constitutive_models.py:1088, reached from :1293. The floor is rounded by
ε = δ·floor; with floor = 0 that is smooth_max(τ_y, 0, 0) = ½(τ_y + |τ_y|) —
the exact kink the smooth floor exists to remove, with a 0/0 derivative. Over the
whole region where raw τ_y ≤ 0 the floored value is exactly 0.0, so η_pl = 0,
η = 0, and every Jacobian entry is NaN (reproduced). For a zero floor the new
"smooth" path is strictly worse than the sympy.Max it replaced, which is clean
there. _apply_floor's own docstring admits it "needs a non-zero floor for a length
scale" and nothing enforces it — and the same commit's sentinel change
(!= 0 → != -sympy.oo) is what made yield_stress_min = 0 reachable.
M8 (MAJOR) — δ doubles as the floor-rounding scale, so early march solves run a different model
constitutive_models.py:1088. yield_continuation starts at delta0=1.0 (and the
yield_smoother setter forces δ→1.0 when switching to powermean), giving ε = 1.0·floor
and measured smooth_max(F)/F = 1.500 — the viscosity and yield-stress floors sit up to
50 % above the values the user requested. It converges away as δ→dmin, but it means
the early continuation steps solve a perturbed problem, it is undocumented, and there is
no independent knob for the floor rounding.
M9 (MAJOR) — the cold-start claim is over-stated: the Newton tangent at ε̇=0 is NaN
constitutive_models.py:1124-1128. The comment I added ("no strain-rate floor is
needed … the soft-min carries it correctly") is true of the residual only: measured
at v=0, η = 1.0000003 (finite — the inf/inf fix works) but all five Jacobian entries
are NaN. Partly intrinsic (dε_II/dE = E/(2ε_II) → 0/0), partly added by the
power-mean. It only works today because solve() interposes picard = 1, and that
guard is consistent_jacobian is True — so consistent_jacobian="continuation", or
zero_init_guess=False on an all-zero field, assembles a NaN Jacobian.
This settles the open question I flagged to the maintainer: a strain-rate floor
is legitimately needed — not for the residual (where removing it was right) but for
the tangent.
Corroborated: δ=0 ⇒ exact Min is false for the power-mean (see M6)
Measured deviation from exact Min: 5.2e-4 at δ=0, 1.2e-3 at δ=1e-3. Note the +0.001
floor equals the default dmin=1e-3, so the default march ends at s = 500 — half
the achievable sharpness. The sqrt family is genuinely exact at δ=0; this is
power-mean-only. Six docstrings state it wrongly (:653, :959, :995, :1039
["s = 1/δ", but the code is 1/(δ+0.001)], :1211, :1353).
Verified CLEAN by the same reviewer (against PETSc source)
- The harmonic-mean rewrite is numerically sound.
η_ve·η_pl/(η_ve+η_pl)vs
η_ve/(1+f)agree to ≤1 ulp at (1e26,1e21) both orders, (1e26,1e26), (1e-20,1e26);
the product form only overflows above η≈1e154; the identity is exact. The new form is
strictly better — the only one that survivesη_pl = ∞. My "no numerical change"
claim holds. - The FMG smoother bundle is correct.
gmresregistersKSP_NORM_NONEfor both
PC sides (gmres.c:894);mg_levels_ksp_converged_maxitsis required, not
redundant, and is present —KSPConvergedDefault(iterativ.c:1529) tests it
before theKSP_NORM_NONEearly return, so without itKSPSolve_GMRESwould
hard-setKSP_DIVERGED_ITSand PCMG would flag PC failure. Restart 30 > max_it 4, so
the cycle never restarts. The GAMG delete list does include
mg_levels_ksp_norm_type. The fgmres claim is verified at both configuring sites.
Nit: the comment's "same four smoother iterations" understates cost — GMRES adds ~5
Krylov vectors and Gram–Schmidt per level over Richardson's 2.
Reviewer claim NOT sustained
The reviewer states the power-mean NaN fix "ships no test that would catch a
regression". Not correct: test_1057::test_cold_viscoplastic_solve_survives_zero_strain_rate
parametrises a cold (ε̇=0) solve over all three yield modes and does fail without the
fix. The reviewer inspected test_1055_yield_smoother.py only. Their broader point
stands, though — no test covers the negative-τ_y path (C4) or the zero-floor path
(C5).
What held up
- Parallel correctness of the new control flow — traced clean.
getConvergedReason
is collective and rank-identical, so_resolve_zero_init_guesscannot split ranks;
the march's predicates are all rank-uniform; the revert is inside
synchronised_array_updateon every rank. - Backward compatibility of the tri-state flip — no caller in
src/,tests/,
docs/or the skills passeszero_init_guesspositionally or truth-tests it before
resolution. _record_convergence_status()coverage — reached on every normal exit of all
four solve bodies and the rotated early return; every wrapper funnels through them.- Recursion/reentrancy of
solve(homotopy=True)—solve_kwargsnever carries
homotopy, so the inner solves cannot re-enter the march. - Return-value contract — the dict survives
timing,memprobeand
SNES_Stokes_Constrained.solve(*args, **kwargs). - March arithmetic —
step ∈ [0.05, 0.95]strictly,dstrictly positive and
decreasing,d <= dminreachable,delta0 <= dminexits correctly. The problem is
cost (m6), not termination. - Layers 1a and 3 as such —
has_solution's lifecycle and the FMG smoother bundle
drew no correctness findings beyond the ones listed.
|
✅ Test Suite: success |
CI caught what the level_1/tier_a gate deselected: this tier_b test compares solutions at rtol 1e-12 and 1e-10, but its diffusivity depends on T, so the solve is nonlinear and its answer is only pinned to the SNES tolerance. With zero_init_guess defaulting to warm, the second solve stops at a different point in the same tolerance ball and the comparison fails by ~2e-5. The test is about whether a constant is read from its constants[] slot or baked as a C literal. Holding the initial guess fixed makes it measure that, and makes it independent of the warm-start default rather than accidentally dependent on one. Underworld development team with AI support from Claude Code
|
✅ Test Suite: success |
What this does
A hard viscoplastic Stokes solve fails in a characteristic way: started cold, from a
zero velocity field, Newton has no strain rate to linearise about, the yield law is
singular, and the line search dies. Started from a nearby solution it converges in a
handful of iterations. Until now getting from one to the other was the user's problem,
solved by hand in a driver script each time.
This branch makes the solver do it. Four layers, each usable on its own:
Layer 1a — the solver knows whether it has a solution.
solver.has_solutionis a public read-only flag, set on a converged solve and clearedby anything that structurally invalidates the state (remesh, adapt, mesh movement,
_force_setup). It survives a coefficient change, because a solution at slightlydifferent coefficients is still a good starting point. When a consistent-Newton solve
starts cold, one Picard iteration is interposed first, so Newton never sees a zero
strain rate.
Layer 1b —
zero_init_guessbecomes tri-state.None(the new default) asks the solver: warm ifhas_solution, cold otherwise.TrueandFalsestill force the old behaviours. Users who never touch it get warmstarts where warm starts are correct, and nothing else changes.
Measured caveat, worth knowing: warm and cold agree to the convergence tolerance, not
bitwise (2.4e-5 at tol 1e-4, 2.0e-14 at 1e-12). An iterative solve stops anywhere in the
tolerance ball. A test whose threshold is tighter than its own solver tolerance can shift.
Layer 2 — one-call yield homotopy.
stokes.solve(homotopy=True)marches the yield-law smoothing parameter δ from a smooth,easily-solvable law down to the sharp one, warm-starting each step from the last. The
constitutive model advertises whether it can do this (
supports_yield_homotopy) andhands back a control object naming its own δ atom and its preferred tangent — the same
pattern as
requires_stress_history. Measured on the cold viscoplastic box: δ from 1.0to 7.8e-4 in four solves, from a cold start.
Layer 3 — the FMG velocity smoother defaults to gmres+sor.
Measured on the Spiegelman notch at four multigrid levels: per-V-cycle contraction 0.746
(richardson) versus 0.560 (gmres), and at ξ ≤ 0.003 richardson fails outright where gmres
still returns. The margin grows with multigrid depth — at three levels it is only 5%,
which is why an earlier shallow test wrongly concluded the smoother did not matter.
A real bug, found on the way
The power-mean yield smoother returned NaN at every rigid (unyielded) point, not just on
cold starts. At zero strain rate η_pl = τ_y/(2·0) = +inf, which propagates correctly
through
Minand through the sqrt smoother; the power-mean formed its harmonic mean asη_ve·η_pl/(η_ve+η_pl)= inf/inf = NaN. The fix is the algebraically identicalη_ve/(1+η_ve/η_pl). No floor, no epsilon, no numerical change — verified to ≤1 ulpagainst the product form across the full viscosity range.
This mattered beyond cold starts: η_pl is infinite wherever a rigid plug forms, which is
a physically ordinary state, so the old form could poison a converged solve.
Review
This branch was put through a four-reviewer adversarial review before being flagged
ready. It found 5 critical, 9 major and 13 minor defects, most of them in the new code
— including three new tests that would have passed with the feature deleted. All of them
are fixed on the branch. The full report is in
docs/reviews/2026-07/nonlinear-solver-homotopy-adversarial-review.mdand is reproducedas a comment on this PR.
Two items are recorded as open rather than fixed, with maintainer rulings noted in the
report: the power-mean cannot reach exact
Min(sharpness saturates at 1000), and arounded tension cap needs an absolute stress scale that
_apply_floorcannot supply.Testing
level_1 and tier_agate green.test_0201—has_solutionlifecycle and the tri-state resolution, driven throughthe public
solve()path (the review's sharpest hit was that the first version ofthis test asserted a private helper back to itself).
test_1055— yield-smoother families and the δ→0 limit.test_1057— the homotopy. Asserts both that the cold solve fails and that themarch converges, with a yielding-fraction guard, so it cannot rot into "runs but
rescues nothing". Parametrised over all three yield modes for the NaN regression.
test_1058— solve reporting.tests/parallel/ptest_0202— np=2 and np=4 give identical settled δ and step count.Note on scope
The branch was rebased onto current
development(which had since merged #377 and itsreview follow-up #437); the rotated-free-slip path now takes its warm-start verdict from
_capture_rotated_report, which #437 made the single source of truth for that path.A
constitutive_models.py.bakcopy had been swept into one commit by accident and isremoved.
Underworld development team with AI support from Claude Code