ForwardDiff.hessian evaluates f on nested duals that carry the same tag at both layers, Dual{T,Dual{T,V,N},N} — HessianConfig passes one tag = Tag(f, V) to both of its inner configs (src/config.jl#L221-L227) and Base.eltype reflects that (src/config.jl#L256).
The two layers are therefore type-indistinguishable, so ForwardDiff.value / ForwardDiff.partials called inside f cannot tell them apart, and the tag-ordering machinery (≺, DualMismatchError) has nothing to compare. Which layer gets peeled then depends on how a given code path happens to nest its evaluations.
1. One function, three Hessians and two gradients
The layer is dropped from an intermediate here: ForwardDiff.value(z[1]) is order 1, while f's return value is a full order-2 Dual{T,Dual{T,V,N},N}. Extraction therefore has both layers to read and nothing about the result type looks wrong — the damage is that the one-layer intermediate is re-absorbed at the inner layer on its way back up, the shared tag making the two indistinguishable.
using ForwardDiff, StaticArrays, DiffResults
f(z) = sum(abs2, z) + ForwardDiff.value(z[1]) * z[2]
x = [1.0, 2.0, 3.0]
sx = SVector(1.0, 2.0, 3.0)
ForwardDiff.hessian(f, x) # H[1,2]=1.0 H[2,1]=0.0
ForwardDiff.hessian(f, x, ForwardDiff.HessianConfig(f, x, ForwardDiff.Chunk{1}())) # H[1,2]=0.0 H[2,1]=0.0
ForwardDiff.hessian(f, sx) # H[1,2]=0.0 H[2,1]=1.0
DiffResults.gradient(ForwardDiff.hessian!(DiffResults.HessianResult(x), f, x)) # [4.0, 5.0, 6.0]
DiffResults.gradient(ForwardDiff.hessian!(DiffResults.HessianResult(sx), f, sx)) # [2.0, 5.0, 6.0]
Three Hessians and two gradients for the same function, varying with the chunk size, the array type and the result type.
2. A silently zeroed gradient
Here the output itself is order 1, so extraction has no outer layer to read, and the surviving first-order partials are discarded rather than used:
g(z) = ForwardDiff.value(sum(abs2, z))
DiffResults.gradient(ForwardDiff.hessian!(DiffResults.HessianResult(x), g, x)) # [2.0, 4.0, 6.0]
DiffResults.gradient(ForwardDiff.hessian!(DiffResults.HessianResult(sx), g, sx)) # [0.0, 0.0, 0.0]
ForwardDiff.gradient(z -> sum(abs2, z), x) # [2.0, 4.0, 6.0]
hessian!(::ImmutableDiffResult, f, ::StaticArray) builds dualize(T, dualize(T, x)) and extracts with nested value(T, ·). Because both layers share T, value(T, ·) peels a lone Dual{T,V,N} down to a plain Float64, and the following partials(T, ·, i) hits the zero(x) fallback. The zero Hessian is correct — no second-order information survived — but the gradient should be [2.0, 4.0, 6.0].
Cause and suggested fix
The two layers need distinct tags: an inner T = Tag(f, V) and an outer TO derived from it, so that T ≺ TO. Then value(TO, ::Dual{T}) correctly leaves an inner-layer value alone, and partials(TO, ::Dual{T}) is zero. Both cases above come out right with no special-casing, and the result stops depending on the chunk size.
The paths that already use two tags give the consistent answer today: hessian(f, ::StaticArray) goes through jacobian(Base.Fix1(gradient, f), x), which builds a fresh tag for each level.
Version: ForwardDiff master (v1.4.5), Julia 1.12.7, StaticArrays 1.9.19.
ForwardDiff.hessianevaluatesfon nested duals that carry the same tag at both layers,Dual{T,Dual{T,V,N},N}—HessianConfigpasses onetag = Tag(f, V)to both of its inner configs (src/config.jl#L221-L227) andBase.eltypereflects that (src/config.jl#L256).The two layers are therefore type-indistinguishable, so
ForwardDiff.value/ForwardDiff.partialscalled insidefcannot tell them apart, and the tag-ordering machinery (≺,DualMismatchError) has nothing to compare. Which layer gets peeled then depends on how a given code path happens to nest its evaluations.1. One function, three Hessians and two gradients
The layer is dropped from an intermediate here:
ForwardDiff.value(z[1])is order 1, whilef's return value is a full order-2Dual{T,Dual{T,V,N},N}. Extraction therefore has both layers to read and nothing about the result type looks wrong — the damage is that the one-layer intermediate is re-absorbed at the inner layer on its way back up, the shared tag making the two indistinguishable.Three Hessians and two gradients for the same function, varying with the chunk size, the array type and the result type.
2. A silently zeroed gradient
Here the output itself is order 1, so extraction has no outer layer to read, and the surviving first-order partials are discarded rather than used:
hessian!(::ImmutableDiffResult, f, ::StaticArray)buildsdualize(T, dualize(T, x))and extracts with nestedvalue(T, ·). Because both layers shareT,value(T, ·)peels a loneDual{T,V,N}down to a plainFloat64, and the followingpartials(T, ·, i)hits thezero(x)fallback. The zero Hessian is correct — no second-order information survived — but the gradient should be[2.0, 4.0, 6.0].Cause and suggested fix
The two layers need distinct tags: an inner
T = Tag(f, V)and an outerTOderived from it, so thatT ≺ TO. Thenvalue(TO, ::Dual{T})correctly leaves an inner-layer value alone, andpartials(TO, ::Dual{T})is zero. Both cases above come out right with no special-casing, and the result stops depending on the chunk size.The paths that already use two tags give the consistent answer today:
hessian(f, ::StaticArray)goes throughjacobian(Base.Fix1(gradient, f), x), which builds a fresh tag for each level.Version: ForwardDiff master (v1.4.5), Julia 1.12.7, StaticArrays 1.9.19.