Skip to content

The discrete adjoint: built into the solvers, driven from the run transcript - #744

Merged
lmoresi merged 28 commits into
developmentfrom
feature/discrete-adjoint
Oct 13, 2026
Merged

lmoresi merged 28 commits into
developmentfrom
feature/discrete-adjoint

Conversation

@lmoresi

@lmoresi lmoresi commented Sep 14, 2026

Copy link
Copy Markdown
Member

What this is

The discrete adjoint, built into the solvers and driven from the run transcript. Stacked on #716, which supplies the step boundary, the snapshots and the record it walks; the diff here is exactly the adjoint additions.

An implicit step is a residual R(u; m) = 0 and the residual is SymPy, so every first derivative is available symbolically: ∂R/∂u is the Jacobian the SNES already assembles, ∂R/∂m and the coupling to every field the residual reads are differentiated, not approximated. The adjoint of one solve is a transpose against that Jacobian; the adjoint of a run is the chain of them in reverse order of the record.

In every solver

b  = -solver.dual_of(T.sym[0] - T_target.sym[0])   # -dJ/dT for J = ½∫(T - T*)²
mu, reason = solver.adjoint_solve(b, target=mu_var) # Kᵀ mu = b
dJ_dkappa  = solver.sensitivity(mu_var, kappa)      # ∫ (∂F/∂κ)·mu, symbolic ∂F/∂κ
  • adjoint_solve transposes against the assembled Jacobian via KSP.solveTranspose. Dirichlet conditions come out homogenised for free: the global vector holds only unconstrained dofs. Stokes does it on the composite (u, p) system.
  • dual_of assembles ∫ e φⱼ on the solver's own constrained space as a generic solver's residual at zero — no linear solve.
  • sensitivity differentiates the residual through the constitutive model's own symbol: the residual holds \upkappa whose value is the user's kappa, and uw.function.derivative substitutes constants' values before differentiating, so both naive routes give zero. _peel_except expands every named expression except the one being differentiated against.
  • If the forward solve used the Picard tangent, the adjoint assembles the consistent tangent itself (a kernel rebuild; the DM and KSP are kept) and puts the Picard kernel back for the next forward. Picard iterations do not spoil the adjoint — the converged state is the same and ∂R/∂u is a function of the state alone; only the compiled kernel was the wrong matrix. Under "continuation" the adjoint assembles at α = 1.

Over a whole run

back   = uw.adjoint.TranscriptAdjoint(model, model.save_state())
result = back.gradient(misfit, parameters=[eta0], fields=[beta])
result["parameters"][eta0]                     # dJ/dη₀
uw.adjoint.inner(beta, result["fields"][beta], dbeta0_dc)   # dJ/dc through β₀

No problem-specific wiring. For each solve, in reverse order of the record: restore the step's snapshot, replay the solves before it, replay it, put each history's input back in the slot the post-solve hook shifted; transpose-solve with the accumulated dual on its unknown; then read the residual to see what the solve depended on. Every other field in F0/F1 gets (∂R/∂f)ᵀμ as a dual on its own space — through its value and, for a field read via its gradient (a Crank–Nicolson step reads κ∇T_old), through the gradient part of the load, assembled as ∫ g₀φⱼ + g₁·∇φⱼ with no integration by parts. A history slot's dual goes to the field it tracks at the previous level; every parameter gets μᵀ∂R/∂m, plus the explicit ∂J/∂m.

Every operator in the transcript carries a verdict, written when it ran: adjoint: {supported, reason}. A rotated constraint (no transpose path through its own Krylov loop), an unconverged solve, a semi-Lagrangian history (interpolation at departure points not materialised), a swarm step whose particle set changed — each refuses with the reason, and transcript_adjoint_segments reads the verdicts back as the strong/weak-constraint partition of the assimilation window.

The default tangent is Newton

consistent_jacobian = True is now the default; Picard is the opt-in, by name, where a hard-yield solve needs it as an entry requirement. The residual is symbolic so the tangent is exact and cheap; Picard converged linearly and left the SNES holding a Jacobian that was not ∂R/∂u for everything downstream. The design note had already named this the intended default and marked it "benchmark before flipping"; the full suite was that benchmark. What it turned up: the sharp-yield cold solve that Picard could not do converges under Newton (test_1057 now sets Picard explicitly on its cold half); and the cold-start "warm-up" on the saddle-point path degraded a linear ksponly solve under Eisenstat–Walker (12% vs 2% on the spherical-shell Nitsche response) — now skipped for ksponly, and honestly described: it is an nrichardson residual sweep, not a Picard step, and picard=-1 switches it off.

Verified

Every case against central finite differences:

