You add adjoint differentiation to a pricer, compare its delta with the bumped delta the desk has used for years, and the two disagree. Sometimes in the fifth digit, sometimes by a factor of ten, sometimes one of them is exactly zero.
Neither number is automatically the right one. A bumped Greek is an approximation with an error you choose through the bump size. An AAD Greek is the exact derivative of the code that ran, which is not always the function you think you are differentiating. Almost every disagreement comes from one of five causes, and each leaves a recognisable signature.
The figures below come from one small script: a one-year European call, spot 100, strike 105, 3% rate, 20% volatility, priced in plain Python and recorded with AADC. The price is 7.128064669261754 and the AAD delta is 0.5024132586078933, which agrees with the closed form N(d1) to the last digit but one.
def bs_call(S, K, r, vol, T, m): # m is the maths module: math for floats, aadc.math when recording
d1 = (m.log(S / K) + (r + 0.5 * vol * vol) * T) / (vol * m.sqrt(T))
d2 = d1 - vol * m.sqrt(T)
return S * m.cdf_normal(d1) - K * m.exp(-r * T) * m.cdf_normal(d2)
f = aadc.Functions()
f.start_recording()
S, r, vol = aadc.idouble(S0), aadc.idouble(r0), aadc.idouble(v0)
aS, ar, av = S.mark_as_input(), r.mark_as_input(), vol.mark_as_input()
out = bs_call(S, K, r, vol, T, aadc.math).mark_as_output()
f.stop_recording()
ws = f.create_workspace()
for h, v in ((aS, S0), (ar, r0), (av, v0)): ws.set_val(h, v)
ws.forward(); ws.set_diff(out, 1.0); ws.reverse()
1. The bump size
A one-sided bump has an error proportional to the bump; a central bump, to its square. Make the bump small enough and floating-point cancellation takes over instead. The error of the bumped delta against the AAD delta in our run:
| Bump h | Forward difference error | Central difference error |
|---|---|---|
| 1 | 9.9e-03 | −3.4e-05 |
| 1e-2 | 1.0e-04 | −3.4e-09 |
| 1e-4 | 1.0e-06 | 1.5e-11 |
| 1e-6 | 1.3e-08 | 2.6e-09 |
| 1e-8 | 1.4e-07 | −2.1e-07 |
| 1e-10 | 1.2e-05 | −2.4e-05 |
The signature: the gap changes with h, shrinks, then grows again. If a scan over h brings the bumped value to within a few digits of the AAD value at its best point, the AAD number is right and the bump was the error. Here the central difference at h = 1e-4 agrees to 1.5e-11.
2. An input that became a constant
AAD only differentiates what it saw flowing from the marked inputs. If a value leaves the active type on the way (a float(), a call into math or NumPy that does not know the active type, a value cached before recording started), everything computed from it is a constant on the record with zero derivative.
In our variant the discount factor is computed as math.exp(-float(r) * T). The AAD rho comes back as exactly 0.0; the central bump gives 43.11326118902059, matching the AAD rho of the correct pricer (43.11326119152758).
The signature: AAD returns exactly zero, or a value that is missing one recognisable term, while the bump moves. AADC counts every active-to-passive conversion during recording (f.num_passive_warnings() was 1 here, 0 for the clean pricer), and a recording with source tracing lists the resulting constant at its line: in this run, a constant at the discount line with value 101.89678102259336, which is 105 × e^−0.03, the strike times a discount factor that should have been moving.
3. A kink at the input point
At a kink the derivative does not exist. AAD returns the slope of whichever side the code took; a central bump returns the average of the two. For the intrinsic value max(S − K, 0) at exactly S = K, AAD returned 1.0 and the central bump 0.5000000000165983. Neither is wrong; they answer different questions.
The signature: the disagreement appears only at or very near specific input values (a strike, a barrier, a cap), and is roughly half the jump in slope.
4. A branch taken at the input point
A Python if on an active value has to produce a plain bool, so the direction taken during recording is frozen. The derivative is then the derivative of the branch that was taken, and the jump between branches is not in it. A bump that straddles the threshold sees the jump; one that does not, does not. This case gets its own page: A derivative that jumps at a branch or a kink.
The signature: a frozen comparison in the recording, and a bumped value that depends sharply on the bump size near a threshold.
5. Discretisation and Monte Carlo noise
AAD differentiates the discretised model: the grid, the time steps, the paths you actually ran. A bumped Greek on the same discretisation should agree with it; a comparison against a closed form or a finer grid measures the discretisation, not AAD.
Monte Carlo makes this sharper. With 4,000 paths and a fixed seed, the pathwise AAD delta of the call was 0.5105083449178975. Bumped with the same random numbers, the delta was 0.5109080740637384 at h = 1 and 0.5106646297208695 at h = 0.01, converging on the AAD value. Bumped with fresh random numbers for the up and down prices, it was 0.5827944985497959 at h = 1 and 8.465698980709968 at h = 0.01: the difference of two noisy prices divided by a small number.
All three differ from the closed-form 0.5024 because 4,000 paths is a small sample; that is the estimator’s error, shared by AAD and by the bump with common random numbers.
The signature: the bumped value changes with the seed, and gets worse, not better, as h shrinks.
Which one is it?
- Re-run the bump with common random numbers and scan h over several orders of magnitude. If it converges to the AAD value, you are done: causes 1 and 5.
- If AAD is exactly zero, or short by one recognisable term, look for an input that left the active type: cause 2.
- Check the recording’s passive-warning count and its list of frozen decisions. A comparison in that list was fixed at record time: cause 4. A non-zero count with no comparison listed points back to cause 2.
- If the gap only appears at particular input values, look for a
max,min,absor payoff boundary nearby: cause 3.
Your agent can check this directly
aadc-mcp is a local MCP server that lets a coding agent ask the AADC engine these questions instead of reading the code and guessing. pip install aadc-mcp, register it with your MCP client, and ask the agent to run check_environment. On a script that records with marked inputs, open_session keeps the recording alive and ask_session answers stats (the frozen-decision count), leaves kind=constant (every constant that reached the price, at its line) and branches order=margin (every frozen decision, closest to flipping first). Two prompts ship with the server: add_sensitivities has the agent make a copy of your script that records itself with marked inputs and reports its passive-warning count, and validate_pricer has it list the constants, the frozen decisions and their margins, each with its file:line.
AADC runs here as Community Edition: free for non-commercial and academic use; commercial use, including evaluation inside a company, needs a licence from MatLogica.
Implemented using AADC, a commercial adjoint AD compiler (matlogica.com).