From a35f1b07b4659eba0059f083ebe104c138ee6b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20M=C3=BCller-Widmann?= Date: Thu, 29 Oct 2026 23:53:31 +0100 Subject: [PATCH 1/5] Broadcast `Tangent`s like their primal Co-Authored-By: Claude Opus 5.5 --- Project.toml | 2 +- src/tangent_types/structural_tangent.jl | 4 ++++ test/tangent_types/structural_tangent.jl | 12 ++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 30a618092..5df2a56ea 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "ChainRulesCore" uuid = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" -version = "1.26.7" +version = "1.26.8" [deps] LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" diff --git a/src/tangent_types/structural_tangent.jl b/src/tangent_types/structural_tangent.jl index 94a0e4e48..d5d160d2f 100644 --- a/src/tangent_types/structural_tangent.jl +++ b/src/tangent_types/structural_tangent.jl @@ -405,6 +405,10 @@ function Base.indexed_iterate(tangent::Tangent{P,<:Tuple}, i::Int, state=1) wher return Base.indexed_iterate(backing(tangent), i, state) end +# Broadcast like the primal: tuples as containers, everything else (e.g. structs) as scalars +Base.Broadcast.broadcastable(tangent::Tangent{<:Any,<:Tuple}) = backing(tangent) +Base.Broadcast.broadcastable(tangent::Tangent) = Ref(tangent) + function Base.map(f, tangent::Tangent{P,<:Tuple}) where {P} vals::Tuple = map(f, backing(tangent)) return Tangent{P,typeof(vals)}(vals) diff --git a/test/tangent_types/structural_tangent.jl b/test/tangent_types/structural_tangent.jl index c0b26217e..1383a8442 100644 --- a/test/tangent_types/structural_tangent.jl +++ b/test/tangent_types/structural_tangent.jl @@ -140,6 +140,18 @@ end @inferred _unpack2tuple(ctup) @test _unpack2tuple(ctup) === (2.0, 3) + # Test broadcastable + @test broadcastable(ctup) === (2.0, 3) + @test @inferred(Tangent{Any}(1.0, [2.0]) .* 2) == (2.0, [4.0]) + @test ctup .+ [1, 2] == [3.0, 5.0] + tfoo = Tangent{Foo}(; y=2.0, x=1.0) + @test broadcastable(tfoo) isa Ref{typeof(tfoo)} + @test @inferred(tfoo .* 2) == Tangent{Foo}(; x=2.0, y=4.0) + @test tfoo .* [1, 2] == [tfoo, 2 * tfoo] + tdict = Tangent{Dict}(Dict(:a => 1.0)) + @test broadcastable(tdict) isa Ref{typeof(tdict)} + @test tdict .* 2 == Tangent{Dict}(Dict(:a => 2.0)) + # Test getproperty is inferrable _unpacknamedtuple = tangent -> (tangent.x, tangent.y) @inferred _unpacknamedtuple(Tangent{Foo}(; x=2, y=3.0)) From 5abeebc9c8a813f115bf1c6ed2c1a76647129ba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20M=C3=BCller-Widmann?= Date: Fri, 30 Oct 2026 01:12:29 +0100 Subject: [PATCH 2/5] Check for zero tuple type --- src/projection.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/projection.jl b/src/projection.jl index ddc797bd5..2577dfa7b 100644 --- a/src/projection.jl +++ b/src/projection.jl @@ -339,7 +339,7 @@ function (project::ProjectTo{<:Tangent{<:Tuple}})(dx::Tuple) end # Here map will fail if the lengths don't match, but gives a much less helpful error: dy = map((f, x) -> f(x), project.elements, dx) - if all(d -> d isa AbstractZero, dy) + if dy isa Tuple{Vararg{AbstractZero}} return NoTangent() else return project_type(project)(dy...) @@ -392,7 +392,7 @@ function (project::ProjectTo{<:Tangent{<:Tuple}})(dx::AbstractArray) end dy = reshape(dx, axes(project.elements)) # allows for dx::OffsetArray dz = ntuple(i -> project.elements[i](dy[i]), length(project.elements)) - if all(d -> d isa AbstractZero, dy) + if dz isa Tuple{Vararg{AbstractZero}} return NoTangent() else return project_type(project)(dz...) From 7e08f58d3b62f75be2360d87da9c8fb4ae455608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20M=C3=BCller-Widmann?= Date: Fri, 30 Oct 2026 09:36:03 +0100 Subject: [PATCH 3/5] Test tuple projector collapse and inference Co-Authored-By: Claude Opus 5.5 --- test/projection.jl | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/projection.jl b/test/projection.jl index dee6ef146..848d85e98 100644 --- a/test/projection.jl +++ b/test/projection.jl @@ -173,6 +173,17 @@ struct NoSuperType end pt3 = ProjectTo(([1, 2, 3], false, :gamma)) # partly non-differentiable @test pt3((1:3, 4, 5)) == Tangent{Tuple{Vector{Int}, Bool, Symbol}}([1.0, 2.0, 3.0], NoTangent(), NoTangent()) @test ProjectTo((true, [false])) isa ProjectTo{NoTangent} + + pt2 = ProjectTo((1.0, 2.0)) + @test @inferred(pt2([3, 4])) == Tangent{Tuple{Float64,Float64}}(3.0, 4.0) + @test @inferred(pt2((ZeroTangent(), 1))) == Tangent{Tuple{Float64,Float64}}(ZeroTangent(), 1.0) + @test @inferred(pt2((ZeroTangent(), NoTangent()))) === NoTangent() + @test pt2([ZeroTangent(), NoTangent()]) === NoTangent() + + ptb = ProjectTo((true, 1.0)) + @test @inferred(ptb((2.0, ZeroTangent()))) === NoTangent() + @test ptb(Any[2.0, ZeroTangent()]) === NoTangent() # collapse all-zero after projection + @test ptb([2.0, 3.0]) == Tangent{Tuple{Bool,Float64}}(NoTangent(), 3.0) end @testset "Base: NamedTuple" begin From 80dc992d12c85391fc4436169c13ee2f1359bf1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20M=C3=BCller-Widmann?= Date: Fri, 30 Oct 2026 09:39:54 +0100 Subject: [PATCH 4/5] Fix format Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- test/projection.jl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/projection.jl b/test/projection.jl index 848d85e98..e05541cae 100644 --- a/test/projection.jl +++ b/test/projection.jl @@ -176,7 +176,8 @@ struct NoSuperType end pt2 = ProjectTo((1.0, 2.0)) @test @inferred(pt2([3, 4])) == Tangent{Tuple{Float64,Float64}}(3.0, 4.0) - @test @inferred(pt2((ZeroTangent(), 1))) == Tangent{Tuple{Float64,Float64}}(ZeroTangent(), 1.0) + @test @inferred(pt2((ZeroTangent(), 1))) == + Tangent{Tuple{Float64,Float64}}(ZeroTangent(), 1.0) @test @inferred(pt2((ZeroTangent(), NoTangent()))) === NoTangent() @test pt2([ZeroTangent(), NoTangent()]) === NoTangent() From 755baf207754d51986c2acd395cc60b4d2183b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20M=C3=BCller-Widmann?= Date: Fri, 30 Oct 2026 17:18:59 +0100 Subject: [PATCH 5/5] Throw when broadcasting over structural tangents Whether a structural tangent should broadcast as a container or as a scalar depends on the broadcast and can't be determined from the tangent alone (e.g. for `Tangent{Any}`), so throw an `ArgumentError` instead of guessing. Move the unrelated tuple projector fix to a separate PR. Co-Authored-By: Claude Opus 5.5 --- Project.toml | 2 +- src/projection.jl | 4 ++-- src/tangent_types/structural_tangent.jl | 10 +++++++--- test/projection.jl | 12 ------------ test/tangent_types/structural_tangent.jl | 19 ++++++++----------- 5 files changed, 18 insertions(+), 29 deletions(-) diff --git a/Project.toml b/Project.toml index 5df2a56ea..b8abe79e4 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "ChainRulesCore" uuid = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" -version = "1.26.8" +version = "1.27.0" [deps] LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" diff --git a/src/projection.jl b/src/projection.jl index 2577dfa7b..ddc797bd5 100644 --- a/src/projection.jl +++ b/src/projection.jl @@ -339,7 +339,7 @@ function (project::ProjectTo{<:Tangent{<:Tuple}})(dx::Tuple) end # Here map will fail if the lengths don't match, but gives a much less helpful error: dy = map((f, x) -> f(x), project.elements, dx) - if dy isa Tuple{Vararg{AbstractZero}} + if all(d -> d isa AbstractZero, dy) return NoTangent() else return project_type(project)(dy...) @@ -392,7 +392,7 @@ function (project::ProjectTo{<:Tangent{<:Tuple}})(dx::AbstractArray) end dy = reshape(dx, axes(project.elements)) # allows for dx::OffsetArray dz = ntuple(i -> project.elements[i](dy[i]), length(project.elements)) - if dz isa Tuple{Vararg{AbstractZero}} + if all(d -> d isa AbstractZero, dy) return NoTangent() else return project_type(project)(dz...) diff --git a/src/tangent_types/structural_tangent.jl b/src/tangent_types/structural_tangent.jl index d5d160d2f..0e5a4d3dc 100644 --- a/src/tangent_types/structural_tangent.jl +++ b/src/tangent_types/structural_tangent.jl @@ -405,9 +405,13 @@ function Base.indexed_iterate(tangent::Tangent{P,<:Tuple}, i::Int, state=1) wher return Base.indexed_iterate(backing(tangent), i, state) end -# Broadcast like the primal: tuples as containers, everything else (e.g. structs) as scalars -Base.Broadcast.broadcastable(tangent::Tangent{<:Any,<:Tuple}) = backing(tangent) -Base.Broadcast.broadcastable(tangent::Tangent) = Ref(tangent) +# Whether a structural tangent should broadcast as a container (e.g. a tuple broadcast) or +# as a scalar (e.g. a 0-dim broadcast with a struct-valued output) depends on the broadcast, +# and in general can't be determined from the tangent alone (e.g. for `Tangent{Any}`) +function Base.Broadcast.broadcastable(::StructuralTangent) + msg = "broadcasting over structural tangents is not supported; use `map` or arithmetic" + return throw(ArgumentError(msg)) +end function Base.map(f, tangent::Tangent{P,<:Tuple}) where {P} vals::Tuple = map(f, backing(tangent)) diff --git a/test/projection.jl b/test/projection.jl index e05541cae..dee6ef146 100644 --- a/test/projection.jl +++ b/test/projection.jl @@ -173,18 +173,6 @@ struct NoSuperType end pt3 = ProjectTo(([1, 2, 3], false, :gamma)) # partly non-differentiable @test pt3((1:3, 4, 5)) == Tangent{Tuple{Vector{Int}, Bool, Symbol}}([1.0, 2.0, 3.0], NoTangent(), NoTangent()) @test ProjectTo((true, [false])) isa ProjectTo{NoTangent} - - pt2 = ProjectTo((1.0, 2.0)) - @test @inferred(pt2([3, 4])) == Tangent{Tuple{Float64,Float64}}(3.0, 4.0) - @test @inferred(pt2((ZeroTangent(), 1))) == - Tangent{Tuple{Float64,Float64}}(ZeroTangent(), 1.0) - @test @inferred(pt2((ZeroTangent(), NoTangent()))) === NoTangent() - @test pt2([ZeroTangent(), NoTangent()]) === NoTangent() - - ptb = ProjectTo((true, 1.0)) - @test @inferred(ptb((2.0, ZeroTangent()))) === NoTangent() - @test ptb(Any[2.0, ZeroTangent()]) === NoTangent() # collapse all-zero after projection - @test ptb([2.0, 3.0]) == Tangent{Tuple{Bool,Float64}}(NoTangent(), 3.0) end @testset "Base: NamedTuple" begin diff --git a/test/tangent_types/structural_tangent.jl b/test/tangent_types/structural_tangent.jl index 1383a8442..746c55849 100644 --- a/test/tangent_types/structural_tangent.jl +++ b/test/tangent_types/structural_tangent.jl @@ -140,17 +140,14 @@ end @inferred _unpack2tuple(ctup) @test _unpack2tuple(ctup) === (2.0, 3) - # Test broadcastable - @test broadcastable(ctup) === (2.0, 3) - @test @inferred(Tangent{Any}(1.0, [2.0]) .* 2) == (2.0, [4.0]) - @test ctup .+ [1, 2] == [3.0, 5.0] - tfoo = Tangent{Foo}(; y=2.0, x=1.0) - @test broadcastable(tfoo) isa Ref{typeof(tfoo)} - @test @inferred(tfoo .* 2) == Tangent{Foo}(; x=2.0, y=4.0) - @test tfoo .* [1, 2] == [tfoo, 2 * tfoo] - tdict = Tangent{Dict}(Dict(:a => 1.0)) - @test broadcastable(tdict) isa Ref{typeof(tdict)} - @test tdict .* 2 == Tangent{Dict}(Dict(:a => 2.0)) + # Test broadcasting is not supported + @test_throws ArgumentError ctup .* 2 + @test_throws ArgumentError Tangent{Any}(1.0, [2.0]) .* 2 + @test_throws ArgumentError ctup .+ [1, 2] + @test_throws ArgumentError Tangent{Foo}(; y=2.0, x=1.0) .* 2 + @test_throws ArgumentError Tangent{Dict}(Dict(:a => 1.0)) .* 2 + @test_throws ArgumentError MutableTangent{MFoo}(; x=1.5, y=1.5) .* 2 + @test_throws ArgumentError @thunk(ctup) .* 2 # Test getproperty is inferrable _unpacknamedtuple = tangent -> (tangent.x, tangent.y)