Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions docs/developer/design/jacobian-consistent-tangent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Consistent Jacobian tangent for nonlinear (viscoplastic) solves

## The bug

The SNES Jacobian assembly in `src/underworld3/cython/petsc_generic_snes_solvers.pyx`
formed the `G0`–`G3` blocks by calling `sympy.derive_by_array` / `sympy.diff` on the
residual flux `F1` **while the effective viscosity was still a wrapped `UWexpression`
atom**. `derive_by_array` therefore treated the viscosity as a constant, so the term
`∂η/∂(grad v)` was **silently dropped from every Jacobian**.

Consequence: UW3 viscoplastic Stokes was running an accidental **Picard /
defect-correction** tangent, not full Newton — not by design, but because the unwrap
happened *after* the derivative instead of *before* it. Constant-viscosity problems were
unaffected (η has no `grad v` dependence), which is why the entire constant-viscosity
suite passed bit-identically and the bug stayed hidden behind the "≈20 Picard iterations
is intrinsic" folklore.

## The fix (this PR — opt-in, default-off)

A new unwrap mode and a gate, so the default path is **bit-identical** to the historical
(Picard) behaviour and the consistent tangent is **opt-in**.

- **`symbolic_keep_constants` unwrap mode** (`function/expressions.py`): expands every
`UWexpression` atom down to — but **not including** — truly-constant atoms (η₀, τ_y, …),
which stay as symbols so the `constants[]` runtime-update mechanism survives. The
"stop at constant" predicate is the *same* `_is_truly_constant` that `getext()`'s
`_extract_constants` uses, so the kept-symbolic set and the `constants[]` set provably
cannot drift (drift-guard test).

- **`solver.consistent_jacobian`** (`SolverBaseClass`), one of:
- `False` (**default**) — differentiate the flux *as wrapped*: frozen viscosity, the
historical Picard tangent. **Bit-identical** to prior behaviour.
- `True` — unwrap before differentiating: full consistent Newton tangent
(`Min → Heaviside` yield switch appears).
- `"continuation"` — Picard → Newton. Blend `J(α) = J_picard + α·(J_newton − J_picard)`
with `α` a `constants[]` parameter ramped 0 → 1 at solve time (`_continuation_solve`):
Picard locates the basin, Newton sharpens inside it. Because `α` lives in `constants[]`,
switching costs **no JIT recompile**, and `α=0` is bit-identical to Picard.

- **Model-owned smooth-tangent hook** `Constitutive_Model.flux_jacobian` (default `None`):
a model may supply a smooth surrogate flux for the *Jacobian only* while the residual
keeps the exact law. Consumed via `_newton_flux`, which is **guarded** so the default
(Picard) path never even evaluates it.

The residual is never routed through the new path, so a converged solution always satisfies
the exact constitutive law regardless of the tangent used.

### Non-regression evidence
- Constant-viscosity Jacobian is **symbolically bit-identical** (0/N blocks change).
- `test_1010`, SolCx `test_1015`, `test_0610`, the asymmetric-Jacobian guard, units (64) all
pass; `level_1 tier_a` 225/225.
- Crash-isolated (`--forked`) `level_2 tier_a` failure set is **identical to pristine
`development`** — the pre-existing reds (`test_1012` gmsh crash + 3 `test_1052` VEP) are
not introduced here. (Creating the continuation `α` lazily was required to avoid a global
symbol-counter perturbation that flipped two flaky VEP variable-dt tests.)

## Why a smooth Jacobian on a sharp residual is *not* the fix (tangent hierarchy)

For a hard-`Min` yield residual there is a strict ordering of tangents:

1. **Picard** (η frozen, `J = η·∂E/∂L`) — not the true Jacobian, but a *contractive*
defect-correction operator whose fixed point **is** the `Min` solution. Slow (linear)
but globally stable.
2. **Consistent Newton** (exact `Heaviside` tangent) — fast near the solution; the kink
discontinuity breaks the line search far from it.
3. **Inconsistent smooth Jacobian** (harmonic tangent + `Min` residual) — the consistent
tangent of a *different* (harmonic) problem; it points at the wrong solution and has
neither Picard's contractivity nor Newton's consistency. **Worst of the three** — it
diverges *more* than Picard on hard-yield VEP (measured 8/15 vs Picard 3/15 on the VEP
loading test).
4. **Full smooth** (smooth residual *and* Jacobian) — consistent and smooth, converges, but
solves a *smoothed* problem (different physics).

