Since 0.3.10, @data emits a "promoting" variant constructor for parametric self-referential fields (the fix for #34). When a variant has two or more bare self-referential fields and no other field pins the type parameter, that generated method has an unbound type parameter, so Test.detect_unbound_args fails for any package defining such an ADT.
MWE
import Moshi
using Moshi.Data: @data
using Test
@data Tree{T} begin
Empty()
Leaf(T)
Node(Tree.Type{T}, Tree.Type{T})
end
Test.detect_unbound_args(Tree; recursive = true)
Output on Moshi 0.3.12 / Julia 1.12.7:
1-element Vector{Method}:
Main.Tree.Node(var"1"::Union{Main.Tree.var"typeof(Tree)"{T}, Main.Tree.var"typeof(Tree)"{Union{}}},
var"2"::Union{Main.Tree.var"typeof(Tree)"{T}, Main.Tree.var"typeof(Tree)"{Union{}}}) where T
@ Main.Tree ~/.julia/packages/Moshi/jb5et/src/data/emit/cons.jl:203
If both arguments take the Tree.Type{Union{}} branch, nothing determines T.
As far as I can tell this is a static-analysis problem, not a runtime bug.
Because the variant has ≥2 self-refs, emit_each_variant_cons_inferred emits both the exact and the promoting constructor, and the exact one claims the all-bottom case:
e, l = Tree.Empty(), Tree.Leaf(3)
Tree.Node(l, e) # -> Tree.Type{Int64} dispatches to cons.jl:203 (promoting) ← what #34 needs
Tree.Node(e, e) # -> Tree.Type{Union{}} dispatches to cons.jl:176 (exact, T = Union{})
So the one argument shape that leaves T unbound in the promoting signature is exactly the shape the more-specific exact method already handles. The method is selectable only when at least one position is non-bottom, and then T is pinned.
detect_unbound_args works on signatures alone and can't see that.
Downstream impact
SymbolicUtils.jl caps Moshi = "0.3.6 - 0.3.9" for this reason (SymbolicUtils#1042):
its BasicSymbolicImpl.Div variant has num::BasicSymbolicImpl.Type{T} and den::BasicSymbolicImpl.Type{T}. Its full test suite passes on 0.3.12; only the QA lane fails. Any package with a binary-node ADT and an Aqua QA lane will hit this.
I have some possible suggestions from Claude, but I'm not sure if they are useful:
-
Bind every self-ref position independently and reconcile in the body, e.g.
Node(a::Tree.Type{A}, b::Tree.Type{B}) where {A, B}, resolving T from the
non-bottom parameters and throwing when they're irreconcilable. No unbound
parameters, and it subsumes the exact constructor. Downside: it accepts
Node(::Tree.Type{Int}, ::Tree.Type{Float64}) at the signature level, so the
rejection moves from dispatch to a runtime error.
-
Emit one promoting method per self-ref position, with that position pinned to
Tree.Type{T} and the rest left as the union. For two self-refs the existing
exact method resolves the ambiguity at the fully-pinned point. This gets murky for
three or more self-refs, where partial-bottom combinations are genuinely ambiguous.
Since 0.3.10,
@dataemits a "promoting" variant constructor for parametric self-referential fields (the fix for #34). When a variant has two or more bare self-referential fields and no other field pins the type parameter, that generated method has an unbound type parameter, soTest.detect_unbound_argsfails for any package defining such an ADT.MWE
Output on Moshi 0.3.12 / Julia 1.12.7:
If both arguments take the
Tree.Type{Union{}}branch, nothing determinesT.As far as I can tell this is a static-analysis problem, not a runtime bug.
Because the variant has ≥2 self-refs,
emit_each_variant_cons_inferredemits both the exact and the promoting constructor, and the exact one claims the all-bottom case:So the one argument shape that leaves
Tunbound in the promoting signature is exactly the shape the more-specific exact method already handles. The method is selectable only when at least one position is non-bottom, and thenTis pinned.detect_unbound_argsworks on signatures alone and can't see that.Downstream impact
SymbolicUtils.jl caps
Moshi = "0.3.6 - 0.3.9"for this reason (SymbolicUtils#1042):its
BasicSymbolicImpl.Divvariant hasnum::BasicSymbolicImpl.Type{T}andden::BasicSymbolicImpl.Type{T}. Its full test suite passes on 0.3.12; only the QA lane fails. Any package with a binary-node ADT and an Aqua QA lane will hit this.I have some possible suggestions from Claude, but I'm not sure if they are useful:
Bind every self-ref position independently and reconcile in the body, e.g.
Node(a::Tree.Type{A}, b::Tree.Type{B}) where {A, B}, resolvingTfrom thenon-bottom parameters and throwing when they're irreconcilable. No unbound
parameters, and it subsumes the exact constructor. Downside: it accepts
Node(::Tree.Type{Int}, ::Tree.Type{Float64})at the signature level, so therejection moves from dispatch to a runtime error.
Emit one promoting method per self-ref position, with that position pinned to
Tree.Type{T}and the rest left as the union. For two self-refs the existingexact method resolves the ambiguity at the fully-pinned point. This gets murky for
three or more self-refs, where partial-bottom combinations are genuinely ambiguous.