case agreement
Poisson, dJ/dκ 1e-4
one SUPG step (non-symmetric K, where a wrong transpose shows) 1e-3
Stokes, constant viscosity 1e-4
Stokes, η(ε̇), consistent tangent / continuation / Picard forward 1e-3
two-solver, two-step run: dJ/dη₀ and the dual on β₀ (θ = 1 and θ = 0.5) 1e-7 measured, serial and np = 2
the sinking-blob example, blob centre as control, through the library Taylor ratio 1.00000 / 0.99997

The example (docs/examples/adjoint/sinker_transcript) keeps its hand-rolled adjoint beside the one-call version, and both Taylor tests give the same gradient to six digits.

Adversarial review

Three reviewers ran probes against the branch (posted on #716, fixed in fba44975 and later): two MPI deadlocks (a rank-local gate before a collective solve; rank-local size checks), a ghost double-count in the dual pairing, the missing explicit ∂J/∂m, sixteen scratch variables leaked per call, a second adjoint_solve refusing after the Picard rebuild, and the warm-up's misdescription. All fixed here except dropping the warm-up, which is a benchmark item in the planning file.

Not in this PR

Semi-Lagrangian schemes still refuse (their two transport operators are not materialised); second-order terms are noted in the design note and not built.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V

@lmoresi

lmoresi commented Sep 15, 2026

Copy link
Copy Markdown
Member Author

Adversarial review — adjoint machinery and the Newton default (2026-09-14)

Two independent reviewers running probes. Findings that survived, and what was done. The fixes were made on the build branch before the split; that history is on archive/timestepping-pattern-history (fixes in fba44975, further in dd928797), and this PR's single commit carries them.

Adjoint machinery (adjoint_solve / dual_of / sensitivity, uw.adjoint.TranscriptAdjoint)

Sev Finding Evidence Status
HIGH _nonzero was a rank-local max gating a collective solve; a misfit supported on one rank's cells deadlocked gradient() np=2, misfit ½|v|²·[y>0.85]: rank 0 solves, rank 1 skips; alive at 100 s vs 10 s serial fixed: reduced over ranks, empty-array safe
HIGH the field-gradient test's dual @ direction was rank-local and counted ghosts np=2: −0.00602 / −0.00614 vs fd −0.010876; owned-dof dot = fd to 7.7e-10 fixed: uw.adjoint.inner() over owned dofs; test passes at np=2
MED gradient() omitted the explicit ∂J/∂m misfit ½η₀|v|²: −0.004824 vs fd −0.002404 fixed, with a test
MED 16 MeshVariables leaked per gradient() call; call time 1.3 → 14.5 s by the sixth vars 5→101 over 6 calls, RSS 335→656 MB fixed: scratch pool; test asserts no growth
MED a second adjoint_solve after the Picard-path rebuild raised "no forward solve" is_setup=False after _restore_tangent fixed: the consistent kernel stays installed until the next forward build
LOW dual-space scratch name collided when the BC set changed without changing its count _dual_SNES_Poisson_29_1 twice fixed: hashed signature
LOW size-mismatch raises were rank-local, before collectives by inspection fixed: decided with an allreduce
LOW AdvDiffusion at θ=0.5 refused ("reads a derivative of psi_star_…") — names the slot, and test_0020 uses θ=1 without saying why _reads on the default solver refusal now names the tracked field and says θ=1 avoids it; the gradient-read term (integration by parts with its boundary part) is an open item

Attacks that failed: token prefix collisions (symbols print braced); a Projection with no BCs (rel 2.3e-9); Vector_Projection through mesh.vector.jacobian (2.4e-9); a parameter two levels deep (2.6e-8); serial gradient() bit-repeatable ×4; state after gradient() is level 0 and the next forward reproduces J to 1e-15; Picard→consistent rebuild cycles identical over 3 rounds.

Newton default (fa83b19d)

Sev Finding Evidence Status
MED the ksponly guard read snes.getType() — the previous solve's type — not the declared option set ksponly after a solve, cold solve: picard = 1 ran fixed: reads petsc_options
MED "a cold start takes one Picard step" is true only on the saddle-point standard path — not scalar/vector solvers, not the rotated/fault-contact path Poisson: no warm-up; rotated: log begins at Newton wording fixed (docstring, CLAUDE.md)
MED the warm-up is an nrichardson residual sweep, not a Picard step: no linear solve, no frozen tangent; 1–12% residual reduction on linear Stokes; the sharp-yield case converges under pure Newton without it (7 its) snes_monitor; 1057 fixture on the rotated path comment corrected; picard=-1 switches it off; dropping it is a planning benchmark item
LOW Stokes_Constrained 2-D annulus with two add_constraint_bc(degree=2) crashes in _scatter_global_to_fields (pre-existing) broadcast (12484,) into (6396,) planning item; own issue

Attacks that failed: linear Stokes newtonls+EW new vs old default identical to 1e-9 on box and annulus; rotated + sharp yield cold: Newton 7 its where Picard did not converge in 50; Poisson κ(T) cold: Newton 8–10 its vs Picard 24–28, no window where the old default converged and the new one does not; continuation unchanged; the homotopy march forces its own tangent.

Also from self-review: the Stokes adjoint verdict called the two-assembly nonlinearity probe on every recorded solve; now the symbolic test, cached until the residual can change.

@lmoresi
lmoresi force-pushed the feature/discrete-adjoint branch from dd7596d to 2848d87 Compare September 15, 2026 00:08
The exact content of the split: adjoint_solve / dual_of / sensitivity on
every solver (composite on Stokes; the consistent tangent assembled for the
adjoint whichever tangent the forward used); uw.adjoint.TranscriptAdjoint,
a reverse driver over the transcript with no problem-specific wiring, with
fields read through their value and their gradient assembled as FEM loads;
uw.adjoint.inner over owned dofs; an adjoint verdict on every recorded
operator and transcript_adjoint_segments; the consistent Newton tangent as
the default with Picard opt-in; the sinking-blob example with its Taylor
test through the library (1.00000); tests 0018-0020, serial and np=2.

The history of how each piece was built and reviewed is on the
docs/timestepping-pattern branch before its split commit (baae081,
258ee6e, fa83b19, 790a97c, 567c8c9, fba4497, dd92879).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
@lmoresi
lmoresi force-pushed the feature/discrete-adjoint branch from 2848d87 to 6c3a976 Compare September 15, 2026 10:43
lmoresi and others added 22 commits September 16, 2026 06:23
uw.adjoint.misfit_duals assembles dJ/df for each field a misfit reads,
through the field's value and its gradient, as one load. A misfit on a
stress or a strain rate reads the velocity through its gradient, and the
driver's misfit dual carried only the value part. The driver now routes
through it.

docs/examples/adjoint/fault_segments: a dipping fault as a weak plane in a
transversely isotropic viscosity, three segments of unknown weak-plane
viscosity, observed through the surface uplift rate and the shear stress
near four points. The adjoint gradient matches central differences to
1.00000 on every segment, and L-BFGS recovers (0.0101, 0.1001, 0.0300)
from a uniform 0.1 in thirteen evaluations. test_0021 is the same check
in miniature.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
… ramp, four strengths

The fault leaves a flat at depth horizontally and steepens on a circular
ramp to sixty degrees at the surface. Signed distance, position along the
fault and the director (vertical on the flat, radial on the ramp) are
exact on each piece. Four segments — flat, lower ramp, upper ramp, near
surface — with true strengths (0.005, 0.05, 0.02, 0.2); the adjoint
gradient matches central differences to 1.00000 on each, and L-BFGS
recovers (0.0050, 0.0500, 0.0200, 0.2001) from a uniform 0.1 in twenty
evaluations.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…ficient per segment

fault_friction.py: the plane yields at tau_y = C + mu p under a gravity
load, with the plane's viscosity the harmonic combination of the bulk
viscosity and tau_y over the resolved shear strain rate, so the residual
is nonlinear in the velocity and the pressure. The adjoint is the
transpose of the consistent tangent, and matches central differences to
1.00000 on all four segments; L-BFGS recovers (0.0500, 0.1500, 0.2500,
0.4000) from a uniform 0.2 in twenty-one evaluations, the flat last.
The plot script takes the data file as an argument.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…at colour for the bulk in the figure

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…ess orientation, or orientation along the surface only

Orientation is a unit vector of the deviatoric strain rate, so there is
no angle to wrap. Noise-free, all three recover the four coefficients to
four figures; what changes is the sensitivity to the flat, 1e-3 of the
near-surface segment's with orientation at the points and the surface,
1e-4 with the surface alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…riangulation

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…ry transcript view

An adjoint solve was already recorded as an event; the log, the chart, the
figure and the flowchart now show it as "adjoint Stokes(v)" in its own
column. The fault example wraps each misfit evaluation in a zero-length
step, so its transcript reads "Stokes(v) > adjoint Stokes(v)" per
evaluation with the twin's truth run and the finite-difference solves
labelled.

The mesh is refined once from a coarser base so the velocity block has a
multigrid hierarchy. Without one it fell back to gamg and hit its
iteration cap on every solve, and the inexact Newton step converged
linearly at a fixed rate — forty-odd iterations cold, twenty warm, the
same with either tangent. With the hierarchy nothing is capped and Newton
is quadratic: six iterations cold, two warm. Evaluations warm-start.

The key prints a vector boundary condition as a tuple, with "free" for an
unconstrained component, and says of a quantity whose value is itself an
expression that it is in the record rather than printing it raw. Both
figures mark the surface observation band as well as the points.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…lver's own KSP

KSP.solveTranspose applies the transpose of the preconditioner, and PETSc
refuses that for a multigrid whose smoother is a one-sided SOR sweep —
which is the velocity block of every Stokes solve that has a mesh
hierarchy. So the fault example's adjoint worked only on the mesh whose
velocity block had fallen back to gamg, and failed the moment the mesh
was refined and FMG could be built. J and P are now transposed
explicitly, the null space carried across, and the same KSP solves the
transposed system forwards, so every preconditioner the forward solve
can use the adjoint can use. The forward operators are put back after.

On the refined mesh the gradient check reads 1.00000 on all four
segments with cold starts, and each recorded step takes about 2.5 s
against 40–60 s on the capped mesh. Warm starts were tried and dropped:
the warm-started Newton stops on its step criterion a little early and
the finite-difference side lost the last digits (0.99976).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…g, so the kernel is not recompiled per value

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
PETSc assembles a Jacobian block from four pointwise kernels, g0..g3,
which the JIT already produces by differentiating the residual. The
transposed bilinear form has the same four with trial and test exchanged:
g0 and g3 transposed on their paired indices, g1 and g2 swapped.
_transpose_kernels does that relabelling on the SymPy matrices, in
PETSc's flat layout; each solver swaps its kernels when _adjoint_kernels
is set (the saddle point swaps uu in place and exchanges up and pu,
registering all four kernels of the transposed (p,u) block); and
adjoint_solve installs the set with a rewire, lets the SNES assemble
K^T at the converged state, and solves it forwards with the solver's
own KSP. Nothing is differentiated again and no matrix is transposed.

test_0022 pins the assembled operator against the explicit transpose:
7e-17 on a non-symmetric SUPG step, 5e-17 on a nonlinear Stokes with
pressure in the viscosity. The gradient tests (0019-0021) pass through
the new path, in half the time.

The explicit-transpose route stays as the fallback for a solver with
boundary Jacobian kernels, whose swap is not written yet — and it is
fixed: Mat.transpose() with no target transposes in place, which had
been transposing the forward Jacobian under the SNES.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…e call, and the adjoint problem in writing

uw.adjoint.gradient(solver, misfit, parameters=, fields=) does the three
steps — the misfit's dual on the unknown, the transposed solve, the
sensitivities — and returns {"J", "parameters", "fields"} like the
transcript driver does for a run; solver.gradient is the method form.
A field control that the residual reads through a history slot (the
initial condition of a transport step) is routed from the slot to the
field, the unknown included. The driver shares the read-detection with
it (field_duals).

Introspection: adjoint_kernels() gives the transposed pointwise kernels
per block; adjoint_templates() writes the adjoint problem in the residual
template language, f0_adj = g0^T mu + g2^T:grad mu, f1_adj = g1^T mu +
g3^T grad mu, per block for a saddle point; adjoint_view() typesets it
in a notebook. test_0023 checks the one-call gradient against central
differences in the diffusivity and in the initial field on a
non-symmetric SUPG step, and the templates against the kernels.

The fault example's evaluation is now stokes.gradient(misfit,
parameters=strengths); its check still reads 1.00000 on every segment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
dual_on(..., boundary=) assembles the facet load int_Gamma v phi_j as a
natural condition of the load assembler with zero volume templates;
misfit_duals and gradient take the same boundary= and integrate J over
it with BdIntegral. So surface observations — the uplift rate along a
free top — are a boundary integral in the misfit, not a band beneath the
surface. A gradient part on a boundary is not assembled yet and raises.