So "exact residual + smooth Jacobian" is option (3) — appealing but mathematically a dead
end. This is why the model-owned harmonic `flux_jacobian` override is **not** in this PR.
The valuable, well-posed part — the consistent tangent and the Picard→Newton continuation —
helps the *non-elastic* viscoplastic Stokes it was built for (the shear box converges via
continuation).

## Successor work (separate PR): δ-homotopy + yield-law unification

The robust route for hard-yield convergence is **problem-space** continuation, not tangent
tricks: ramp the *residual* smooth → sharp.

- Use the softmin softness **δ** as the homotopy parameter. softmin at **δ=0 is identically
`Min`** (`g(f) = max(1, f)` exactly), and is C∞ for δ>0. So ramping δ → 0 gives a
differentiable path whose endpoint has **zero physics change** (exact yield law) — unlike a
yield-stress staircase, which moves the target.
- Empirically: solving smooth-first then warm-starting the sharp solve converges the hard
VEP loading test **0/15** (vs cold 3/15).
- Production form mirrors the `α`-continuation: make δ a `constants[]` parameter and ramp it
within one solve (per-iteration via the `add_update_callback` / `SNESSetUpdate` hook), so
there is one compiled problem, one BDF-history update per step, and no recompile.
- Unify the yield-mode "zoo": collapse `softmin`/`smooth`/`min` into one δ-parameterised law
(δ=0 default = exact `Min`; δ>0 = a *controlled* smooth-min), keep `harmonic` as a distinct
physical model, drop the redundant `smooth` formula. "Smooth-min" then means *incomplete
blending* (final δ>0), not an ad-hoc surrogate with simulation-dependent deviation from τ_y.

That work depends on this PR's `constants[]`-ramp machinery, so it lands after.
29 changes: 29 additions & 0 deletions src/underworld3/constitutive_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,26 @@ def flux_1d(self):

return uw.maths.tensor.rank2_to_voigt(flux, dim=self.dim)

@property
def flux_jacobian(self):
"""Optional smooth surrogate flux for Jacobian assembly.

Returns ``None`` by default, meaning the solver differentiates the
exact :attr:`flux` (the Newton fix unwraps it first; a generic Min/Max
kink-smoothing fallback then rounds any remaining yield kink).
Comment on lines +580 to +582

A model whose flux has a non-smooth yield kink (e.g. hard-``Min``
viscoplasticity) may override this to supply a physically-motivated
*smooth constitutive law for the tangent only*. The residual still uses
the exact :attr:`flux`, so the converged solution satisfies the true
constitutive law — only the Newton search direction is smoothed, giving
a robust, line-search-friendly tangent without changing the answer.

Shape must match :attr:`flux` (the solver substitutes it for ``F1`` when
forming the velocity-gradient Jacobian blocks).
"""
return None

def _reset(self):
"""Flags that the expressions in the consitutive tensor need to be refreshed and also that the
solver will need to rebuild the stiffness matrix and jacobians"""
Expand Down Expand Up @@ -1690,6 +1710,15 @@ def viscosity(self):
else:
return effective_viscosity

# NOTE: a hard-Min smooth-tangent override (flux_jacobian = harmonic) was
# prototyped here but deferred to the yield-law / δ-homotopy follow-up. The
# smooth-Jacobian-with-Min-residual tangent is inconsistent (it is the
# consistent tangent of the *harmonic* problem) and converges WORSE than
# Picard on hard-yield VEP; the robust route is problem-space homotopy
# (ramp the softmin softness δ→0), not a smooth tangent. See the design doc
# docs/developer/design/jacobian-unwrap-constants-bug.md. The generic
Comment on lines +1718 to +1719
# Constitutive_Model.flux_jacobian hook (default None) remains available.

@property
def _plastic_effective_viscosity(self):
parameters = self.Parameters
Expand Down
Loading
Loading