Skip to content

Order tags structurally and require unique tags in nested Duals - #853

Draft
devmotion wants to merge 8 commits into
masterfrom
dmw/order-free-tags
Draft

devmotion wants to merge 8 commits into
masterfrom
dmw/order-free-tags

Conversation

@devmotion

@devmotion devmotion commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Prototype of a redesign of the tag machinery, opened as a draft to discuss the design. Fixes #714, #801 and #845.

Motivation

Nested Duals represent a tensor product D_A ⊗ D_B of dual-number algebras, one factor per tag. Preventing perturbation confusion requires only

  • R1: distinct live perturbations have distinct tags, and
  • R2: values and partials are only ever accessed by tag (coefficient maps of the D_T factor; identity and zero if T is absent).

An order of tags is not needed mathematically. It only fixes a canonical storage order, so that binary operations just compare the outermost tags, each element has a single type, and seeding is plain wrapping. But since operations rely on it, the order must be the same in all code exchanging Duals, including precompiled code. tagcount violates this (#714, #801).

Changes

  1. value(T, d), partials(T, d), partials(T, d, i), valtype(T, D) descend through layers with other tags and return identity/zero if T is absent, independent of the order (R2).
  2. Accessors without a tag are deprecated; tags must be types.
  3. tagcount is replaced by a structural total order: a generated function returns a constant (rank, key...) ID per tag, and ≺ compares IDs (folded at compile time). The rank strictly increases under containment, so Tag{F,V} is greater than all tags in F and V. DualMismatchError is removed. The ID itself could be improved; what matters is that it is unique per tag, totally ordered and independent of the session, and that ≺ is folded to a constant Bool at compile time, as tagcount comparisons were.
  4. The constructor requires tags to be strictly decreasing inwards, so the tags of a nested Dual are unique (R1 within a number). Hence HessianConfig uses a separate gradient tag (hessian: the two nested dual layers share a tag, so results depend on the code path and chunk size #845), and untagged Duals and f = nothing configs use Tag{Nothing,V}, so nesting them yields distinct tags by containment. Similar to the strict order, and for efficiency reasons, the constructor also requires a concrete value type (e.g. Dual{T,Real} is disallowed), so the type of a Dual determines all its tags. Containment does not guarantee that the tag of a call is greater than the tags of its input (e.g. an abstract input type hides them), so seeding keeps layers of the input with greater tags outside. Elements of abstract input arrays are seeded with their own concrete types, in work buffers with element type Real.

Results (ForwardDiff 1.4.6 vs. this PR)

1.4.6 This PR Changes
Siskind & Pearlmutter 2005, #83, jacobian of gradient, #238, #267, constant inner derivative, nesting not ordered by containment, nested calls with abstract input types correct correct
#714: nested derivative in precompiled code Cannot determine ordering of Dual tags correct 3
#801: packages precompiling tags in opposite orders order depends on load order, confusion same order, correct 3
#845: hessian, hessian!, DiffResults, chunk sizes, SVector disagree with jacobian of gradient or throw agree 4
Hand-built Dual with the same tag or a greater tag inside silently accepted error 4
Nested untagged Duals Dual{Nothing,Dual{Nothing}}, confusion distinct tags 4
#313, #443: calls with the same function and input types silently wrong silently wrong
#320: function and config deserialized separately InvalidTagException, correct with Val(false) same
4th-order nested derivative / gradient(rosen, 100) / hessian(rosen, 100) 24 ns / 7.8 µs / 1.93 ms 24 ns / 7.8 µs / 1.93 ms

Comparison with other PRs

Breaking changes

  • Untagged Duals and f = nothing configs have tag Tag{Nothing,V} instead of Nothing.
  • Nesting a Dual in a Dual with the same or a greater tag throws an error.
  • Duals with a non-concrete value type, such as Dual{T,Real}, throw an error. Packages allocating their own Dual buffers for abstract input arrays are affected.
  • HessianConfig is parametrized on the type of its gradient config; work buffers for abstract input arrays have element type Real; DualMismatchError is removed; non-type tags throw an error.

Not addressed

R1 across numbers: Tag{F,V} is a static proxy for a fresh tag per call, so calls with the same function and input types share a tag.

  • Perturbation Confusion II: The Tag Wars #313 (live collision): an inner call uses the tag of an enclosing call. Tracking the active tags in a ScopedValue would detect this on entry and could throw a descriptive error (or retag the inner call).
  • Perturbation Confusion issue #443 (stale collision): a Dual leaks from a finished call through mutable state (a Core.Box) into a later call with the same tag. The tag is no longer active, and types cannot distinguish the stale Dual from a fresh one. Detecting this would require walking the state reachable from f and x (incomplete, e.g. globals) or a per-call identity stored in each Dual (too costly).

Another possible follow-up is restricting tags to Tag (no custom ≺). The same analysis applies to ReverseDiff#45; a small shared interface (active tags plus descending through wrappers of other packages) could let both packages interoperate.

🤖 Generated with Claude Code

devmotion and others added 5 commits September 24, 2026 21:45
`value(T, d)`, `partials(T, d, i)` and `valtype(T, D)` now descend through
layers with other tags instead of throwing `DualMismatchError` when `T` is
not the outermost tag. If `T` does not occur at all, the value is the
number itself and the partials are zero, regardless of the order of tags.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`value(x)`, `partials(x)`, `partials(x, i...)`, `npartials(d)` and
`partials(T, x, i, j...)` extract the outermost layer of a nested `Dual`,
which depends on the order of the tags. They are deprecated in favor of
`value(T, x)`, `partials(T, x)`, `partials(T, x, i)` and
`npartials(T, D)`, which all internal code now uses.

Dispatching on the tag requires it to be a type, so constructing a `Dual`
with a non-type tag such as `Dual{1}` now throws an `ArgumentError`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Replace the impure `tagcount` counter with a total order on tag types that
depends only on their structure, so it is the same in every session and
unaffected by precompilation. A generated function computes a constant
`(rank, key)` ID per tag, and `≺` compares the IDs, which is evaluated at
compile time. The rank strictly increases from a type to any type
containing it, so every tag is greater than the tags in its parameters and
seeding outermost keeps nested `Dual`s sorted. The `Dual` constructor
rejects a tag stored outside a greater tag.

Since every pair of tags is now ordered, `DualMismatchError` is removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nesting a `Dual` in a `Dual` with the same tag merges two perturbations,
so the `Dual` constructor now requires every tag to be strictly greater
than the tags of its value and partials. It checks their runtime types, so
tags hidden behind an abstract value type are rejected as well.

Hence `HessianConfig` uses a separate tag `Tag{F,Dual{T,V,N}}` for its
gradient layer (#845), which adds a type parameter, and so does `hessian!`
for StaticArrays with immutable results. The latter now reads the gradient
from the gradient layer, as all other Hessian paths do, so that all paths
agree with the Jacobian of the gradient. Untagged `Dual`s and configs
created with `f = nothing` use the tag `Tag{Nothing,V}` instead of
`Nothing`, where `V` is the promoted value type, so that nesting them
yields distinct tags. Promoting `Dual`s
with the same tag but different numbers of partials throws an error
instead of nesting the tag in itself.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.54%. Comparing base (a3c0f4f) to head (8fdf29f).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #853      +/-   ##
==========================================
+ Coverage   91.23%   93.54%   +2.31%     
==========================================
  Files          11       12       +1     
  Lines        1072     1146      +74     
==========================================
+ Hits          978     1072      +94     
+ Misses         94       74      -20     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@longemen3000

Copy link
Copy Markdown
Contributor

The new structure switches the need for tagging API away from the tag type itself and into tagid, making it easier for structs with captured variables to provide their inner dual state if available.

For example, #313 could be resolved by the owner of the mutable struct by defining an appropiate tagid method depending on the whoami field.

#815 conflicts with this PR, because what needs to be exposes as an API changes. so merging this PR should close #815.

devmotion and others added 3 commits September 29, 2026 14:57
…and cover new lines

- `Dual{T}(args...)` and `Dual(args...)` take the value type from the partials, so
  e.g. `zero(Dual{T,Real,N})` and `convert(Dual{T,Real,N}, x)` work again
- `promote_rule` for the same tag with different numbers of partials returns `Union{}`
  instead of throwing, so `promote_type` falls back to `typejoin`
- Include the package UUID in the tag key, so types of packages with the same name are
  distinguished
- Remove the redundant `Tag(::Nothing, V)` method
- Test `≺` with symbols, values and `Vararg`s as parameters, `npartials` without the tag,
  the deprecated `partials(x, i)` and `show`

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With an abstract input type, the tag of an inner call can be smaller than tags of the input
elements. Seeding now keeps layers with greater tags outside, and the work buffers use a
bound on the seeded type (`Real` if the value type is abstract and can contain `Dual`s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With an abstract value type, the components of a `Dual` can carry different tag layers, so the
type no longer determines where a tag occurs. Seeding now converts the seeds to the type of each
element, so elements of abstract input arrays are seeded with concrete value types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

Precompilation can lead to Dual tag ordering problems

2 participants