sensitivity adds the facet part of mu^T dR/dm for every natural
condition whose expression carries the parameter: a prescribed traction
or flux is differentiated on its boundary and paired with mu there.

test_0024 checks both against central differences on Stokes with a free
top: the viscosity through a misfit on the top's uplift rate, and a
traction amplitude on the top through a volume misfit. Twenty adjoint
tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…erms are boundary integrals

gradient() takes {None: volume integrand, "Top": surface integrand};
J, the duals and the explicit parameter parts are sums over the terms.
dual_on(boundary=) assembles a gradient part on a facet for a vector
space through the natural condition's flux slot, the one a Nitsche
condition uses for its symmetry term, so a stress orientation read on
the surface is a true boundary integral too. The fault example's uplift
and surface-orientation terms are boundary integrals over "Top" rather
than a Gaussian band beneath it; the check reads 1.00000 on every
segment for all three observation sets.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…g value

L-BFGS-B stops on the absolute decrease of its objective, and a surface
integral of a velocity misfit is a small number: unscaled, the surface-only
orientation inversion stopped after seven evaluations with the deep
segments untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…ves the strain rate

On a traction-free surface the shear strain rate vanishes, so a stress
orientation read there is a sign, and the surface-only orientation case
had been fitting the discrete strain rate's departure from that. The
observation sets are now uplift+stress, the principal-stress orientation
at the five interior points only, and the surface strain rate dv_x/dx
along the top only. All three recover the four coefficients to five
figures (22, 19 and 40 evaluations); the checks read 1.00000.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
objective(x) -> (J, dJ/dx) driven by TAO's limited-memory quasi-Newton
method (lmvm, or blmvm with bounds), its own line search choosing the
step. A few scalar controls are replicated on every rank; the objective's
collective solves keep the ranks in step. Returns the solution, the
iteration count, the converged reason and the (J, x) history. The fault
example takes -uw_optimiser tao|scipy and defaults to tao. test_0025
checks the driver on a quadratic, bounded and not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…res come from it; tao is the switch

