Skip to content

Core tests fail with TimerOutputs v1: MethodError from Dict{Any,Any} in wronskian (models with no inputs) #556

Closed

Description

@ChrisRackauckas-Claude

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

  1. Give the dicts in wronskian explicit 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.

  2. Report upstream to TimerOutputs.jl so the body is not duplicated (or the duplicated
    branches get their own scope), since any @timeit function 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions