# Gradients: the composed adjoint
The objective is a scalar function of hundreds of design variables, so the
gradient is computed in reverse mode: one adjoint solve per solver per
evaluation, whatever the number of variables. Three different "adjoints" are
chained, each living where its solver lives.
```mermaid
flowchart TB
subgraph r1[" "]
direction LR
a["θ → filter H → N(θ)
JAX"] -- "N" --> ct["ChargeTransport · Julia
drift-diffusion, 0 V and −5 V
adjoint: Jᵀλ = −g"]
ct -- "n, p" --> b["Soref–Bennett → mesh transfer
JAX"]
end
subgraph r2[" "]
direction LR
gy["gyptis · FEniCS
eigensolve A x = λ B x
∂λ/∂ε = yᴴ(∂A − λ∂B)x / yᴴBx"] -- "n_eff²" --> c["J = Δn_eff − w·α
JAX"]
c -. "jax.grad: same chain, reversed" .-> g(["∂J/∂θ"])
end
r1 -- "ε per design cell" --> r2
style r1 fill:none,stroke:none
style r2 fill:none,stroke:none
```
`jax.grad(J)` walks this chain backwards. Each Tesseract call enters the JAX
program through [tesseract-jax](https://github.com/pasteurlabs/tesseract-jax)'s
`apply_tesseract`: its forward is the component's `apply` endpoint and its
backward is the component's `vector_jacobian_product`, with `abstract_eval`
reporting the output shapes at trace time; everything between the two solvers is
ordinary JAX autodiff.
## Discrete adjoint of the drift-diffusion solve
At each bias the solver finds the state $x = (\psi, \varphi_n, \varphi_p)$ of
the finite-volume residual $F(x; N) = 0$ and reads out the carrier densities
$n(x), p(x)$ per node. Given the cotangents $\bar n, \bar p$ arriving from
downstream, the Tesseract's VJP returns $\bar N = \mathrm{d}J/\mathrm{d}N$:
1. **State gradient.** $g = (\partial n/\partial x)^\top \bar n +
(\partial p/\partial x)^\top \bar p$, assembled per DOF by differencing the
(smooth, local) density read-out.
2. **Adjoint solve.** $J_F^\top \lambda = -g$ with $J_F = \partial F/\partial x$
the assembled Jacobian VoronoiFVM already builds for Newton. The Ohmic
contacts are enforced by a $10^{30}$ Dirichlet penalty on their rows, which
makes the full Jacobian's condition number $\sim 10^{42}$; those rows are
eliminated first (the constrained state is known exactly) and the physical
block is factorized with partial pivoting. The sensitivity of the pinned
DOFs re-enters through the well-scaled coupling column
$\lambda_\mathrm{free}^\top J_F[\mathrm{free}, c]$.
3. **Explicit doping term.** Doping enters the bulk residual only through the
Poisson source, so $\bar N_i \mathrel{+}= \lambda_{\psi,i}\, q\,
\lambda_1\, |\Omega_i|$ (node volume) — a contracted analytic derivative, not
a residual finite difference.
4. **Contact path.** Out of equilibrium the contacts are held at
$\psi = \psi_\mathrm{eq} + U$, and $\psi_\mathrm{eq}$ is itself an output of
the equilibrium solve — a local function of the doping at the contact node
through charge neutrality, $\mathrm{d}\psi_\mathrm{eq}/\mathrm{d}N =
1 / (\mathrm{d}/\mathrm{d}\psi \sum_\alpha z_\alpha n_\alpha)$
($-U_T/(n+p)$ for Boltzmann statistics). At reverse bias the rib is depleted
and this path carries most of the gradient; dropping it leaves the composed
gradient wrong by more than half.
The adjoint is taken at the warm-started converged state the forward returned,
and the sign/unit conversion between PRISMO's donor-positive $\mathrm{cm^{-3}}$
doping and the solver's acceptor-positive SI convention is undone on the way
out. Two forward solves and two adjoint solves per iteration (0 V and −5 V);
the loss term rides on the 0 V adjoint at no extra cost.
## Hellmann–Feynman eigen-adjoint
For the generalized eigenproblem $A(\varepsilon)\, x = \lambda\, B(\varepsilon)\, x$
with right eigenvector $x$ and left eigenvector $y$ ($y^H A = \lambda\, y^H B$),
the first-order change of the eigenvalue with a per-cell permittivity
$\varepsilon_c$ is
$$
\frac{\partial \lambda}{\partial \varepsilon_c} =
\frac{y^H \bigl(\partial A/\partial\varepsilon_c - \lambda\, \partial B/\partial\varepsilon_c\bigr) x}
{y^H B\, x},
\qquad
\frac{\partial (n_\mathrm{eff}^2)}{\partial \varepsilon_c} = \frac{1}{k_0^2}\,\frac{\partial\lambda}{\partial\varepsilon_c}.
$$
The gyptis Tesseract recovers $x$ and $y$ from **one** two-sided SLEPc solve
(shared by forward and adjoint), and assembles the numerator for *all* design
cells in a single pass by testing the $\varepsilon$-derivative density of the
weak form against a DG0 test function — no per-cell loop, no re-solve. The
result is the field $w_c = \partial(n_\mathrm{eff}^2)/\partial\varepsilon_c$ on
the design cells; the VJP multiplies it by the incoming scalar cotangent. The
same $w_c$, evaluated once at the uniform background, is the mode-overlap
weight of the modal loss.
pyadjoint does not tape the SLEPc eigensolve, which is why this adjoint is
written by hand. The shift-invert transform behind the solve is factorized with
a pivoting direct solver (UMFPACK): the assembled system is an indefinite
saddle point, and PETSc's native non-pivoting LU left a $2\times10^{-3}$
relative noise floor on $\Delta n_\mathrm{eff}$ that the adjoint could not
explain and the optimizer could not descend through. With pivoting the
eigenvalue is smooth along a design line to $10^{-13}$ and the noise floor is
$2\times10^{-11}$.
## The JAX glue
- **Density filter**: a dense $(n_\mathrm{design}, n_\mathrm{design})$ matrix;
its VJP is the transpose, written as a `custom_vjp` so the matrix is not
traced.
- **Doping map** $N(\theta)$: custom JVP so the derivative is the even,
continuous $N_\mathrm{ref}\ln 10\, s\, 10^{s|\theta|}$ at the junction too.
- **Soref–Bennett**: the signed power $\operatorname{sign}(x)|x|^p$ gets a
custom VJP ($p|x|^{p-1}$, set to zero at $x = 0$ where $p < 1$ would blow
up) so depletion differentiates cleanly.
- **Mesh transfer**: a constant restriction matrix (1/3 per vertex, zero elsewhere).
- **Design nodes**: only silicon nodes carry $\theta$; the filtered field is
scattered into full node order before the doping map, so the transposes are
plain gathers.
## Validation
`prismo validate-gradient` (`make validate-gradient-containers`) compares the
composed adjoint's directional derivative $\nabla J \cdot d$ against central
finite differences $[J(\theta + h d) - J(\theta - h d)] / 2h$ along sampled
unit directions $d$, for $h \in \{10^{-2}, 10^{-3}, 10^{-4}\}$, and gates on a
stated relative tolerance. Across the real Julia + FEniCS boundary the error
follows the $O(h^2)$ slope to $\approx 2\times10^{-6}$ at $h = 10^{-3}$ before
finite-difference round-off takes over (the drift-diffusion adjoint reads the
density through an internal $10^{-8}$ difference, and the eigensolver carries
its tolerance). `--cold` resets the ChargeTransport worker before every
evaluation so no finite-difference sample inherits another's Newton start.
`prismo probe-objective` is the complementary smoothness gauge: it samples
$J(\theta_0 + t\,d)$ at uniform spacing, fits a quadratic, and reports the fit
residual, the white-noise amplitude implied by second differences, and the
adjoint's slope against the fitted one. A kink shows as a structured residual
that shrinks with the spacing; an evaluation noise floor shows as a white
residual that does not.