Under TAO the uplift-and-stress inversion recovers (0.05, 0.15, 0.25, 0.4)
to machine precision in 26 evaluations against L-BFGS-B's 22 to five
figures; the first trial step overshoots in log space and the line
search recovers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…s for TAO's blmvm

-uw_noise adds Gaussian noise to the observed velocity field at a fraction
of each component's rms, drawn once; every observation set inherits it.
-uw_regularisation adds alpha (log mu - log mu_start)^2 to the scaled
objective; -uw_bounds lo,hi switches the TAO driver to blmvm on the
log-strengths. TAO is the default driver. plot_noise.py draws the
recovered friction against the noise level.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…rior given by its width

The data term is chi-squared/2: the misfit scaled by its expected value
at the truth under the noise (read directly in the twin) times the
number of independent data, the surface nodes and the nodes under the
point weights. The prior term is (log mu - log mu_start)^2 / 2 sigma_m^2
with -uw_prior_sigma in log units. The weight between them is then the
noise and the prior, not a number to tune; which coefficients the data
move is decided by their sensitivities against it. Replaces the Tikhonov
weight on the J/J0-scaled misfit, whose range under ten percent noise
was parts in ten thousand, so that any weight above 1e-4 pinned every
coefficient at the prior.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…n header in the declared units

