Conversation
9d25a65 to
3185edb
Compare
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 Adjoint machinery (
|
| 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.
dd7596d to
2848d87
Compare
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
2848d87 to
6c3a976
Compare
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
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
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
adjoint_solvetransposes against the assembled Jacobian viaKSP.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_ofassembles ∫ e φⱼ on the solver's own constrained space as a generic solver's residual at zero — no linear solve.sensitivitydifferentiates the residual through the constitutive model's own symbol: the residual holds\upkappawhose value is the user'skappa, anduw.function.derivativesubstitutes constants' values before differentiating, so both naive routes give zero._peel_exceptexpands every named expression except the one being differentiated against."continuation"the adjoint assembles at α = 1.Over a whole run
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/F1gets(∂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, andtranscript_adjoint_segmentsreads the verdicts back as the strong/weak-constraint partition of the assimilation window.The default tangent is Newton
consistent_jacobian = Trueis 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_1057now sets Picard explicitly on its cold half); and the cold-start "warm-up" on the saddle-point path degraded a linearksponlysolve under Eisenstat–Walker (12% vs 2% on the spherical-shell Nitsche response) — now skipped forksponly, and honestly described: it is annrichardsonresidual sweep, not a Picard step, andpicard=-1switches it off.Verified
Every case against central finite differences:
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
fba44975and 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 secondadjoint_solverefusing 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