Skip to content

Nonlinear solver: automatic warm start, model-advertised yield homotopy, and an inf-safe power-mean yield law#441

Open
lmoresi wants to merge 21 commits into
developmentfrom
feature/nonlinear-warmstart-homotopy
Open

Nonlinear solver: automatic warm start, model-advertised yield homotopy, and an inf-safe power-mean yield law#441
lmoresi wants to merge 21 commits into
developmentfrom
feature/nonlinear-warmstart-homotopy

Conversation

@lmoresi

@lmoresi lmoresi commented Jul 27, 2026

Copy link
Copy Markdown
Member

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_solution is a public read-only flag, set on a converged solve and cleared
by anything that structurally invalidates the state (remesh, adapt, mesh movement,
_force_setup). It survives a coefficient change, because a solution at slightly
different 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_guess becomes tri-state.
None (the new default) asks the solver: warm if has_solution, cold otherwise.
True and False still force the old behaviours. Users who never touch it get warm
starts 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) and
hands 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.0
to 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 Min and 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 ulp
against 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.md and is reproduced
as 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 a
rounded tension cap needs an absolute stress scale that _apply_floor cannot supply.

Testing

  • level_1 and tier_a gate green.
  • test_0201has_solution lifecycle and the tri-state resolution, driven through
    the public solve() path (the review's sharpest hit was that the first version of
    this 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 the
    march 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 its
review 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.bak copy had been swept into one commit by accident and is
removed.

Underworld development team with AI support from Claude Code

lmoresi added 20 commits July 27, 2026 17:20
…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
Copilot AI review requested due to automatic review settings July 27, 2026 07:39
@github-actions

Copy link
Copy Markdown

🔍 Architectural Review Checklist

Thank you for submitting an architectural review! Reviewers should validate:

Design & Architecture

  • Design rationale is clear and well-justified
  • Trade-offs are documented with alternatives considered
  • System architecture is comprehensible
  • Integration points are clearly identified

Implementation

  • Implementation matches documented design
  • Code quality meets project standards
  • Breaking changes are identified and justified
  • Backward compatibility is properly addressed

Testing & Validation

  • Testing strategy is adequate for the changes
  • Test coverage is sufficient
  • Edge cases are properly covered
  • Performance impact has been assessed

Documentation

  • Known limitations are clearly documented
  • Benefits are quantified with metrics
  • User-facing changes are documented
  • Migration guide provided (if needed)

Review Process: See CODE-REVIEW-PROCESS.md

Approval: This PR merge = Review formally approved

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@lmoresi

lmoresi commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

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 feature/nonlinear-warmstart-homotopy vs development
(16 commits, 2402 insertions; level_1 and tier_a suite is green at 470 passed).

Method: four independent adversarial reviewers, each given one dimension and
instructed to break the change rather than praise it, plus the author's own pass.
Every finding below was re-verified against the source before being reported;
findings the reviewers raised that did not survive verification are not listed.

Verdict: NOT ready to flag ready. Three critical and seven major defects, most of
them in the new code rather than pre-existing. Two of the four layers (1a, 3) look
sound; the homotopy driver (Layer 2) and the default flip (Layer 1b) both need work.


CRITICAL

C1 — entry_maxit / step_maxit are inert; a failing δ-step burns 50 iterations

systems/yield_continuation.py:151 sets solver.petsc_options["snes_max_it"], but
SNES_Stokes_SaddlePt.solve hardcodes snes_max_it = 50
(petsc_generic_snes_solvers.pyx:8474) and pushes it with setValue +
setFromOptions immediately before the real solve (:8543) — the file's own comment
at :8468 documents that this clobbers any user-set value.

Consequences: both documented homotopy_options budget keys are silent no-ops; the
docstring promise that "a tight budget lets a too-hard step abort cheaply" is false
(a failing δ costs 50 Newton iterations, ×3 with the default retries); and the
residual-guided step logic at :175 compares the real nit (cap 50) against a
fictional budget (10/30), so the "ease off" branch mis-fires and drives step
toward its 0.95 clamp. The mechanism that does work is
snes.setTolerances(max_it=…) after setFromOptions (as _snes_solve_with_retries
already does at :1495).

C2 — every retry after a failed δ is COLD, defeating the revert

yield_continuation.py:181 reverts u/p to the last converged state so the retry
can warm-start from it. But the failed inner solve already ran
_record_convergence_status(), setting has_solution = False, so the retry's
solver.solve(zero_init_guess=not solver.has_solution, …) (:155) resolves to
cold — a from-zero solve at a δ sharper than the one that just failed. That is
exactly the regime this module's own docstring says does not converge. The revert is
wasted, the retry is near-guaranteed to fail, and the march settles earlier than it
should.

C3 — homotopy=True on a VEP model advances the stress history once per δ-step

ViscoElasticPlasticFlowModel advertises the homotopy
(constitutive_models.py:2289), and SNES_Stokes.solve forwards timestep into
every inner solve (systems/solvers.py:1478). Each inner solve therefore re-enters
the has_stress_history branch and runs the full time-integration tail —
DFDt.update_pre_solve(timestep), the stress projection, the psi_star shift loop,
and DFDt.update_post_solve(timestep) (solvers.py:1525-1585). An N-step march
advances the elastic stress history by N·dt for one requested dt, and the
driver's revert restores only u/p, not the history, so a failed step leaves the
extra shift in place. test_1057 only exercises the non-elastic model, so nothing
catches it.


MAJOR

M1 — _force_setup=True now warm-starts, inverting its own contract

All four solve bodies resolve zero_init_guess before the invalidation it depends
on (pyx resolve/invalidate pairs 3444/3450, 4494/4497, 5217/5220, 8394/8402):

stokes.solve()                    # converged -> has_solution True
stokes.solve(_force_setup=True)   # resolves WARM off the stale flag, THEN rebuilds

The is_setup setter's own comment names "explicit _force_setup" as a structural
invalidation that must drop the warm-start claim. Before the flip this path was cold.
Fix: resolve after the _force_setup block.

M2 — a march that settles via the failure exit leaves has_solution=False on a good solution

On the retries-exhausted break, the fields are reverted to the settled solution and
δ is reset to settled, but nothing restores has_solution (left False by the
failed solve). The report says converged: True with a real settled_delta, yet the
next solve() auto-cold-starts and discards the continuation result — precisely the
advertised usage (solve(homotopy=True) once, then a time loop). solve_report has
the same problem: it describes the failed δ, not the settled one.

M3 — the march always cold-starts its entry solve

yield_continuation.py:148 runs solver.is_setup = False on the first iteration,
whose setter clears has_solution, so not solver.has_solution on the next line is
unconditionally True. A time loop calling solve(homotopy=True) per step discards
the previous step's converged state every time, contradicting the module's own
comment ("Cold only if there is genuinely nothing to warm-start from").

M4 — no try/finally: an exception mid-march leaves solver and model corrupted

yield_continuation.py:145-203 has no exception handling. If an inner solve raises
(PETSc error, JIT failure, the VEP timestep is None ValueError), then snes_max_it
is left at the march value, consistent_jacobian at control.tangent, the model in
softmin/powermean at the failed δ, and u/p hold the diverged iterate with no
revert.

M5 — unrestored side effects on the user's solver and model

solver.consistent_jacobian = control.tangent (:128) is never restored: a user who
set consistent_jacobian = "continuation" (a supported value) silently gets True
or False forever after. _yield_homotopy_control() likewise leaves
yield_mode="softmin" and yield_smoother="powermean" permanently — a model the user
configured as yield_mode="min" (the ViscoPlasticFlowModel default) comes back
as a power-mean soft-min, so every later plain solve() runs different physics than
asked for, unwarned.

M6 — the power-mean cannot reach exact Min, but the docs say it does

Sharpness is s = 1/(δ + 0.001), which saturates at 1000:

δ 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

  • m1uw.pprint(0, …) (yield_continuation.py:168,187,194) uses the retired
    pprint_old(ranks, …) shape; the current signature is pprint(*args, proc=0, …),
    so the literal 0 is printed. Confirmed empirically: every march line reads
    0 [yield-continuation] δ=….
  • m2 — diverged linear rotated free-slip records success. pyx:8456 does
    .get("converged", True), but solve_rotated_freeslip() returns only
    ksp_reason/ksp_its (rotated_bc.py:379), so the default fires even when
    _warn_if_ksp_diverged just warned. Provably inconsistent with the sibling line:
    _capture_rotated_report derives converged = reason > 0 from the same dict, so
    solve_report.converged is False while has_solution is True.
  • m3 — the retry log prints the wrong δ as having failed (:194 recomputes
    d/step, which is the last good δ).
  • m4failures is a whole-march counter, never reset on success, so retries
    is not "per failed δ" as documented.
  • m5delta0 is unvalidated (down is). delta0 <= 0 reaches
    s = 1/(δ+0.001) and divides by zero at δ = −0.001.
  • m6 — no cap on δ-steps: step is clamped to ≤0.95, so a hostile march can take
    ~135 solves from delta0=1.0 to dmin=1e-3 with no max_steps escape (compounded
    by C1's 50-iteration steps).
  • m7solve(homotopy=True) silently drops picard, divergence_retries,
    evalf, order, _force_setup, debug, and even zero_init_guess.
  • m8 — two dispatch points with different behaviour: pyx:8399 forwards no
    solve_kwargs, so a SNES_Stokes_SaddlePt subclass that doesn't override solve
    would give a VEP model inner solves with no timestep.
  • m9 — Charter §6: YieldHomotopyControl and SolveReport are named in public
    docstrings but not exported from systems/__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.solve still says
    "If True (default)"; SNES_TransientDarcy.solve says "(default True)".
  • m12 — duplicate test number: test_1055_solve_report.py and
    test_1055_yield_smoother.py are both new on this branch; test_1057 then skips
    1056.
  • m13 — latent, pre-existing: the yield_stress_min != 0 guard fixed on the DP
    model survives at constitutive_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_detects asserts the private
    _resolve_zero_init_guess back to itself; it never drives the public solve() path,
    so it is blind to M1 (the _force_setup ordering bug) — the exact defect it should
    have caught.
  • test_0201::test_repeated_default_solve_agrees_to_solver_tolerance passes 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_converges claims to exercise
    the automatic Picard branch but uses a linear ViscousFlowModel, which converges
    cold regardless; it passes with the warm-up line deleted.
  • test_1057::test_solve_homotopy_marches_and_reports asserts only steps >= 1 and
    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 < 0f < 0, and a = 1+f,
b = 1+1/f are then negative bases raised to the non-integer power -sNaN
(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. gmres registers KSP_NORM_NONE for both
    PC sides (gmres.c:894); mg_levels_ksp_converged_maxits is required, not
    redundant
    , and is present — KSPConvergedDefault (iterativ.c:1529) tests it
    before the KSP_NORM_NONE early return, so without it KSPSolve_GMRES would
    hard-set KSP_DIVERGED_ITS and 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_guess cannot split ranks;
    the march's predicates are all rank-uniform; the revert is inside
    synchronised_array_update on every rank.
  • Backward compatibility of the tri-state flip — no caller in src/, tests/,
    docs/ or the skills passes zero_init_guess positionally 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_kwargs never carries
    homotopy, so the inner solves cannot re-enter the march.
  • Return-value contract — the dict survives timing, memprobe and
    SNES_Stokes_Constrained.solve(*args, **kwargs).
  • March arithmeticstep ∈ [0.05, 0.95] strictly, d strictly positive and
    decreasing, d <= dmin reachable, delta0 <= dmin exits correctly. The problem is
    cost (m6), not termination.
  • Layers 1a and 3 as suchhas_solution's lifecycle and the FMG smoother bundle
    drew no correctness findings beyond the ones listed.

@github-actions

Copy link
Copy Markdown

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
@github-actions

Copy link
Copy Markdown

Test Suite: success

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants