Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rotated free-slip never solved
K δ = b, so its adjoint was neverKᵀ μ. 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_adjointowns the sequence, assolve_rotated_freeslipowns the forward one.SNES_Stokes.adjoint_solvedispatches to it whenever_rotated_freeslip_bcsis 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
Qbeing orthogonal. Withμ̂ = Q μandb̂ = 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.zeroRowsColumnscommutes 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.τ − pIagainst+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 inB A⁻ᵀ Bᵀ. Fieldsplit-Schur, the 1/μ pressure mass and the custom-FMG prolongation all apply unchanged.‖Â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
Qis EXACTLY symmetric — the SVD frame for a single normal is the Householder reflection[[nx,ny],[ny,−nx]], and‖Q − Qᵀ‖measures zero. So swappingQforQᵀ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:
zeroRowsdoes not commute with transposition (its transpose iszeroCols); now builds the forward operator and transposes once_build_rotated_custom_Plleakstry/finallyQ, not a live and wrong one; the boundary SET is comparedμis zero in the wall-normal COMPONENT not the rows; rotated nodes are not Dirichlet-zeroed; only the iterative path projects the null spaceThe gradient fixture turns out to have no null space at all — it pins the inner boundary, so
_rotated_nullspacereturnsNone. 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
ctx=None), paying a fullPCSetUpon 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.mpirun -n 2the new test file aborts about half the time in_jitextensionwith "JIT C-source hash differs across MPI ranks". The abort lands in the fixture's FIRST FORWARD solve, before any adjoint runs;test_0021is 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 onlytests/parallel/, so it is not in the gate. Filed separately.Tests
tests/test_0022_rotated_adjoint.py(new, 7 tests). Adjoint suitetest_0018–test_0022and 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