The listric-fault friction example is now a jupytext percent-format script
with a notebook made from it, named constants ahead of a uw.Params block, and
the orchestration model declared first with three reference quantities: the
depth of 10 km, the bulk viscosity of 1e21 Pa s, and the convergence rate,
8.4 mm/yr, fixed by the one number the Coulomb problem depends on, the
lithostatic pressure at the base over the viscous stress of the shortening.
The nondimensional problem that reaches the solver is the one that ran
before, to every printed digit of the misfit and the gradient check, so the
figures and the numbers in the note stand; the axes now read in km and mm/yr.

The default run was broken: the negative-log-posterior objective went in for
the noise study and was never run at zero noise, where its scale is a
thousand times larger and TAO's first trial step overflows the exponential.
Without noise the objective is J/J0 again, as it was for the published runs,
and a prior without a noise level is refused.

The run header in the transcript, the figure and the table now reports the
reference quantities as they were declared, in their own units, beside the
fundamental scales the record keeps; composite pint unit strings abbreviate
to "Pa s" and "mm/yr", and a quantity declared as an expression of others is
reduced to base units. The units system's status lines print only with
verbose=True, and without emoji.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
A parameter that enters only through an essential datum u|Γ = g(m) gave a
zero sensitivity with nothing said: the volume integrand and the natural-BC
facet part were the only places the derivative was taken. The missing term
is the reaction one, [(Kᵀμ)_Γ + (∂J/∂u)_Γ] · ∂g/∂m over the constrained
degrees of freedom. K_IΓ is not available, since the global vector holds
only the unconstrained dofs and the local residual assembly skips the
constrained rows, so Kᵀμ is assembled on every node as the FEM load of the
adjoint templates on an unconstrained copy of the unknown's space, through
the load assembler the misfit duals already use. For a saddle point the
pressure adjoint enters through the transposed divergence block, so
gradient() keeps it until the sensitivities are taken, and passes the
misfit's own dual on every node, whose constrained rows are the misfit
reading the prescribed values.

Which datum applies where boundaries meet follows PETSc: the closure of
each labelled boundary is constrained, corners included, and the data are
inserted in registration order, so the last boundary wins. A solver whose
boundary tangent the reaction cannot see, a natural condition that reads
the unknown, a rotated constraint, a fault contact or a multiplier,
refuses rather than return a term with a piece missing.

adjoint_kernels() read the installed kernel set, which after an adjoint
solve is already the transposed one; the forward derivatives are kept at
build time and the introspection transposes those.

Checked against central differences on a Poisson wall value and a Stokes
lid velocity with corners (test_0026).

Fixes #762

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…for data on other fields

