Repository navigation
Core tests fail with TimerOutputs v1: MethodError from Dict{Any,Any} in wronskian (models with no inputs) #556
Description
Summary
StructuralIdentifiability.jl master fails its Core test group whenever the environment
resolves TimerOutputs v1.x. Every model without inputs (u_vars empty) errors in
wronskian. This is currently red on ModelingToolkit.jl's downstream CI
(StructuralIdentifiability.jl/Core/1/ in the IntegrationTest workflow), but
ModelingToolkit is not involved — the failure reproduces with StructuralIdentifiability
alone.
Failing test
test/bodies/identifiability.jl → @testset "Assessing identifiability" (first case, the
2-compartment model, which has no inputs).
Assessing identifiability: Error During Test at test/bodies/identifiability.jl:3
Got exception outside of a @test
MethodError: no method matching power_series_solution(::StructuralIdentifiability.ODE{Nemo.fpMPolyRingElem}, ::Dict{Nemo.fpMPolyRingElem, Int64}, ::Dict{Nemo.fpMPolyRingElem, Int64}, ::Dict{Any, Any}, ::Int64)
Closest candidates are:
power_series_solution(::ODE{P}, ::Dict{P, Int64}, ::Dict{P, Int64}, ::Dict{P, Vector{Int64}}, ::Int64) where P<:(MPolyRingElem{<:FieldElem})
@ StructuralIdentifiability src/ODE.jl:202
power_series_solution(::ODE{P}, ::Dict{P, T}, ::Dict{P, T}, ::Dict{P, Vector{T}}, ::Int64) where {T<:FieldElem, P<:MPolyRingElem{T}}
@ StructuralIdentifiability src/ODE.jl:162
Stacktrace:
[1] wronskian(io_equations::Dict{QQMPolyRingElem, QQMPolyRingElem}, ode::ODE{QQMPolyRingElem})
@ StructuralIdentifiability src/wronskian.jl:218
[3] initial_identifiable_functions(...) @ src/global_identifiability.jl:108
[5] check_identifiability(...) @ src/global_identifiability.jl:202
[7] assess_global_identifiability(...) @ src/global_identifiability.jl:301
[10] _assess_identifiability(...) @ src/StructuralIdentifiability.jl:227
Root cause
src/wronskian.jl (currently lines 216-223) builds the input_values argument with an
untyped dict comprehension inside a @timeit-annotated function:
@timeit _to function wronskian(io_equations::Dict{P, P}, ode::ODE{P}) where {P <: MPolyRingElem}
...
ps = power_series_solution(
ode_red,
Dict(p => rand(1:(PRIME - 1)) for p in ode_red.parameters),
Dict(x => rand(1:(PRIME - 1)) for x in ode_red.x_vars),
Dict(u => [rand(1:(PRIME - 1)) for i in 0:ord] for u in ode_red.u_vars),
ord,
)With TimerOutputs v0.5 these comprehensions infer their element type statically, so an
empty ode_red.u_vars still produces Dict{fpMPolyRingElem, Vector{Int64}} and dispatch
works.
TimerOutputs v1 changed @timeit to emit the wrapped body twice, in an
if enabled … else … end. Duplicating the body makes Julia's closure conversion box the
locals that the comprehension closures capture (ord, PRIME become Core.Box), which
destroys inference of the generator element type. Base.@default_eltype then falls back to
Any, and because grow_to! never runs on an empty iterator, the dict stays
Dict{Any, Any} — no method matches.
Fully standalone MWE, no SciML packages involved:
using TimerOutputs
const to = TimerOutput()
@timeit to function f(xs)
n = 3
return Dict(x => [i for i in 0:n] for x in xs)
end
@timeit to function h()
n = 3
return () -> n
end
println("TimerOutputs v", pkgversion(TimerOutputs))
println(" empty: ", typeof(f(String[])))
println(" nonempty: ", typeof(f(["a"])))
println(" Core.Box: ", occursin("Box", string(first(code_lowered(h, Tuple{})))))Observed on Julia 1.12.7:
TimerOutputs v1.2.1
empty: Dict{Any, Any} <-- wrong
nonempty: Dict{String, Vector{Int64}}
Core.Box: true
TimerOutputs v0.5.29
empty: Dict{String, Vector{Int64}}
nonempty: Dict{String, Vector{Int64}}
Core.Box: false
The @timeit to "label" begin … end block form is affected identically.
This is arguably an upstream TimerOutputs bug (related to, but not the same as, the already
fixed KristofferC/TimerOutputs.jl#241 — that one landed in
v1.2.1, and the Core.Box case above still reproduces on v1.2.1).
Why it started failing now
Nothing changed in StructuralIdentifiability. TimerOutputs = "0.5.10, 1.0" has been the
compat here since #548, but the resolver still picked v0.5 because
RationalFunctionFields pinned TimerOutputs = "0.5.10".
RationalFunctionFields v0.3.5 (registered 2026-09-07T22:01Z,
JuliaRegistries/General#167451) relaxed that to
"0.5.10, 1" — its only change. From that moment every fresh resolve picks
TimerOutputs v1.2.1 and this breaks.
That timing is exactly what the ModelingToolkit downstream CI shows: the last green run
resolved TimerOutputs v0.5.29 and started at 2026-09-07T21:39Z (22 minutes before the
registration); every run after it resolves TimerOutputs v1.2.1 and fails.
| MTK master commit | job | TimerOutputs | result |
|---|---|---|---|
027fd26c6e |
https://github.com/SciML/ModelingToolkit.jl/actions/runs/34133478157/job/101778897496 | v0.5.29 | pass |
f21201f889 |
https://github.com/SciML/ModelingToolkit.jl/actions/runs/34133487250/job/101830636772 | v0.5.29 | pass |
9b458b059d |
https://github.com/SciML/ModelingToolkit.jl/actions/runs/34284399567/job/102256485846 | v1.2.1 | fail |
fcda4b8c23 |
https://github.com/SciML/ModelingToolkit.jl/actions/runs/34304213094/job/102317407471 | v1.2.1 | fail |
35818b8328 |
https://github.com/SciML/ModelingToolkit.jl/actions/runs/34395760914/job/102614954099 | v1.2.1 | fail |
There is no ModelingToolkit commit to bisect to — the correlation is registry timing.
Reproduction
Julia 1.12.7, clone StructuralIdentifiability.jl at
90f8452,
then:
using Pkg; Pkg.activate("StructuralIdentifiability.jl"); Pkg.update() # picks TimerOutputs v1.2.1
using StructuralIdentifiability
ode = @ODEmodel(
x0'(t) = -(a01 + a21) * x0(t) + a12 * x1(t),
x1'(t) = a21 * x0(t) - a12 * x1(t),
y(t) = x0(t),
)
assess_identifiability(ode, funcs_to_check = [a01, a21, a12, a01 * a12])errors as above. Pkg.add(name="TimerOutputs", version="0.5") in the same environment and
it succeeds. No ModelingToolkit anywhere in the environment.
Possible fixes
-
Give the dicts in
wronskianexplicit element types, which is immune to the boxing:Pred = elem_type(polyring_red) ps = power_series_solution( ode_red, Dict{Pred, Int}(p => rand(1:(PRIME - 1)) for p in ode_red.parameters), Dict{Pred, Int}(x => rand(1:(PRIME - 1)) for x in ode_red.x_vars), Dict{Pred, Vector{Int}}(u => [rand(1:(PRIME - 1)) for i in 0:ord] for u in ode_red.u_vars), ord, )
Verified locally: with this patch the repro above succeeds on TimerOutputs v1.2.1.
-
Report upstream to TimerOutputs.jl so the body is not duplicated (or the duplicated
branches get their own scope), since any@timeitfunction in any package that captures
a local in a closure is silently losing inference right now.
Note that (1) fixes the one call site that dispatches on the dict type; the boxing is
present in every @timeit function in src/ and may be costing inference elsewhere.
🤖 Investigated and filed by an AI agent (Claude Code, model claude-opus-5[1m]) on behalf of @ChrisRackauckas.
Session: https://claude.ai/code/session_01GdSpCLd7NBZuuePJmcDzU7
Activity