Skip to content

The discrete adjoint of a rotated free-slip solve - #751

Open
lmoresi wants to merge 3 commits into
feature/discrete-adjointfrom
feature/adjoint-rotated-bc
Open

lmoresi wants to merge 3 commits into
feature/discrete-adjointfrom
feature/adjoint-rotated-bc

Conversation

@lmoresi

@lmoresi lmoresi commented Sep 16, 2026

Copy link
Copy Markdown
Member

Rotated free-slip never solved K δ = b, so its adjoint was never Kᵀ μ. The forward path rotates into the per-node boundary frame, strikes out the wall-normal rows and inverts Âhat = Q K Qᵀ in its own Krylov loop; the adjoint is the transpose of THAT operator. It was refused for want of a transpose path, which blocked the adjoint for the free-slip this project recommends on any curved or deformed boundary — the annulus and shell benchmarks among them.

rotated_bc.solve_rotated_adjoint owns the sequence, as solve_rotated_freeslip owns the forward one. SNES_Stokes.adjoint_solve dispatches to it whenever _rotated_freeslip_bcs is non-empty. Fault contact still refuses: its rotated operator carries an additive interface tangent whose transpose is not routed into the adjoint.

The algebra

Everything follows from Q being orthogonal. With μ̂ = Q μ and b̂ = Q b, ⟨μ̂, Âhat δ̂⟩ = ⟨μ, K δ⟩ and ⟨b̂, δ̂⟩ = ⟨b, δ⟩, so the rotated adjoint system is the physical one written in the boundary frame, and μ = Qᵀ μ̂ takes the answer back.

  • zeroRowsColumns commutes with transposition (row AND column zeroed, scalar diagonal), so eliminating the constrained rows from Âhatᵀ gives exactly the transpose of the operator the forward solve ran on. The dual of a strong constraint is a strong homogeneous constraint on the same DOFs: the multiplier's wall-normal component is set, not converged to — measured 4.8e-18.
  • The saddle structure survives. UW3 assembles τ − pI against +div u, so the operator is [[A, −Bᵀ], [B, 0]] and its transpose is [[Aᵀ, Bᵀ], [−B, 0]] — the off-diagonal signs swap, the blocks do not move, and the two swapped signs cancel in B A⁻ᵀ Bᵀ. Fieldsplit-Schur, the 1/μ pressure mass and the custom-FMG prolongation all apply unchanged.
  • The null space serves both sides. Measured, not argued: on a free-slip annulus with a power-law TI tangent the admitted modes give ‖Âhat v‖ = 4.8e-17 / 2.3e-10 against ‖Âhatᵀ v‖ = 4.8e-17 / 2.4e-10.

The test, and what it took to make it discriminate

The contract is the gradient against a central finite difference, on an annulus with a power-law transversely isotropic viscosity under the consistent tangent.

The anisotropy is measured, not assumed. With a constant TI viscosity the tangent keeps major symmetry and deleting the transpose entirely still passes — we verified that by deleting it. At the measured 5.7e-2 velocity-block asymmetry, deleting it moves the gradient 1.9% against a 0.2% bound.

The guard on that guard was itself wrong first time round: it read the composite Jacobian, which is structurally non-symmetric for every rheology (2.5e-2 for constant isotropic viscosity) and so proved nothing. It now reads the velocity block — 5.3e-17 isotropic, 6.0e-17 constant TI, 5.7e-2 power-law TI.

Stated in the test rather than left implied: in 2-D with one normal per node Q is EXACTLY symmetric — the SVD frame for a single normal is the Householder reflection [[nx,ny],[ny,−nx]], and ‖Q − Qᵀ‖ measures zero. So swapping Q for Qᵀ is invisible here; it takes a 3-D boundary or a multi-normal corner to pin that axis down, and there is no such test.

Adversarial review before the PR

Six real defects found and fixed in the second commit, each verified first:

Asymmetry guard vacuous read the composite, not the velocity block
Direct-LU transposed the wrong matrix zeroRows does not commute with transposition (its transpose is zeroCols); now builds the forward operator and transposes once
_build_rotated_custom_Pl leaks velocity submatrix + rotated fine prolongation, per call; the forward's is reused
A refusal left the Newton tangent installed our own test drives a refusal, so a later FORWARD solve would silently run Newton; now try/finally
Stale rotation undetected the handle guard caught a destroyed Q, not a live and wrong one; the boundary SET is compared
Docstring claims μ is zero in the wall-normal COMPONENT not the rows; rotated nodes are not Dirichlet-zeroed; only the iterative path projects the null space

The gradient fixture turns out to have no null space at all — it pins the inner boundary, so _rotated_nullspace returns None. Pinning one boundary is the configuration to prefer when a sensitivity is the point, and the docs now say so.

Known, recorded, not fixed here

  • The adjoint builds a fresh KSP/PC every call (ctx=None), paying a full PCSetUp on the one path an inversion calls in a loop. An adjoint workspace cache keyed like the forward's would fix that and the prolongation leak together.
  • Under mpirun -n 2 the new test file aborts about half the time in _jitextension with "JIT C-source hash differs across MPI ranks". The abort lands in the fixture's FIRST FORWARD solve, before any adjoint runs; test_0021 is stable at np=2 and the same expressions outside pytest never diverge, so this is latent JIT non-determinism rather than anything this branch introduced. CI's parallel pass collects only tests/parallel/, so it is not in the gate. Filed separately.

Tests

tests/test_0022_rotated_adjoint.py (new, 7 tests). Adjoint suite test_0018test_0022 and the forward rotated free-slip suite (test_1018, test_1061, test_1065): 75 passed, forward behaviour unchanged.

test_0019's refusal test used rotated free-slip as its stand-in for "refuses"; repointed at fault contact. Also de-duplicates the Picard sentence in the Stokes adjoint verdict, which the base class and the override were each appending.

Underworld development team with AI support from Claude Code

Rotated free-slip never solved K delta = b, so the adjoint was never K^T mu.
The forward path rotates into the per-node boundary frame, strikes out the
wall-normal rows and inverts Ahat = Q K Q^T in its own Krylov loop; the adjoint
is the transpose of THAT operator. It was refused for want of a transpose path,
which blocked the adjoint for the free-slip the project recommends on any
curved or deformed boundary - the annulus and shell benchmarks among them.

rotated_bc.solve_rotated_adjoint owns the sequence, as solve_rotated_freeslip
owns the forward one. Everything follows from Q being orthogonal: with mu_hat =
Q mu and b_hat = Q b the rotated system is the physical one written in the
boundary frame, and mu = Q^T mu_hat takes the answer back. zeroRowsColumns
commutes with transposition, so eliminating the constrained rows from Ahat^T
gives exactly the transpose of the operator the forward solve ran on - the dual
of a strong constraint is a strong homogeneous constraint on the same degrees of
freedom, and the multiplier's wall-normal component is set rather than converged
to (measured 4.8e-18). The block structure and the null space carry over
unchanged, so the fieldsplit-Schur setup, the 1/mu pressure mass and the
custom-FMG prolongation all apply to the transposed operator.

The multiplier is returned modulo the free-slip null space. An enclosed
free-slip domain has an undetermined pressure level and, on an annulus, an
undetermined rigid rotation; the forward fixes the gauge afterwards and the
adjoint has no rest state to fix it against, so that component of b is
projected out.

Reading Q after a reset would have been a use-after-free, not a wrong answer:
the result dict shares Q/Qt with the workspace cache, and
_reset_rotated_solver_cache destroys the cache while deliberately keeping the
dict (its reaction vector is still wanted). Refused by name instead.

Fault contact still refuses - its rotated operator carries an additive interface
tangent whose transpose is not routed into the adjoint.