The transcript driver took each level's sensitivity without the pressure
adjoint or the accumulated dual on that level's output, so a parameter in
a datum of a time-dependent run had no reaction term there (#762). It now
keeps the pressure adjoint until the sensitivities are taken and hands over
the dual on every node, whose constrained rows are whatever later reads
this level's prescribed values. A datum on a field other than the unknown
that reads a parameter refuses instead of being skipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V
…s, and the

expression snapshot

64 commits of development — #697, #716, #753 and #775 among them. Two conflicts,
both the same shape as #716's, because this branch forked from the timestepping
branch before that merge happened.

swarm.py — kept BOTH sides. Two unrelated additions land at the same point:
this branch's _adjoint_support (the particle-set rule: an advection is
adjointable exactly when the count is unchanged across it) and development's
_characteristics_for. Neither supersedes the other.

CLAUDE.md — took development's trimmed version. This branch still carried the
pre-#725 file, so taking its side wholesale would have reverted a 22.9 KB -> 8.3
KB cut. The one ruling it genuinely adds — the consistent_jacobian tangent
policy, and that the rotated constraint is transparent to it — is carried over,
rewritten in the trimmed file's voice and attached to the free-slip ruling it
belongs with.

Also annotated one except-pass the Charter scan flagged in adjoint.py: the
_sync_lvec_to_gvec call is an optimisation, not the write. The values are
already in out_var.vec; a variable class without that method syncs on demand
instead, which is correct and merely later. Caught AttributeError specifically,
so a failure inside the sync still propagates.

Adjoint suite (test_0018-0026) 38 passed, including the nonlinear-continuation
gradient that CI had been failing on. Core band tests/test_00[0-4]*py 292
passed. Deprecated-pattern scan clean.

Underworld development team with AI support from Claude Code
Six conflicts in petsc_generic_snes_solvers.pyx, four in ddt.py, and one
each in __init__.py, model.py and two test files. Most were placement
collisions — this branch adds adjoint members where development added
unrelated ones — and keep both sides. Three were not:

- _jacobian_unwrap: development's signature (expr, route=None) for the
  JIT shared-graph route (#836) supersedes this branch's one-argument
  version, which is dropped. The two new adjoint helpers beside it,
  _transpose_kernels and _solve_transposed, are kept.
- The cold-start warm-up: development's _plan_picard_warmup /
  _snes_solve_with_retries (#794, the Newton default with one automatic
  frozen-tangent step) replaces this branch's hand-rolled nrichardson
  sweep entirely. Taking both would have applied the policy twice.
- The consistent_jacobian docstring takes development's text, which
  states the current default, plus one sentence this branch needs: the
  consistent tangent is the matrix the adjoint transposes.

Two test failures the merge brings to light, both the tests working:

- test_every_history_scheme_declares_a_verdict caught
  ForwardSemiLagrangian, a scheme development added after this branch
  forked, with no adjoint verdict. It now refuses, and says why: the
  forward trace is differentiable in the velocity, but the per-cell
  least-squares fit of the arrivals is not materialised as an operator,
  and which cell an arrival lands in is piecewise constant in it.
- test_a_swarm_step_that_loses_particles_refuses_with_the_count set
  return_coords_to_bounds = None to switch the restore off. On
  development None selects the general facet restore instead, so every
  particle was returned (972 -> 972, measured) and the fixture no longer
  showed the effect it tests. Switched off is now an identity closure,
  and the step is partial (972 -> 482) so the refusal is read off a
  change of dimension rather than an empty swarm.

test_1063_constrained_traction, the one real failure on this PR's last
CI run (SNES reason -3), passes on the merge.

36 adjoint tests pass; test_0030_capability_guides, test_0641 and
test_1057 pass.

Underworld development team with AI support from Claude Code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E87Q7KrpapxeQiLD1RiNXv
lmoresi added a commit to gthyagi/underworld3 that referenced this pull request Oct 12, 2026
Two conflicts, both in ddt.py and both placement collisions: this branch
adds EulerianSUPGPC where development adds CharacteristicTrace, and
development adds IntegrationPointSemiLagrangian and
ForwardSemiLagrangian where this branch adds nothing. Both sides are
kept in full; no behaviour was chosen between them.

Note for whichever of this branch and underworldcode#744 merges second: underworldcode#744 adds
_adjoint_support to _DDtBase and a test that every history scheme
declares a verdict. That method does not exist on development yet, so
nothing is owed here today, but EulerianSUPGPC will need a verdict once
underworldcode#744 is in.

check_test_coverage.py passes.

Underworld development team with AI support from Claude Code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E87Q7KrpapxeQiLD1RiNXv
Copilot AI balanced review requested due to automatic review settings October 12, 2026 19:14

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 Oct 12, 2026

Copy link
Copy Markdown
Member Author

Adversarial review: the merge with development (4649653)

Thirteen hunks across six files. Ten were placement collisions — this branch
adds adjoint members where development added unrelated ones — and keep both
sides. Three were decisions, and taking both sides would have been wrong in
each.

Three places where keeping both sides was the wrong answer

_jacobian_unwrap. Both sides define it. development's signature is
(expr, route=None) for the #836 shared-graph route; this branch's is the
one-argument version from before #823 tier 2. This branch's copy is dropped.
Checked before dropping it: the only caller in the solver passes the route
explicitly (_jacobian_unwrap(expr, route=self._jit_route_in_use())), and the
only one-argument callers are in development's own
tests/test_1066_stokes_jacobian_layout.py, which route=None serves. No
adjoint code calls it. This matters for the adjoint specifically: the adjoint
must transpose the kernels the forward assembly actually produced, so it has
to use whichever route the forward solve used, not a second unwrap of its own.

The cold-start warm-up. This branch carries a hand-rolled nrichardson
sweep: declared = ...; if (picard == 0 and consistent_jacobian is True and declared != "ksponly" and zero_init_guess): picard = 1. development has
_plan_picard_warmup / _snes_solve_with_retries (#794 — Newton default with
one automatic frozen-tangent step). These are two implementations of one
policy. Keeping both would have applied it twice. development's stands and
this branch's block is dropped.

The consistent_jacobian docstring. development's text states the
current default and this branch's does not (it still says "flipped 2026-09").
development's is kept, plus one sentence it does not say and the adjoint
needs: the consistent tangent is the matrix the adjoint transposes, so an
adjoint solve inherits the choice made here. On the first attempt that
sentence landed between the True and "continuation" entries of the
definition list, breaking it; it is now a paragraph after the list.

Two failures the merge brought to light, both tests working

test_every_history_scheme_declares_a_verdict caught a real gap.
ForwardSemiLagrangian is a scheme development added after this branch
forked, and it declared no adjoint verdict — exactly what that test exists to
catch, since the base class refuses by naming the class so an undeclared
scheme cannot pass as either. It now refuses, with a reason read off its own
mechanism rather than copied from the backward scheme: the forward trace is
differentiable in the velocity, but the per-cell least-squares fit of the
arrivals is not materialised as an operator, and which cell an arrival lands
in — and so which fit reads it — is piecewise constant in the velocity.

test_a_swarm_step_that_loses_particles_refuses_with_the_count had stopped
testing anything.
It set return_coords_to_bounds = None to switch the
restore off. On development None selects the general facet restore instead
(the deliberate fix for file meshes, which used to restore nothing at all), so
every particle was returned: measured 972 -> 972, with every particle clamped
to x = 0.99994. The assertion n_after < n_before failed, and would have gone
on failing for a reason that has nothing to do with the adjoint.

Switched off is now an identity closure. The step is also made partial
(972 -> 482 at dt = 0.05, measured) rather than total: with the original
dt = 0.5 an identity closure loses every particle (972 -> 0), and a refusal
read off an empty swarm is not the dimension change the test is about.

This is the fixture-no-longer-shows-the-effect failure mode, and it is worth
naming: the test was green when written, went silently void when a default
changed elsewhere, and only a merge surfaced it.

CI reach

test_1063_constrained_traction::test_constrained_topography_carries_the_augmentation_share
was the one real failure on this PR's last CI run (2026-09-24, SNES reason
-3). It passes on the merge.

