diff --git a/ext/CatalystStructuralIdentifiabilityExtension/structural_identifiability_extension.jl b/ext/CatalystStructuralIdentifiabilityExtension/structural_identifiability_extension.jl index 24bdb7efd1..4585e0bece 100644 --- a/ext/CatalystStructuralIdentifiabilityExtension/structural_identifiability_extension.jl +++ b/ext/CatalystStructuralIdentifiabilityExtension/structural_identifiability_extension.jl @@ -27,10 +27,10 @@ Notes: - `measured_quantities` and `known_p` input may also be symbolic (e.g. measured_quantities = [rs.X]) """ function Catalyst.make_si_ode(rs::ReactionSystem; measured_quantities = [], known_p = [], - ignore_no_measured_warn = false, remove_conserved = true) + ignore_no_measured_warn = false, remove_conserved = true, mtkcompile::Bool = false) # Creates a MTK ODE System, and a list of measured quantities (there are equations). # Gives these to SI to create an SI ode model of its preferred form. - osys, conseqs, _, _ = make_osys(rs; remove_conserved) + osys, conseqs, _, _ = make_osys(rs; remove_conserved, mtkcompile) measured_quantities = make_measured_quantities(rs, measured_quantities, known_p, conseqs; ignore_no_measured_warn) return SI.mtk_to_si(osys, measured_quantities)[1] @@ -65,9 +65,10 @@ Notes: """ function SI.assess_local_identifiability(rs::ReactionSystem, args...; measured_quantities = [], known_p = [], funcs_to_check = Vector(), - remove_conserved = true, ignore_no_measured_warn = false, kwargs...) + remove_conserved = true, ignore_no_measured_warn = false, + mtkcompile::Bool = false, kwargs...) # Creates an ODE System, list of measured quantities, and functions to check, of SI's preferred form. - osys, conseqs, consconsts, vars = make_osys(rs; remove_conserved) + osys, conseqs, consconsts, vars = make_osys(rs; remove_conserved, mtkcompile) measured_quantities = make_measured_quantities(rs, measured_quantities, known_p, conseqs; ignore_no_measured_warn) funcs_to_check = make_ftc(funcs_to_check, conseqs, vars) @@ -105,9 +106,10 @@ Notes: """ function SI.assess_identifiability(rs::ReactionSystem, args...; measured_quantities = [], known_p = [], funcs_to_check = Vector(), - remove_conserved = true, ignore_no_measured_warn = false, kwargs...) + remove_conserved = true, ignore_no_measured_warn = false, + mtkcompile::Bool = false, kwargs...) # Creates an ODE System, list of measured quantities, and functions to check, of SI's preferred form. - osys, conseqs, consconsts, vars = make_osys(rs; remove_conserved) + osys, conseqs, consconsts, vars = make_osys(rs; remove_conserved, mtkcompile) measured_quantities = make_measured_quantities(rs, measured_quantities, known_p, conseqs; ignore_no_measured_warn) funcs_to_check = make_ftc(funcs_to_check, conseqs, vars) @@ -147,9 +149,9 @@ Notes: """ function SI.find_identifiable_functions(rs::ReactionSystem, args...; measured_quantities = [], known_p = [], remove_conserved = true, - ignore_no_measured_warn = false, kwargs...) + ignore_no_measured_warn = false, mtkcompile::Bool = false, kwargs...) # Creates an ODE System, and list of measured quantities, of SI's preferred form. - osys, conseqs, consconsts, _ = make_osys(rs; remove_conserved) + osys, conseqs, consconsts, _ = make_osys(rs; remove_conserved, mtkcompile) measured_quantities = make_measured_quantities(rs, measured_quantities, known_p, conseqs; ignore_no_measured_warn) @@ -162,26 +164,38 @@ end # From a reaction system, creates the corresponding MTK-style ODE System for SI application # Also compute the, later needed, conservation law equations and list of system symbols (unknowns and parameters). -function make_osys(rs::ReactionSystem; remove_conserved = true) +function make_osys(rs::ReactionSystem; remove_conserved = true, mtkcompile::Bool = false) # Creates the ODE System corresponding to the ReactionSystem (expanding functions and flattening it). # Creates a list of the systems all symbols (unknowns and parameters). if !ModelingToolkitBase.iscomplete(rs) error("Identifiability should only be computed for complete systems. A ReactionSystem can be marked as complete using the `complete` function.") end rs = complete(Catalyst.expand_registered_functions(flatten(rs))) - osys = complete(ode_model(rs; remove_conserved)) + # SI treats Γ as a free parameter and works purely symbolically, so we skip + # the conservation law bindings (Γ => missing) that are only needed for + # numerical initialization. + osys = ode_model(rs; remove_conserved, add_cl_bindings = false) + if mtkcompile + osys = ModelingToolkitBase.mtkcompile(osys) + elseif ModelingToolkitBase.has_alg_equations(rs) + error("The input ReactionSystem has algebraic equations. This requires setting `mtkcompile = true`.") + else + osys = complete(osys) + end vars = [unknowns(rs); parameters(rs)] # Computes equations for system conservation laws. - # If there are no conserved equations, the `conseqs` variable must still have the `Vector{Pair{Any, Any}}` type. + # The element-typed comprehensions guarantee `Vector{Pair{Any, Any}}` even + # when the iterable is empty (empty list comprehensions otherwise infer as + # `Vector{Any}`, and the previous fix-up `Vector{Pair{Any, Any}}[]` was a + # typo that produced `Vector{Vector{Pair{Any, Any}}}`). if remove_conserved - conseqs = [ceq.lhs => ceq.rhs for ceq in conservedequations(rs)] - consconsts = [cconst.lhs => cconst.rhs for cconst in conservationlaw_constants(rs)] - isempty(conseqs) && (conseqs = Vector{Pair{Any, Any}}[]) - isempty(consconsts) && (consconsts = Vector{Pair{Any, Any}}[]) + conseqs = Pair{Any, Any}[ceq.lhs => ceq.rhs for ceq in conservedequations(rs)] + consconsts = Pair{Any, Any}[cconst.lhs => cconst.rhs + for cconst in conservationlaw_constants(rs)] else - conseqs = Vector{Pair{Any, Any}}[] - consconsts = Vector{Pair{Any, Any}}[] + conseqs = Pair{Any, Any}[] + consconsts = Pair{Any, Any}[] end return osys, conseqs, consconsts, vars diff --git a/src/network_analysis.jl b/src/network_analysis.jl index 277319fe9d..8f8ded6a8a 100644 --- a/src/network_analysis.jl +++ b/src/network_analysis.jl @@ -900,7 +900,7 @@ function cache_conservationlaw_eqs!(rn::ReactionSystem, N::AbstractMatrix, col_o # Declare the conservation constant parameters #`using guesses is for consistency and possibly faster initialisation guesses = [Initial(depspecs[i] + rhs_terms[i]) for i in 1:nullity] - Γs = @parameters $(CONSERVED_CONSTANT_SYMBOL)[1:nullity] = missing [conserved = true, guess = guesses] + Γs = @parameters $(CONSERVED_CONSTANT_SYMBOL)[1:nullity] [conserved = true, guess = guesses] constants = unwrap(only(Γs)) # Creates the conservation constant and conservation equation equations. diff --git a/src/reactionsystem_conversions.jl b/src/reactionsystem_conversions.jl index cba5a24b5b..5186643485 100644 --- a/src/reactionsystem_conversions.jl +++ b/src/reactionsystem_conversions.jl @@ -510,7 +510,7 @@ end # merge constraint components with the ReactionSystem components # also handles removing BC and constant species function addconstraints!(eqs, rs::ReactionSystem, ists, ispcs; remove_conserved = false, - compute_cl_initeqs = false, include_cl_as_eqs = false) + include_cl_as_eqs = false) # if there are BC species, put them after the independent species rssts = get_unknowns(rs) sts = any(isbc, rssts) ? vcat(ists, filter(isbc, rssts)) : ists @@ -518,6 +518,7 @@ function addconstraints!(eqs, rs::ReactionSystem, ists, ispcs; remove_conserved initeqs = Equation[] ics = MT.initial_conditions(rs) obs = MT.observed(rs) + cl_bindings = Dict{Any, Any}() # make dependent species observables and add conservation constants as parameters if remove_conserved && !isempty(conservedequations(rs)) @@ -527,6 +528,18 @@ function addconstraints!(eqs, rs::ReactionSystem, ists, ispcs; remove_conserved ps = copy(ps) push!(ps, nps.conservedconst) + # Bind Γ => missing and provide initialization equations so MTK solves for Γ + # during initialization (see MTK docs on parameter initialization). + # The binding is placed at the System level (not as a variable metadata default) + # so that SI.jl doesn't encounter Missing in the parameter symtype. + cl_bindings[nps.conservedconst] = missing + if !include_cl_as_eqs + initialmap = Dict(u => Initial(u) for u in species(rs)) + for eq in nps.constantdefs + push!(initeqs, Symbolics.substitute(eq, initialmap)) + end + end + # add the dependent species as observed. If `include_cl_as_eqs = true` add them as # algebraic equations instead. if !include_cl_as_eqs @@ -535,13 +548,6 @@ function addconstraints!(eqs, rs::ReactionSystem, ists, ispcs; remove_conserved else append!(eqs, [0 ~ ceq.rhs - ceq.lhs for ceq in conservedequations(rs)]) end - - # create initialization equations (only used for nonlinear systems) - if compute_cl_initeqs && !include_cl_as_eqs - initialmap = Dict(u => Initial(u) for u in species(rs)) - conseqs = conservationlaw_constants(rs) - initeqs = [Symbolics.substitute(conseq, initialmap) for conseq in conseqs] - end end ceqs = Equation[eq for eq in get_eqs(rs) if eq isa Equation] @@ -558,7 +564,7 @@ function addconstraints!(eqs, rs::ReactionSystem, ists, ispcs; remove_conserved append!(eqs, ceqs) end - eqs, sts, ps, obs, ics, initeqs + eqs, sts, ps, obs, ics, initeqs, cl_bindings end ### Utility ### @@ -626,6 +632,7 @@ function hybrid_model(rs::ReactionSystem; combinatoric_ratelaws = get_combinatoric_ratelaws(rs), include_zero_odes = true, remove_conserved = false, + add_cl_bindings = true, expand_catalyst_funs = true, save_positions = (true, true), checks = false, @@ -715,8 +722,10 @@ function hybrid_model(rs::ReactionSystem; jumps = vcat(rxn_jumps, user_jumps) # --- Add constraints (BC species, constraint equations, conserved species) --- + initeqs = Equation[] + cl_bindings = Dict{Any, Any}() if has_continuous - eqs, us, ps, obs, ics = addconstraints!(eqs, flatrs, ists, ispcs; remove_conserved) + eqs, us, ps, obs, ics, initeqs, cl_bindings = addconstraints!(eqs, flatrs, ists, ispcs; remove_conserved) else # Pure jump case. any(isbc, get_unknowns(flatrs)) && @@ -729,12 +738,16 @@ function hybrid_model(rs::ReactionSystem; # --- Construct unified System --- # Note: brownians is a positional arg (5th) in the System constructor. + all_bindings = add_cl_bindings ? merge(MT.get_bindings(flatrs), cl_bindings) : + MT.get_bindings(flatrs) + all_initeqs = add_cl_bindings ? initeqs : Equation[] MT.System(eqs, get_iv(flatrs), us, ps, brownian_vars; poissonians = user_poissonians, jumps, observed = obs, name, - bindings = MT.get_bindings(flatrs), + initialization_eqs = all_initeqs, + bindings = all_bindings, initial_conditions = merge(initial_conditions, ics), checks, continuous_events = MT.get_continuous_events(flatrs), @@ -910,8 +923,8 @@ function ss_ode_model(rs::ReactionSystem; name = nameof(rs), ists, ispcs = get_indep_sts(fullrs, (remove_conserved && !include_cl_as_eqs)) eqs = assemble_drift(fullrs, ispcs; combinatoric_ratelaws, remove_conserved, as_odes = false, include_zero_odes = false, expand_catalyst_funs) - eqs, us, ps, obs, ics, initeqs = addconstraints!(eqs, fullrs, ists, ispcs; - remove_conserved, compute_cl_initeqs = !include_cl_as_eqs, include_cl_as_eqs) + eqs, us, ps, obs, ics, initeqs, cl_bindings = addconstraints!(eqs, fullrs, ists, ispcs; + remove_conserved, include_cl_as_eqs) # Comoutes the correct initial conditions and bindings. initial_conditions, bindings = MT.convert_bindings_for_time_independent_system(rs) @@ -924,7 +937,7 @@ function ss_ode_model(rs::ReactionSystem; name = nameof(rs), System(eqs, us, ps; name, observed = obs, initialization_eqs = initeqs, - bindings, + bindings = merge(bindings, cl_bindings), initial_conditions, checks, metadata = MT.get_metadata(rs), @@ -1026,12 +1039,14 @@ function sde_model(rs::ReactionSystem; remove_conserved, expand_catalyst_funs, use_jump_ratelaws) noiseeqs = assemble_diffusion(flatrs, ists, ispcs; combinatoric_ratelaws, remove_conserved, expand_catalyst_funs, use_jump_ratelaws) - eqs, us, ps, obs, ics = addconstraints!(eqs, flatrs, ists, ispcs; remove_conserved) + eqs, us, ps, obs, ics, initeqs, cl_bindings = addconstraints!(eqs, flatrs, ists, ispcs; remove_conserved) return MT.System(eqs, get_iv(flatrs), us, ps; noise_eqs = noiseeqs, observed = obs, name, + initialization_eqs = initeqs, + bindings = cl_bindings, initial_conditions = merge(initial_conditions, ics), checks, continuous_events = MT.get_continuous_events(flatrs), diff --git a/test/extensions/structural_identifiability.jl b/test/extensions/structural_identifiability.jl index c4becbc5e4..6c78530bd6 100644 --- a/test/extensions/structural_identifiability.jl +++ b/test/extensions/structural_identifiability.jl @@ -15,7 +15,7 @@ function sym_dict(dict_in) sym_key = Symbol(key) sym_key = Symbol(replace(String(sym_key), "(t)" => "")) dict_out[sym_key] = dict_in[key] - end + end return dict_out end @@ -24,7 +24,7 @@ end # Tests for Goodwin model (model with both global, local, and non identifiable components). # Tests for system using Catalyst function (in this case, Michaelis-Menten function) -let +@testset "Goodwin oscillator (Michaelis-Menten)" begin # Identifiability analysis for Catalyst model. goodwind_oscillator_catalyst = @reaction_network begin (mmr(P,pₘ,1), dₘ), 0 <--> M @@ -61,13 +61,13 @@ let @test isequal(collect(keys(gi_1)), [unknowns(goodwind_oscillator_catalyst); parameters(goodwind_oscillator_catalyst)]) @test isequal(collect(values(gi_1)), [:globally, :nonidentifiable, :globally, :globally, :globally, :nonidentifiable, :locally, :nonidentifiable, :locally]) @test isequal(collect(keys(li_1)), [unknowns(goodwind_oscillator_catalyst); parameters(goodwind_oscillator_catalyst)]) - @test isequal(collect(values(li_1)), [1, 0, 1, 1, 1, 0, 1, 0, 1]) + @test isequal(collect(values(li_1)), [1, 0, 1, 1, 1, 0, 1, 0, 1]) end # Tests on a made-up reaction network with mix of identifiable and non-identifiable components. # Tests for symbolics input. # Tests using known_p argument. -let +@testset "Chain network with known_p and symbolic measured quantities" begin # Identifiability analysis for Catalyst model. rs_catalyst = @reaction_network begin (p1, d), 0 <--> X1 @@ -110,14 +110,14 @@ let @test isequal(collect(keys(gi_1)),[unknowns(rs_catalyst); parameters(rs_catalyst)]) @test isequal(collect(values(gi_1)),[:nonidentifiable, :globally, :globally, :nonidentifiable, :nonidentifiable, :nonidentifiable, :nonidentifiable, :globally, :globally, :globally]) @test isequal(collect(keys(li_1)),[unknowns(rs_catalyst); parameters(rs_catalyst)]) - @test isequal(collect(values(li_1)),[0, 1, 1, 0, 0, 0, 0, 1, 1, 1]) + @test isequal(collect(values(li_1)),[0, 1, 1, 0, 0, 0, 0, 1, 1, 1]) end # Tests on a made-up reaction network with mix of identifiable and non-identifiable components. # Tests for system with conserved quantity. # Tests for symbolics known_p # Tests using an equation for measured quantity. -let +@testset "Conserved binding pair (composite measured quantity)" begin # Identifiability analysis for Catalyst model. rs_catalyst = @reaction_network begin p, 0 --> X1 @@ -170,7 +170,7 @@ let end # Tests that various inputs types work. -let +@testset "Input type variations (Symbol vs symbolic)" begin goodwind_oscillator_catalyst = @reaction_network begin (mmr(P,pₘ,1), dₘ), 0 <--> M (pₑ*M,dₑ), 0 <--> E @@ -205,7 +205,7 @@ let end # Tests for hierarchical model with conservation laws at both top and internal levels. -let +@testset "Hierarchical system with nested conservation laws" begin # Identifiability analysis for Catalyst model. rs1 = @network_component rs1 begin (k1, k2), X1 <--> X2 @@ -259,7 +259,7 @@ end # Tests directly on reaction systems with known identifiability structures. # Test provided by Alexander Demin. -let +@testset "Direct tests on small networks" begin rs = @reaction_network begin k1, x1 --> x2 end @@ -316,8 +316,8 @@ end ### Other Tests ### # Checks that identifiability can be assessed for coupled CRN/DAE systems. -# `remove_conserved = false` is used to remove info print statement from log. -let +# DAE systems (with algebraic equations) require `mtkcompile = true`. +@testset "Coupled CRN/DAE" begin rs = @reaction_network begin @parameters k c1 c2 @variables C(t) @@ -327,39 +327,106 @@ let end (p/V,d/V), 0 <--> X end - @unpack p, d, k, c1, c2 = rs - + @unpack X, V, p, d, k, c1, c2 = rs + + # After `mtkcompile`, the algebraic variable C becomes an observed equation + # and is no longer a state. SI.jl currently cannot handle observed variables + # in `funcs_to_check` (KeyError in eval_at_nemo). We pass explicit + # `funcs_to_check` excluding C to work around this upstream limitation. + ftc = [X, V, p, d, k, c1, c2] + # Tests identifiability assessment when all unknowns are measured. - remove_conserved = false - gi_1 = assess_identifiability(rs; measured_quantities = [:X, :V, :C], loglevel, remove_conserved) - li_1 = assess_local_identifiability(rs; measured_quantities = [:X, :V, :C], loglevel, remove_conserved) - ifs_1 = find_identifiable_functions(rs; measured_quantities = [:X, :V, :C], loglevel, remove_conserved) - @test sym_dict(gi_1) == Dict([:X => :globally, :C => :globally, :V => :globally, :k => :globally, + gi_1 = assess_identifiability(rs; measured_quantities = [:X, :V, :C], funcs_to_check = ftc, loglevel, remove_conserved = false, mtkcompile = true) + li_1 = assess_local_identifiability(rs; measured_quantities = [:X, :V, :C], funcs_to_check = ftc, loglevel, remove_conserved = false, mtkcompile = true) + ifs_1 = find_identifiable_functions(rs; measured_quantities = [:X, :V, :C], loglevel, remove_conserved = false, mtkcompile = true) + @test sym_dict(gi_1) == Dict([:X => :globally, :V => :globally, :k => :globally, :c1 => :nonidentifiable, :c2 => :nonidentifiable, :p => :globally, :d => :globally]) - @test sym_dict(li_1) == Dict([:X => 1, :C => 1, :V => 1, :k => 1, :c1 => 0, :c2 => 0, :p => 1, :d => 1]) + @test sym_dict(li_1) == Dict([:X => 1, :V => 1, :k => 1, :c1 => 0, :c2 => 0, :p => 1, :d => 1]) @test issetequal(ifs_1, [d, p, k, c1 + c2]) - - # Tests identifiability assessment when only variables are measured. + + # Tests identifiability assessment when only variables are measured. # Checks that a parameter in an equation can be set as known. - gi_2 = assess_identifiability(rs; measured_quantities = [:V, :C], known_p = [:c1], loglevel, remove_conserved) - li_2 = assess_local_identifiability(rs; measured_quantities = [:V, :C], known_p = [:c1], loglevel, remove_conserved) - ifs_2 = find_identifiable_functions(rs; measured_quantities = [:V, :C], known_p = [:c1], loglevel, remove_conserved) - @test sym_dict(gi_2) == Dict([:X => :nonidentifiable, :C => :globally, :V => :globally, :k => :nonidentifiable, + gi_2 = assess_identifiability(rs; measured_quantities = [:V, :C], known_p = [:c1], funcs_to_check = ftc, loglevel, remove_conserved = false, mtkcompile = true) + li_2 = assess_local_identifiability(rs; measured_quantities = [:V, :C], known_p = [:c1], funcs_to_check = ftc, loglevel, remove_conserved = false, mtkcompile = true) + ifs_2 = find_identifiable_functions(rs; measured_quantities = [:V, :C], known_p = [:c1], loglevel, remove_conserved = false, mtkcompile = true) + @test sym_dict(gi_2) == Dict([:X => :nonidentifiable, :V => :globally, :k => :nonidentifiable, :c1 => :globally, :c2 => :nonidentifiable, :p => :nonidentifiable, :d => :globally]) - @test sym_dict(li_2) == Dict([:X => 0, :C => 1, :V => 1, :k => 0, :c1 => 1, :c2 => 0, :p => 0, :d => 1]) + @test sym_dict(li_2) == Dict([:X => 0, :V => 1, :k => 0, :c1 => 1, :c2 => 0, :p => 0, :d => 1]) @test issetequal(ifs_2, [d, c1, k*p, c1*p + c2*p]) end # Checks that identifiability functions cannot be applied to non-complete `ReactionSystems`s. -let +@testset "Incomplete ReactionSystems raise" begin # Create model. incomplete_network = @network_component begin (p, d), 0 <--> X end measured_quantities = [:X] - + # Computes bifurcation diagram. @test_throws Exception assess_identifiability(incomplete_network; measured_quantities, loglevel) @test_throws Exception assess_local_identifiability(incomplete_network; measured_quantities, loglevel) @test_throws Exception find_identifiable_functions(incomplete_network; measured_quantities, loglevel) +end + +# Covers the `funcs_to_check` kwarg: when the caller passes a custom vector of +# expressions, the extension must route them through `make_ftc` and return an +# identifiability verdict keyed on exactly those expressions (and no others). +@testset "funcs_to_check kwarg" begin + rs = complete(@reaction_network begin + p, 0 --> X # open network: no conservation law + d, X --> 0 + end) + @unpack p, d = rs + out = assess_identifiability(rs; measured_quantities = [:X], + funcs_to_check = [p, d, p / d], loglevel) + @test length(out) == 3 + @test all(v -> v == :globally, values(out)) +end + +# Covers the `ignore_no_measured_warn` kwarg and the corresponding @warn path. +# `known_p` is passed so SI has something non-empty to feed mtk_to_si; the +# warning path only cares that the explicit `measured_quantities` kwarg is +# empty. +@testset "measured_quantities warning" begin + rs = complete(@reaction_network begin + p, 0 --> X # open network; picked to avoid upstream Issue 1 + d, X --> 0 + end) + # Default: warning fires when `measured_quantities` is empty. + @test_logs (:warn, r"No measured quantity") match_mode=:any make_si_ode(rs; known_p = [:p]) + # Opt-out: no warning when `ignore_no_measured_warn = true`. + @test_logs min_level=Logging.Warn make_si_ode(rs; known_p = [:p], + ignore_no_measured_warn = true) +end + +# Regression: `make_osys` must return `Vector{Pair{Any, Any}}` for `conseqs` and +# `consconsts` in every branch, matching the type guarantee stated in the comment +# above the branch. Previously the empty-branch path produced +# `Vector{Vector{Pair{Any, Any}}}` (a nested empty vector) which silently leaked +# into downstream callers that inspect or append to the vector. +@testset "make_osys return type invariants" begin + ext = Base.get_extension(Catalyst, :CatalystStructuralIdentifiabilityExtension) + + # Network with no conservation laws. + rs_open = complete(@reaction_network begin + p, 0 --> X + d, X --> 0 + end) + for rc in (true, false) + _, conseqs, consconsts, _ = ext.make_osys(rs_open; remove_conserved = rc) + @test conseqs isa Vector{Pair{Any, Any}} + @test consconsts isa Vector{Pair{Any, Any}} + end + + # Network with a conservation law. The type invariant must hold regardless + # of whether conservation laws were removed. + rs_closed = complete(@reaction_network begin + k1, x1 --> x2 + end) + for rc in (true, false) + _, conseqs, consconsts, _ = ext.make_osys(rs_closed; remove_conserved = rc) + @test conseqs isa Vector{Pair{Any, Any}} + @test consconsts isa Vector{Pair{Any, Any}} + end end \ No newline at end of file