The test is the gradient against a central finite difference on an annulus with
a power-law transversely isotropic viscosity under the consistent tangent. The
anisotropy is measured, not assumed: with a CONSTANT TI viscosity the tangent is
major-symmetric and deleting the transpose entirely still passes. At the
measured 6.7e-2 asymmetry, deleting it moves the gradient by 1.9% against a 0.2%
bound. What the test does not cover is stated in it: in 2-D with one normal per
node Q is EXACTLY symmetric (the SVD frame is the Householder reflection
[[nx,ny],[ny,-nx]], ||Q - Q^T|| measures zero), so swapping Q for Q^T is
invisible here and wants a 3-D case.

Also de-duplicates the Picard sentence in the Stokes adjoint verdict, which the
base class and the override were each appending.

Underworld development team with AI support from Claude Code
…d vacuous guard

Adversarial review of the previous commit. Six real defects, each verified
before fixing.

The asymmetry guard was VACUOUS. It measured the composite Jacobian, but UW3
assembles the velocity flux as tau - p.I against +div u, so the operator is
[[A, -B^T], [B, 0]] and the composite is structurally non-symmetric for every
rheology: measured 2.5e-2 for CONSTANT ISOTROPIC viscosity, well above the 1e-3
floor the guard was asserting. The guard now reads the velocity block, where the
numbers are 5.3e-17 isotropic, 6.0e-17 constant TI (the frozen tangent keeps
major symmetry) and 5.7e-2 for the power-law TI the fixture uses. Deleting the
transpose still moves the gradient 1.9% against a 0.2% bound, so the conclusion
stands - but it stood on the negative control alone, not on the guard.

The direct-LU path transposed the wrong matrix. zeroRowsColumns commutes with
transposition; zeroRows does NOT (its transpose is zeroCols), and the forward
pins the pressure with zeroRows to keep the B^T coupling. Transposing after the
pin rather than reproducing the pin in transposed form makes this exactly M^T by
construction, for both paths.

_build_rotated_custom_Pl leaks the velocity submatrix and the rotated fine
prolongation on every call - nothing owns them, and the cache only dereferences
the list. An inversion loop paid that per adjoint. The forward's prolongation
depends only on Q and the hierarchy, so it is reused where it exists.

A refusal used to leave the solver switched to the Newton tangent:
_consistent_tangent_for_adjoint sets it and the restore was a bare statement
after the solve. The new path refuses by design, and one of our own tests drives
a refusal - so a later FORWARD solve would silently have run Newton on a solver
configured for Picard. Now try/finally.

Also: refuse when the rotated boundary SET has changed since the forward solve
(the handle guard only catches a DESTROYED Q, not a live and wrong one, and the
is_setup gate is bypassed once the adjoint kernel is installed); put
_rotated_velocity_null_modes back, since an adjoint must not change what the
next forward solve does; correct the docstring claims that mu is zero at the
constrained ROWS (it is the wall-normal COMPONENT), that Dirichlet zeroing
covers rotated nodes (it does not - they keep a tangential multiplier), and that
this function projects the null space (only the iterative path does).

Two measurements that changed what the prose says. The null space DOES serve the
transposed operator - on a free-slip annulus with a power-law TI tangent the
admitted modes give ||A v|| = 4.8e-17 / 2.3e-10 and ||A^T v|| = 4.8e-17 /
2.4e-10 - and there is now a test that measures it instead of arguing it. And
the gradient fixture has NO null space at all: it pins the inner boundary, so
_rotated_nullspace returns None. Pinning one boundary is the configuration to
prefer when a sensitivity is the point, and the docs now say so.

The leak bound moves 1e-12 -> 1e-14: at a forward tolerance of 1e-11 the old
bound was one decade below what a deleted _zero_rows_local would leave, which is
not a baseline. The measured value is 4.8e-18.

Known and recorded, not fixed here: the adjoint builds a fresh KSP/PC per call,
and under mpirun -n 2 the file aborts about half the time in _jitextension with
"JIT C-source hash differs across MPI ranks". That abort lands in the fixture's
first FORWARD solve, before any adjoint; test_0021 is stable at np=2 and the
same expressions outside pytest never diverge, so it is a latent JIT
non-determinism rather than anything this branch introduced. CI's parallel pass
collects only tests/parallel/, so it is not in the gate.

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

1 participant