What was run, and what was not

Run locally, at HEAD, after rm -rf build/ and ./uw build (a .pyx change):

  • the eight adjoint test files — 36 passed
  • test_1066_stokes_jacobian_layout, test_1067_newton_cold_start,
    test_1068_picard_warmup_is_a_frozen_tangent_step — 28 passed. These
    are the files that target the two dropped blocks directly: 1066 calls
    _jacobian_unwrap itself, 1067/1068 pin the warm-up policy.
  • test_0030_capability_guides, test_0641_wave_c_api_shims,
    test_1057_yield_homotopy_solve (the conflicted test files) — 67 passed
  • test_1063_constrained_traction — 1 passed

Not run locally: the full level_1 and tier_a suite. Three attempts
produced no output and exit 59, which is not a pytest exit code, and the cause
is not diagnosed. It is left to CI rather than reported as a pass. The
targeted runs above cover the three files this merge actually decided
something in; the broad regression check on the rest of the .pyx is CI's.

Issue state

No issue is marked on this merge. The adjoint issues this branch gates (#751
is stacked on it; #763, #766, #769, #773, #787, #804) are not probed here
because they need the branch merged to development before a probe against
development means anything. Per CLAUDE.md they stay as they are until then —
marking them on the word of a merge rather than a probe is what the policy
forbids.

Underworld development team with AI support from Claude Code

The full tier_a run after the merge left one failure:
test_0023_field_realness_and_derivatives::
test_no_solver_differentiates_with_plain_sympy_diff. Development has no
sympy.diff in petsc_generic_snes_solvers.pyx; all five sites were this
branch's adjoint code, and the census test that forbids them arrived on
development after this branch forked.

The rule is not cosmetic. sympy.diff differentiates with respect to
anything that is not a Symbol by swapping in a Dummy with no
assumptions, so a field is complex while it is differentiated: Abs(u-c)
gives sign(u-c) * du/du with the last factor unevaluated, which does not
compile. diff_wrt_field makes the stand-in carry the field's realness.
adjoint_integrand and the Dirichlet-datum sensitivity both differentiate
with respect to wrt, which the docstrings say may be a field, so they
were exposed to exactly that.

Four of the five are drop-in. The fifth, in the nonlinearity probe,
differentiates twice by a Symbol the method makes itself, where
diff_wrt_field delegates straight to sympy.diff; it is converted anyway
so every derivative in the solvers reads the same way and the census
stays a blunt textual check with nothing to audit.

test_0023 and the 36 adjoint tests pass (45 in all). Rebuilt with
rm -rf build/ first, as a .pyx change needs.

Underworld development team with AI support from Claude Code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E87Q7KrpapxeQiLD1RiNXv
@lmoresi

lmoresi commented Oct 12, 2026

Copy link
Copy Markdown
Member Author

Correction and a second finding: the full tier_a suite did run

Two things in the review above are now wrong, and the second is a defect in
this branch.

The suite ran: 1449 passed, 1 failed

The review said the level_1 and tier_a suite produced no output and exit 59
with the cause undiagnosed. The cause is diagnosed and it was mine, not the
code's: setup.cfg's [tool:pytest] sets no testpaths, so
pytest -m "level_1 and tier_a" with no path argument collects from the
rootdir — which in a worktree includes .pixi/envs/ and its thousands of
vendored site-packages tests. Hence no output, a very long run and an exit
code that is not one of pytest's.

With the path given, pytest tests -m "level_1 and tier_a":

1 failed, 1449 passed, 56 skipped, 2116 deselected, 1 xfailed in 978.98s

CI never meets this because scripts/test.sh always passes explicit globs,
never a bare marker selection. A one-line testpaths = tests would retire it;
it is not this branch's territory, so it is left alone and noted here.

The one failure was real: Charter S7

FAILED tests/test_0023_field_realness_and_derivatives.py::
       test_no_solver_differentiates_with_plain_sympy_diff

development has zero sympy.diff( in
petsc_generic_snes_solvers.pyx. All five sites were this branch's adjoint
code, and the census test that forbids them landed on development after
this branch forked — so nothing on either side alone would have caught it.

It is not a style rule. sympy.diff differentiates with respect to anything
that is not a Symbol by swapping in a Dummy carrying no assumptions,
so the field is complex while it is differentiated: Abs(u - c) yields
sign(u - c) * du/du with the last factor left unevaluated, which cannot be
compiled. diff_wrt_field gives the stand-in the field's realness and is
otherwise the same swap.

Two of the five sites were exposed to exactly that. adjoint_integrand(mu, wrt) and the Dirichlet-datum sensitivity both differentiate with respect to
wrt, and both docstrings say wrt may be a field — "for a field, project
it to get the dual on that field". A yield law putting an Abs or a sign of
a field in its flux would have reached them.

Fixed in 33a4b198. Four sites are drop-in replacements. The fifth, in the
nonlinearity probe, differentiates twice by a Symbol the method constructs
itself, where diff_wrt_field delegates straight to sympy.diff; it is
converted anyway so that every derivative in the solvers reads the same and
the census stays a blunt textual check with nothing to audit.

Rebuilt with rm -rf build/ first, as a .pyx change needs. test_0023
plus the 36 adjoint tests pass — 45 in all.

Where that leaves the branch

  • full level_1 and tier_a: 1449 passed, 1 failed, and that failure is now
    fixed
  • the 36 adjoint tests, test_0023, test_1066/1067/1068,
    test_0030_capability_guides, test_0641, test_1057,
    test_1063_constrained_traction: all pass
  • The discrete adjoint of a rotated free-slip solve #751, stacked on this branch, came through the push MERGEABLE/CLEAN with
    CI passing

The tier_a suite has not been re-run end to end since 33a4b198; the
targeted runs above cover what it changed, and CI has the rest.

Underworld development team with AI support from Claude Code

@lmoresi
lmoresi merged commit f414da6 into development Oct 13, 2026
2 checks passed
lmoresi added a commit that referenced this pull request Oct 17, 2026
The one conflict is the Stokes Jacobian-preconditioner wiring: our velocity
block reads _uu_PG2/_uu_PG3, development's adjoint (#744) adds the
transposed (p,u) blocks. The adjoint transposes the uu kernels after the
preconditioner alias was taken, which left the alias on the untransposed,
uncompiled kernels -- a KeyError for any non-symmetric tangent (three
nonlinear cases in test_0019). The alias is retaken after the transpose:
an adjoint solve preconditions with the transposed Jacobian.

Underworld development team with AI support from Claude Code
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