diff --git a/Project.toml b/Project.toml index ac81ff2b40..c3d7af0fd5 100644 --- a/Project.toml +++ b/Project.toml @@ -31,7 +31,7 @@ CliqueTrees = "1.17.1" CodecBzip2 = "0.6, 0.7, 0.8" CodecZlib = "0.6, 0.7" ForwardDiff = "1" -JSON = "0.21, 1" +JSON = "1" JSONSchema = "1" JuliaC = "0.3" LinearAlgebra = "1" diff --git a/src/FileFormats/MOF/MOF.jl b/src/FileFormats/MOF/MOF.jl index d7290faf8f..e011a6108b 100644 --- a/src/FileFormats/MOF/MOF.jl +++ b/src/FileFormats/MOF/MOF.jl @@ -129,7 +129,7 @@ function get_options(m::Model) nothing, false, ), - ) + )::Options end """ diff --git a/src/FileFormats/MOF/write.jl b/src/FileFormats/MOF/write.jl index 3e4dfc2c43..41fe2b1c5f 100644 --- a/src/FileFormats/MOF/write.jl +++ b/src/FileFormats/MOF/write.jl @@ -24,23 +24,68 @@ function Base.write(io::IO, model::Model) objective, has_scalar_nonlinear = _write_objective(model, name_map) end has_scalar_nonlinear |= _write_constraints(constraints, model, name_map) - object = (; - name = "MathOptFormat Model", - version = ( - major = Int(_SUPPORTED_VERSIONS[1].major), - minor = Int(_SUPPORTED_VERSIONS[1].minor), - ), - variables = variables, - objective = objective, - constraints = constraints, + version = ( + major = Int(_SUPPORTED_VERSIONS[1].major), + minor = Int(_SUPPORTED_VERSIONS[1].minor), ) - if has_scalar_nonlinear - object = (; has_scalar_nonlinear = true, object...) + # Construct both layouts directly instead of merging a tuple whose + # objective type may not be inferred. + object = if has_scalar_nonlinear + (; + has_scalar_nonlinear = true, + name = "MathOptFormat Model", + version = version, + variables = variables, + objective = objective, + constraints = constraints, + ) + else + (; + name = "MathOptFormat Model", + version = version, + variables = variables, + objective = objective, + constraints = constraints, + ) end - Base.write(io, JSON.json(object)) + tree = _json_tree(object)::JSON.Object{String,Any} + Base.write(io, JSON.json(tree)) return end +# Keep object shapes in data, rather than in NamedTuple types, so JSON can +# traverse heterogeneous records with its existing Any-value fast paths. +@noinline function _json_tree(@nospecialize(value)) + if value isa NamedTuple + object = JSON.Object{String,Any}() + # Inline accessors so trimming does not need the concrete record type. + names = @inline keys(value) + for i in 1:nfields(value) + name = getfield(names, i) + object[String(name)] = _json_tree(getfield(value, i)) + end + return object + elseif value isa Vector && !(value isa AbstractVector{<:Pair}) + # Vectors of pairs are JSON objects, not arrays. + array = Any[] + sizehint!(array, length(value)) + for i in eachindex(value) + # Like JSON, write undefined array entries as null. + item = try + @inline @inbounds value[i] + catch err + if !(err isa UndefRefError) + rethrow() + end + nothing + end + push!(array, _json_tree(item)) + end + return array + end + return value +end + function _write_variables(variables::Vector{NamedTuple}, model::Model) name_map = Dict{MOI.VariableIndex,String}() for index in MOI.get(model, MOI.ListOfVariableIndices()) @@ -132,30 +177,220 @@ function _write_objective( if sense == MOI.FEASIBILITY_SENSE return (; :sense => moi_to_object(sense)), false end + return _write_objective(model, name_map, sense) +end + +function _write_objective(model, name_map, sense) F = MOI.get(model, MOI.ObjectiveFunctionType()) - objective_function = MOI.get(model, MOI.ObjectiveFunction{F}()) + return _write_objective( + MOI.get(model, MOI.ObjectiveFunction{F}()), + name_map, + sense, + ) +end + +function _write_objective( + model::MOI.Utilities.UniversalFallback, + name_map, + sense, +) + if model.objective === nothing + return _write_objective(model.model, name_map, sense) + end + return _write_objective(something(model.objective), name_map, sense) +end + +function _write_objective(model::MOI.Utilities.GenericModel, name_map, sense) + return _write_objective(model.objective, name_map, sense) +end + +function _write_objective( + objective::MOI.Utilities.ObjectiveContainer{T}, + name_map, + sense, +) where {T} + # Build the complete objective inside each branch, while the function type + # is concrete. Returning the function first would merge its types again. + if objective.scalar_affine !== nothing + return _write_objective( + something(objective.scalar_affine), + name_map, + sense, + ) + elseif objective.single_variable !== nothing + return _write_objective( + something(objective.single_variable), + name_map, + sense, + ) + elseif objective.scalar_quadratic !== nothing + return _write_objective( + something(objective.scalar_quadratic), + name_map, + sense, + ) + elseif objective.scalar_nonlinear !== nothing + return _write_objective( + something(objective.scalar_nonlinear), + name_map, + sense, + ) + elseif objective.vector_variables !== nothing + return _write_objective( + something(objective.vector_variables), + name_map, + sense, + ) + elseif objective.vector_affine !== nothing + return _write_objective( + something(objective.vector_affine), + name_map, + sense, + ) + elseif objective.vector_quadratic !== nothing + return _write_objective( + something(objective.vector_quadratic), + name_map, + sense, + ) + elseif objective.vector_nonlinear !== nothing + return _write_objective( + something(objective.vector_nonlinear), + name_map, + sense, + ) + end + return _write_objective(zero(MOI.ScalarAffineFunction{T}), name_map, sense) +end + +function _write_objective(f::MOI.AbstractFunction, name_map, sense) object = (; :sense => moi_to_object(sense), - :function => moi_to_object(objective_function, name_map), + :function => moi_to_object(f, name_map), ) - return object, (F == MOI.ScalarNonlinearFunction) + return object, f isa MOI.ScalarNonlinearFunction end function _write_constraints( constraints::Vector{NamedTuple}, model::Model, name_map::Dict{MOI.VariableIndex,String}, + storage = model, ) has_scalar_nonlinear = false - for (F, S) in MOI.get(model, MOI.ListOfConstraintTypesPresent()) - has_scalar_nonlinear |= (F == MOI.ScalarNonlinearFunction) - for index in MOI.get(model, MOI.ListOfConstraintIndices{F,S}()) - push!(constraints, moi_to_object(index, model, name_map)) - end + for (F, S) in MOI.get(storage, MOI.ListOfConstraintTypesPresent()) + has_scalar_nonlinear |= + _write_constraints(constraints, model, name_map, storage, F, S) end return has_scalar_nonlinear end +function _write_constraints( + constraints, + model, + name_map, + storage, + ::Type{F}, + ::Type{S}, +) where {F,S} + indices = MOI.get(storage, MOI.ListOfConstraintIndices{F,S}()) + for index in indices + # Query the outer model for attributes held by UniversalFallback. + push!(constraints, moi_to_object(index, model, name_map)) + end + return F == MOI.ScalarNonlinearFunction && !isempty(indices) +end + +function _write_constraints( + constraints::Vector{NamedTuple}, + model::Model, + name_map::Dict{MOI.VariableIndex,String}, + storage::MOI.Utilities.UniversalFallback, +) + has_scalar_nonlinear = + _write_constraints(constraints, model, name_map, storage.model) + for S in keys(storage.single_variable_constraints) + has_scalar_nonlinear |= _write_constraints( + constraints, + model, + name_map, + storage, + MOI.VariableIndex, + S, + ) + end + for inner in values(storage.constraints) + has_scalar_nonlinear |= + _write_constraints(constraints, model, name_map, inner) + end + return has_scalar_nonlinear +end + +function _write_constraints( + constraints::Vector{NamedTuple}, + model::Model, + name_map::Dict{MOI.VariableIndex,String}, + storage::MOI.Utilities.GenericModel, +) + has_scalar_nonlinear = + _write_constraints(constraints, model, name_map, storage.constraints) + has_scalar_nonlinear |= + _write_constraints(constraints, model, name_map, storage.variables) + return has_scalar_nonlinear +end + +function _write_constraints( + constraints::Vector{NamedTuple}, + model::Model, + name_map::Dict{MOI.VariableIndex,String}, + storage::MOI.Utilities.VariablesContainer{T}, +) where {T} + function write_bound(::Type{S}) where {S} + return _write_constraints( + constraints, + model, + name_map, + storage, + MOI.VariableIndex, + S, + ) + end + # Match ListOfConstraintTypesPresent order. A tuple of types would erase + # the concrete set types, so call the typed helper for each set directly. + write_bound(MOI.EqualTo{T}) + write_bound(MOI.GreaterThan{T}) + write_bound(MOI.LessThan{T}) + write_bound(MOI.Interval{T}) + write_bound(MOI.Semicontinuous{T}) + write_bound(MOI.Semiinteger{T}) + write_bound(MOI.Integer) + write_bound(MOI.ZeroOne) + write_bound(MOI.Parameter{T}) + return false +end + +function _write_constraints( + constraints::Vector{NamedTuple}, + model::Model, + name_map::Dict{MOI.VariableIndex,String}, + storage::MOI.Utilities.StructOfConstraints, +) + ret = MOI.Utilities.mapreduce_constraints(|, storage, false) do inner + return inner !== nothing && + _write_constraints(constraints, model, name_map, inner) + end + return something(ret, false) +end + +function _write_constraints( + constraints::Vector{NamedTuple}, + model::Model, + name_map::Dict{MOI.VariableIndex,String}, + storage::MOI.Utilities.VectorOfConstraints{F,S}, +) where {F,S} + return _write_constraints(constraints, model, name_map, storage, F, S) +end + """ moi_to_object(x, model::Model) @@ -193,11 +428,12 @@ function moi_to_object( end push!(pairs, :function => moi_to_object(func, name_map)) push!(pairs, :set => moi_to_object(set, name_map)) + # Start values have type Any, so construct the destination pair directly. if !isnothing(dual_start) - push!(pairs, :dual_start => dual_start) + push!(pairs, Pair{Symbol,Any}(:dual_start, dual_start)) end if !isnothing(primal_start) - push!(pairs, :primal_start => primal_start) + push!(pairs, Pair{Symbol,Any}(:primal_start, primal_start)) end return NamedTuple(pairs) end @@ -444,11 +680,9 @@ function moi_to_object( set::SetType, ::Dict{MOI.VariableIndex,String}, ) where {SetType} - pairs = Pair{Symbol,Any}[:type=>head_name(SetType)] - for key in fieldnames(SetType) - push!(pairs, Symbol(string(key)) => getfield(set, key)) - end - return NamedTuple(pairs) + names = fieldnames(SetType) + values = map(name -> getfield(set, name), names) + return NamedTuple{(:type, names...)}((head_name(SetType), values...)) end # ========== Non-typed scalar sets ========== diff --git a/src/FileFormats/utils.jl b/src/FileFormats/utils.jl index f33904189b..7aee5af3e5 100644 --- a/src/FileFormats/utils.jl +++ b/src/FileFormats/utils.jl @@ -8,11 +8,64 @@ ### Re-naming utilities ### +# Visit non-variable constraint types in storage order. Walking concrete +# storage preserves F and S for callers, unlike ListOfConstraintTypesPresent. +function _for_each_constraint_type(f::T, model::MOI.ModelLike) where {T} + for (F, S) in MOI.get(model, MOI.ListOfConstraintTypesPresent()) + if F != MOI.VariableIndex + f(F, S) + end + end + return +end + +function _for_each_constraint_type( + f::T, + model::MOI.Utilities.GenericModel, +) where {T} + _for_each_constraint_type(f, model.constraints) + return +end + +function _for_each_constraint_type( + f::T, + model::MOI.Utilities.StructOfConstraints, +) where {T} + MOI.Utilities.broadcastcall(model) do inner + if inner !== nothing + _for_each_constraint_type(f, inner) + end + return + end + return +end + +function _for_each_constraint_type( + f::T, + model::MOI.Utilities.VectorOfConstraints{F,S}, +) where {T,F,S} + if F != MOI.VariableIndex && !MOI.is_empty(model) + f(F, S) + end + return +end + +function _for_each_constraint_type( + f::T, + model::MOI.Utilities.UniversalFallback, +) where {T} + _for_each_constraint_type(f, model.model) + for inner in values(model.constraints) + _for_each_constraint_type(f, inner) + end + return +end + """ create_unique_names( model::MOI.ModelLike; warn::Bool = false, - replacements::Vector{Function} = Function[], + replacements = (), ) Rename variables in `model` to ensure that all variables and constraints have @@ -24,7 +77,7 @@ If `warn`, print a warning if a variable or constraint is renamed. function create_unique_names( model::MOI.ModelLike; warn::Bool = false, - replacements::Vector{Function} = Function[], + replacements = (), ) create_unique_variable_names(model, warn, replacements) create_unique_constraint_names(model, warn, replacements) @@ -52,11 +105,9 @@ function create_generic_variable_names(model::MOI.ModelLike) end function create_generic_constraint_names(model::MOI.ModelLike) - i = 1 - for (F, S) in MOI.get(model, MOI.ListOfConstraintTypesPresent()) - if F != MOI.VariableIndex - i = create_generic_constraint_names(model, F, S, i) - end + i = Ref(1) + _for_each_constraint_type(model) do F, S + return i[] = create_generic_constraint_names(model, F, S, i[]) end return end @@ -74,7 +125,7 @@ function create_generic_constraint_names( return i end -function _replace(s::String, replacements::Vector{Function}) +function _replace(s::String, replacements) for f in replacements s = f(s) end @@ -170,15 +221,21 @@ end function create_unique_constraint_names( model::MOI.ModelLike, warn::Bool, - replacements::Vector{Function}, + replacements, ) original_names = Set{String}() - for (F, S) in MOI.get(model, MOI.ListOfConstraintTypesPresent()) - _get_original_names_inner(model, replacements, original_names, F, S) + _for_each_constraint_type(model) do F, S + return _get_original_names_inner( + model, + replacements, + original_names, + F, + S, + ) end added_names = Set{String}() - for (F, S) in MOI.get(model, MOI.ListOfConstraintTypesPresent()) - _create_unique_constraint_names_inner( + _for_each_constraint_type(model) do F, S + return _create_unique_constraint_names_inner( model, warn, replacements, @@ -194,7 +251,7 @@ end function create_unique_variable_names( model::MOI.ModelLike, warn::Bool, - replacements::Vector{Function}, + replacements, ) variables = MOI.get(model, MOI.ListOfVariableIndices()) # This is a list of all of the names currently in the model. We're going to diff --git a/src/Utilities/mutable_arithmetics.jl b/src/Utilities/mutable_arithmetics.jl index c8c8019db3..ed37a2996a 100644 --- a/src/Utilities/mutable_arithmetics.jl +++ b/src/Utilities/mutable_arithmetics.jl @@ -37,6 +37,10 @@ function MA.mutable_copy(func::MOI.ScalarQuadraticFunction) ) end +function MA.mutable_copy(func::TypedVectorLike) + return operate_coefficients(MA.copy_if_mutable, func) +end + function MA.isequal_canonical( f::F, g::F, diff --git a/test/FileFormats/MOF/test_MOF.jl b/test/FileFormats/MOF/test_MOF.jl index fc2ebbc770..88388c14c0 100644 --- a/test/FileFormats/MOF/test_MOF.jl +++ b/test/FileFormats/MOF/test_MOF.jl @@ -60,6 +60,24 @@ struct UnsupportedSet <: MOI.AbstractSet end struct UnsupportedFunction <: MOI.AbstractFunction end +struct NamedFieldsSet{T} <: MOI.AbstractScalarSet + lower_bound::T + count::Int +end + +MOF.head_name(::Type{<:NamedFieldsSet}) = "NamedFieldsSet" + +struct ObjectiveStorage{T} + inner::T +end + +function MOI.get( + storage::ObjectiveStorage, + attr::Union{MOI.ObjectiveFunctionType,MOI.ObjectiveFunction}, +) + return MOI.get(storage.inner, attr) +end + function _test_model_equality( model_string, variables, @@ -332,15 +350,33 @@ function test_nonempty_model() model, joinpath(@__DIR__, "empty_model.mof.json"), ) - options = MOF.get_options(model) + options = @inferred MOF.get_options(model) @test options.warn MOI.empty!(model) @test MOI.is_empty(model) MOI.read_from_file(model, joinpath(@__DIR__, "empty_model.mof.json")) - options2 = MOF.get_options(model) + options2 = @inferred MOF.get_options(model) @test options2.warn end +function test_set_object_inference() + names = Dict{MOI.VariableIndex,String}() + @test (@inferred MOF.moi_to_object(MOI.Integer(), names)) == + (type = "Integer",) + @test (@inferred MOF.moi_to_object(MOI.LessThan(2.0), names)) == + (type = "LessThan", upper = 2.0) + @test (@inferred MOF.moi_to_object(MOI.Interval(1.0f0, 2.0f0), names)) == + (type = "Interval", lower = 1.0f0, upper = 2.0f0) + set = MOI.SOS1([1.0, 2.0]) + object = @inferred MOF.moi_to_object(set, names) + @test object == (type = "SOS1", weights = [1.0, 2.0]) + @test object.weights === set.weights + set = NamedFieldsSet(1.0f0, 2) + @test (@inferred MOF.moi_to_object(set, names)) == + (type = "NamedFieldsSet", lower_bound = 1.0f0, count = 2) + return +end + function test_failing_models() @testset "$(filename)" for filename in filter( f -> endswith(f, ".mof.json"), @@ -421,6 +457,111 @@ function test_FEASIBILITY_SENSE() return MOI.Test.util_test_models_equal(model, model_2, ["x"], String[]) end +function test_write_document_layout() + for mode in + (:feasibility, :linear, :nonlinear_objective, :nonlinear_constraint) + model = MOF.Model() + x = MOI.add_variable(model) + MOI.set(model, MOI.VariableName(), x, "x") + name_map = Dict(x => "x") + nonlinear = MOI.ScalarNonlinearFunction(:sin, Any[x]) + objective = (sense = "feasibility",) + if mode != :feasibility + f = mode == :nonlinear_objective ? nonlinear : 1.0 * x + MOI.set(model, MOI.ObjectiveSense(), MOI.MIN_SENSE) + MOI.set(model, MOI.ObjectiveFunction{typeof(f)}(), f) + objective = + (; :sense => "min", :function => MOF.moi_to_object(f, name_map)) + end + constraints = NamedTuple[] + if mode == :nonlinear_constraint + c = MOI.add_constraint(model, nonlinear, MOI.LessThan(1.0)) + MOI.set(model, MOI.ConstraintName(), c, "c") + push!(constraints, MOF.moi_to_object(c, model, name_map)) + end + version = MOF._SUPPORTED_VERSIONS[1] + expected = ( + name = "MathOptFormat Model", + version = (major = Int(version.major), minor = Int(version.minor)), + variables = [(name = "x",)], + objective = objective, + constraints = constraints, + ) + has_nonlinear = mode in (:nonlinear_objective, :nonlinear_constraint) + if has_nonlinear + expected = (; has_scalar_nonlinear = true, expected...) + end + # Compare bytes as well as the flag, including root field order. + contents = sprint(write, model) + @test contents == JSON.json(expected) + @test haskey(JSON.parse(contents), "has_scalar_nonlinear") == + has_nonlinear + end + return +end + +struct JSONNumber <: Number + text::String +end + +JSON.tostring(value::JSONNumber) = value.text + +function test_json_tree_nested_records() + object = (z = (b = Any[(last = 1,), (first = "x",)], a = 2), m = [3, 4]) + tree = MOF._json_tree(object) + @test tree isa JSON.Object{String,Any} + @test tree["z"] isa JSON.Object{String,Any} + @test tree["z"]["b"] isa Vector{Any} + @test all(item -> item isa JSON.Object{String,Any}, tree["z"]["b"]) + @test tree["m"] isa Vector{Any} + @test JSON.json(tree) == JSON.json(object) + @test JSON.json(tree) == + "{\"z\":{\"b\":[{\"last\":1},{\"first\":\"x\"}],\"a\":2},\"m\":[3,4]}" + @test isempty(MOF._json_tree((;))) + @test MOF._json_tree(NamedTuple[]) == Any[] + return +end + +function test_json_tree_preserves_values() + values = Any[ + nothing, + true, + 2, + Float32(1.5), + big"123456789012345678901234567890", + big"1.234567890123456789", + 1//3, + 1+2im, + JSONNumber("0.125"), + "quoted \" Unicode ☃", + :symbol, + ] + object = (values = values,) + tree = MOF._json_tree(object) + for (original, normalized) in zip(values, tree["values"]) + @test normalized === original + end + @test JSON.json(tree) == JSON.json(object) + @test JSON.json(tree["values"][9]) == "0.125" + return +end + +function test_json_tree_vector_edge_cases() + values = Vector{Any}(undef, 2) + values[2] = (value = 3,) + tree = MOF._json_tree(values) + @test tree[1] === nothing + @test tree[2] isa JSON.Object{String,Any} + @test !isassigned(values, 1) + @test JSON.json(tree) == JSON.json(values) == "[null,{\"value\":3}]" + pairs = ["z" => (b = 1,), "a" => (value = 2,)] + @test MOF._json_tree(pairs) === pairs + @test JSON.json(MOF._json_tree((values = pairs,))) == + JSON.json((values = pairs,)) + @test JSON.json(pairs) == "{\"z\":{\"b\":1},\"a\":{\"value\":2}}" + return +end + function test_empty_function_term() model = MOF.Model() x = MOI.add_variable(model) @@ -460,6 +601,115 @@ function test_max_objective() ) end +function test_write_objective_storage_traversal() + model = MOF.Model() + x, y = MOI.add_variables(model, 2) + name_map = Dict(x => "x", y => "y") + affine = 2.0 * x + y + 3.0 + quadratic = 2.0 * x * y + affine + nonlinear = MOI.ScalarNonlinearFunction(:sin, Any[x]) + @test (@inferred MOF._write_objective(affine, name_map, MOI.MIN_SENSE)) == ( + (; :sense => "min", :function => MOF.moi_to_object(affine, name_map)), + false, + ) + functions = ( + x, + affine, + quadratic, + nonlinear, + MOI.VectorOfVariables([x, y]), + MOI.Utilities.vectorize([affine, 3.0 * y]), + MOI.Utilities.vectorize([quadratic, 3.0 * y * y]), + MOI.VectorNonlinearFunction([nonlinear]), + ) + MOI.set(model, MOI.ObjectiveSense(), MOI.MIN_SENSE) + for f in functions + MOI.set(model, MOI.ObjectiveFunction{typeof(f)}(), f) + expected = + (; :sense => "min", :function => MOF.moi_to_object(f, name_map)) + has_scalar_nonlinear = f isa MOI.ScalarNonlinearFunction + @test model.objective === nothing + @test MOF._write_objective(model, name_map, MOI.MIN_SENSE) == + (expected, has_scalar_nonlinear) + @test MOF._write_objective(model, name_map) == + (expected, has_scalar_nonlinear) + end + return +end + +function test_write_objective_default_and_feasibility() + for T in (Float64, Int) + model = MOF.Model(; coefficient_type = T) + name_map = Dict{MOI.VariableIndex,String}() + expected = + MOF.moi_to_object(zero(MOI.ScalarAffineFunction{T}), name_map) + for (sense, text) in ((MOI.MIN_SENSE, "min"), (MOI.MAX_SENSE, "max")) + MOI.set(model, MOI.ObjectiveSense(), sense) + @test MOF._write_objective(model, name_map) == + ((; :sense => text, :function => expected), false) + end + MOI.set(model, MOI.ObjectiveSense(), MOI.FEASIBILITY_SENSE) + x = MOI.add_variable(model) + # A stored objective must not be serialized for feasibility sense. The + # empty name map would make serialization of either objective fail. + for f in ( + MOI.ScalarNonlinearFunction(:sin, Any[x]), + MOF.Nonlinear(:(sin($x))), + ) + MOI.set(model, MOI.ObjectiveFunction{typeof(f)}(), f) + @test MOF._write_objective(model, name_map) == + ((; sense = "feasibility"), false) + end + end + return +end + +function test_write_objective_universal_fallback() + model = MOF.Model() + x = MOI.add_variable(model) + name_map = Dict(x => "x") + MOI.set(model, MOI.ObjectiveSense(), MOI.MAX_SENSE) + affine = 2.0 * x + 3.0 + # Integer coefficients and legacy nonlinear expressions are stored by the + # fallback, whereas Float64 affine objectives use the inner model. + for f in (affine, 4 * x + 5, affine, MOF.Nonlinear(:(sin($x))), affine) + MOI.set(model, MOI.ObjectiveFunction{typeof(f)}(), f) + @test (model.objective === nothing) == + (f isa MOI.ScalarAffineFunction{Float64}) + expected = MOF.moi_to_object(f, name_map) + @test MOF._write_objective(model, name_map) == + ((; :sense => "max", :function => expected), false) + end + return +end + +function test_write_objective_generic_storage() + inner = MOI.Utilities.Model{Float64}() + x = MOI.add_variable(inner) + name_map = Dict(x => "x") + # GenericModel permits custom objective storage, even without ModelLike. + objective = ObjectiveStorage(inner.objective) + model = MOI.Utilities.GenericModel{Float64}( + objective, + inner.variables, + inner.constraints, + ) + for f in (2.0 * x + 3.0, MOI.ScalarNonlinearFunction(:sin, Any[x])) + MOI.set(inner, MOI.ObjectiveFunction{typeof(f)}(), f) + expected = ( + (; :sense => "min", :function => MOF.moi_to_object(f, name_map)), + f isa MOI.ScalarNonlinearFunction, + ) + @test MOF._write_objective(model, name_map, MOI.MIN_SENSE) == expected + @test MOF._write_objective( + MOI.Utilities.UniversalFallback(model), + name_map, + MOI.MIN_SENSE, + ) == expected + end + return +end + function test_min_scalaraffine() return _test_model_equality( """ @@ -1318,6 +1568,263 @@ function test_constraint_start_vector() return end +MOI.Utilities.@struct_of_constraints_by_function_types(EmptyConstraintStorage) + +function test_write_constraints_empty_struct() + constraints = NamedTuple[] + @test MOF._write_constraints( + constraints, + MOF.Model(), + Dict{MOI.VariableIndex,String}(), + EmptyConstraintStorage{Float64}(), + ) === false + @test isempty(constraints) + return +end + +function _constraint_objects_reference(model, name_map) + constraints = NamedTuple[] + for (F, S) in MOI.get(model, MOI.ListOfConstraintTypesPresent()) + for ci in MOI.get(model, MOI.ListOfConstraintIndices{F,S}()) + push!(constraints, MOF.moi_to_object(ci, model, name_map)) + end + end + return constraints +end + +function test_write_constraints_storage_traversal() + model = MOF.Model() + x, y, z = MOI.add_variables(model, 3) + name_map = Dict(x => "x", y => "y", z => "z") + for (variable, name) in name_map + MOI.set(model, MOI.VariableName(), variable, name) + end + # Add the different stores in an order that differs from their traversal. + fallback_bound = MOI.add_constraint(model, z, NamedFieldsSet(-2.0, 3)) + quadratic = MOI.add_constraint(model, 2.0 * x * y, MOI.GreaterThan(-1.0)) + fallback_affine = MOI.add_constraint(model, 3.0 * y, NamedFieldsSet(0.0, 2)) + bound = MOI.add_constraint(model, x, MOI.Interval(-1.0, 2.0)) + affine = MOI.add_constraint(model, 1.0 * x + y, MOI.LessThan(2.0)) + affine_2 = MOI.add_constraint(model, 2.0 * x + y, MOI.LessThan(3.0)) + nonlinear = MOI.add_constraint( + model, + MOI.ScalarNonlinearFunction(:sin, Any[x]), + MOI.EqualTo(0.0), + ) + fallback_nonlinear = MOI.add_constraint( + model, + MOI.ScalarNonlinearFunction(:cos, Any[y]), + NamedFieldsSet(0.0, 1), + ) + fallback_vector = MOI.add_constraint( + model, + MOI.VectorOfVariables([z, y]), + MOI.Indicator{MOI.ACTIVATE_ON_ONE}(MOI.LessThan(2.0)), + ) + for (ci, name) in [ + quadratic => "quadratic", + fallback_affine => "fallback_affine", + affine => "affine", + affine_2 => "affine_2", + nonlinear => "nonlinear", + fallback_nonlinear => "fallback_nonlinear", + fallback_vector => "fallback_vector", + ] + MOI.set(model, MOI.ConstraintName(), ci, name) + end + for ci in + [bound, fallback_bound, affine, fallback_affine, fallback_nonlinear] + MOI.set(model, MOI.ConstraintPrimalStart(), ci, 1.5) + MOI.set(model, MOI.ConstraintDualStart(), ci, -2.5) + end + MOI.set(model, MOI.ConstraintPrimalStart(), fallback_vector, [1.0, 2.0]) + MOI.set(model, MOI.ConstraintDualStart(), fallback_vector, [3.0, 4.0]) + reference = _constraint_objects_reference(model, name_map) + constraints = NamedTuple[] + @test MOF._write_constraints(constraints, model, name_map) + @test constraints == reference + @test length(constraints) == 9 + empty!(constraints) + storage = MOI.Utilities.ModelFilter(_ -> true, model) + @test MOF._write_constraints(constraints, model, name_map, storage) + @test constraints == reference + empty!(constraints) + @test !(@inferred MOF._write_constraints( + constraints, + model, + name_map, + storage, + MOI.ScalarAffineFunction{Float64}, + MOI.LessThan{Float64}, + )) + @test constraints == [ + MOF.moi_to_object(affine, model, name_map), + MOF.moi_to_object(affine_2, model, name_map), + ] + object = JSON.parse(sprint(write, model)) + @test object["constraints"] == JSON.parse(JSON.json(reference)) + @test object["has_scalar_nonlinear"] + return +end + +function test_write_constraints_variable_bounds() + for T in (Float32, Float64) + model = MOF.Model(; coefficient_type = T) + x = MOI.add_variables(model, 10) + name_map = Dict(x[i] => "x$i" for i in eachindex(x)) + for (variable, name) in name_map + MOI.set(model, MOI.VariableName(), variable, name) + end + sets = ( + MOI.EqualTo(T(1)), + MOI.GreaterThan(T(2)), + MOI.LessThan(T(3)), + MOI.Interval(T(4), T(5)), + MOI.Semicontinuous(T(6), T(7)), + MOI.Semiinteger(T(8), T(9)), + MOI.Integer(), + MOI.ZeroOne(), + MOI.Parameter(T(10)), + ) + # Neither insertion order nor variable order is the traversal order. + for i in reverse(eachindex(sets)) + MOI.add_constraint(model, x[i], sets[i]) + end + MOI.add_constraint(model, x[10], MOI.GreaterThan(T(-1))) + bound = MOI.add_constraint(model, x[2], MOI.LessThan(T(11))) + # Starts are stored by UniversalFallback, not VariablesContainer. + MOI.set(model, MOI.ConstraintPrimalStart(), bound, T(3)) + MOI.set(model, MOI.ConstraintDualStart(), bound, T(-4)) + constraints = NamedTuple[] + @test !(@inferred MOF._write_constraints( + constraints, + model, + name_map, + model.model.variables, + )) + @test constraints == _constraint_objects_reference(model, name_map) + @test [object.set.type for object in constraints] == [ + "EqualTo", + "GreaterThan", + "GreaterThan", + "LessThan", + "LessThan", + "Interval", + "Semicontinuous", + "Semiinteger", + "Integer", + "ZeroOne", + "Parameter", + ] + @test [object[:function].name for object in constraints] == [ + "x1", + "x2", + "x10", + "x2", + "x3", + "x4", + "x5", + "x6", + "x7", + "x8", + "x9", + ] + @test all( + value isa T for object in constraints for + value in Base.tail(values(object.set)) + ) + @test constraints[4].primal_start === T(3) + @test constraints[4].dual_start === T(-4) + @test !haskey(constraints[2], :primal_start) + object = JSON.parse(sprint(write, model)) + @test object["constraints"] == JSON.parse(JSON.json(constraints)) + @test !haskey(object, "has_scalar_nonlinear") + end + return +end + +function test_write_constraints_variable_bounds_deleted() + model = MOF.Model() + name_map = Dict{MOI.VariableIndex,String}() + constraints = NamedTuple[] + @test !MOF._write_constraints( + constraints, + model, + name_map, + model.model.variables, + ) + @test isempty(constraints) + x, y = MOI.add_variables(model, 2) + name_map[x], name_map[y] = "x", "y" + MOI.add_constraint(model, x, MOI.EqualTo(1.0)) + lower = MOI.add_constraint(model, y, MOI.GreaterThan(0.0)) + upper = MOI.add_constraint(model, y, MOI.LessThan(2.0)) + MOI.delete(model, x) + MOI.delete(model, upper) + @test !MOF._write_constraints(constraints, model, name_map) + @test constraints == _constraint_objects_reference(model, name_map) + @test constraints == [MOF.moi_to_object(lower, model, name_map)] + MOI.delete(model, lower) + empty!(constraints) + @test !MOF._write_constraints(constraints, model, name_map) + @test isempty(constraints) + return +end + +function test_write_constraints_empty_nonlinear_storage() + # Both concrete inner stores and fallback stores retain their type after + # deleting the final constraint. Neither should set has_scalar_nonlinear. + for set in (MOI.EqualTo(0.0), NamedFieldsSet(0.0, 1)) + model = MOF.Model() + x = MOI.add_variable(model) + MOI.set(model, MOI.VariableName(), x, "x") + name_map = Dict(x => "x") + constraints = NamedTuple[] + @test !MOF._write_constraints(constraints, model, name_map) + @test isempty(constraints) + ci = MOI.add_constraint( + model, + MOI.ScalarNonlinearFunction(:sin, Any[x]), + set, + ) + @test MOF._write_constraints(constraints, model, name_map) + @test length(constraints) == 1 + empty!(constraints) + storage = MOI.Utilities.ModelFilter(item -> item != ci, model) + @test !MOF._write_constraints(constraints, model, name_map, storage) + @test isempty(constraints) + MOI.delete(model, ci) + empty!(constraints) + @test !MOF._write_constraints(constraints, model, name_map) + @test isempty(constraints) + object = JSON.parse(sprint(write, model)) + @test isempty(object["constraints"]) + @test !haskey(object, "has_scalar_nonlinear") + end + return +end + +function test_write_constraints_vector_nonlinear_flag() + model = MOF.Model() + x = MOI.add_variable(model) + MOI.set(model, MOI.VariableName(), x, "x") + ci = MOI.add_constraint( + model, + MOI.VectorNonlinearFunction([ + MOI.ScalarNonlinearFunction(:sin, Any[x]), + ]), + MOI.Zeros(1), + ) + name_map = Dict(x => "x") + constraints = NamedTuple[] + @test !MOF._write_constraints(constraints, model, name_map) + @test constraints == [MOF.moi_to_object(ci, model, name_map)] + object = JSON.parse(sprint(write, model)) + @test length(object["constraints"]) == 1 + @test !haskey(object, "has_scalar_nonlinear") + return +end + function test_parse_int_coefficient_scalaraffineterm() x = MOI.VariableIndex(1) object = Dict{String,Any}("coefficient" => 2, "variable" => "x") diff --git a/test/FileFormats/test_FileFormats.jl b/test/FileFormats/test_FileFormats.jl index 19fa144af9..87fa2c259e 100644 --- a/test/FileFormats/test_FileFormats.jl +++ b/test/FileFormats/test_FileFormats.jl @@ -178,6 +178,139 @@ function test_unique_names() return end +function test_unique_names_replacements() + replace_spaces = name -> replace(name, " " => "_") + replacements = (replace_spaces, uppercase) + @test (@inferred MOI.FileFormats._replace("a b", ())) == "a b" + @test (@inferred MOI.FileFormats._replace("a b", replacements)) == "A_B" + for functions in (replacements, Function[replacements...]) + model = MOI.Utilities.Model{Float64}() + x = MOI.add_variables(model, 3) + MOI.set.(model, MOI.VariableName(), x, ["x a", "x_a", "X_A_1"]) + constraints = MOI.add_constraint.(model, 1.0 .* x, MOI.EqualTo(1.0)) + MOI.set.( + model, + MOI.ConstraintName(), + constraints, + ["c a", "c_a", "C_A_1"], + ) + MOI.FileFormats.create_unique_names(model; replacements = functions) + @test MOI.get.(model, MOI.VariableName(), x) == + ["X_A", "X_A_2", "X_A_1"] + @test MOI.get.(model, MOI.ConstraintName(), constraints) == + ["C_A", "C_A_2", "C_A_1"] + end + return +end + +struct GenericNamingModel{M<:MOI.ModelLike} <: MOI.ModelLike + model::M +end + +function MOI.get(model::GenericNamingModel, attr::MOI.AbstractModelAttribute) + return MOI.get(model.model, attr) +end + +function MOI.get( + model::GenericNamingModel, + attr::MOI.ConstraintName, + index::MOI.ConstraintIndex, +) + return MOI.get(model.model, attr, index) +end + +function MOI.set( + model::GenericNamingModel, + attr::MOI.ConstraintName, + index::MOI.ConstraintIndex, + name::String, +) + return MOI.set(model.model, attr, index, name) +end + +struct NamingFallbackSet <: MOI.AbstractScalarSet end + +Base.copy(set::NamingFallbackSet) = set + +function test_constraint_naming_typed_traversal() + for model in (MOI.Utilities.Model{Float64}(), MOI.FileFormats.MOF.Model()) + x = MOI.add_variable(model) + bound = MOI.add_constraint(model, x, MOI.GreaterThan(0.0)) + MOI.add_constraint(model, 1.0 * x, MOI.LessThan(1.0)) + MOI.add_constraint(model, 1.0 * x, MOI.EqualTo(1.0)) + MOI.add_constraint(model, 1.0 * x, MOI.GreaterThan(1.0)) + MOI.add_constraint(model, 1.0 * x * x, MOI.LessThan(1.0)) + deleted = MOI.add_constraint(model, 1.0 * x, MOI.Interval(0.0, 1.0)) + MOI.delete(model, deleted) + if model isa MOI.Utilities.UniversalFallback + # This name is stored on the outer model, after the inner types. + MOI.add_constraint(model, 1.0 * x, NamingFallbackSet()) + end + constraints = [ + index for + (F, S) in MOI.get(model, MOI.ListOfConstraintTypesPresent()) if + F != MOI.VariableIndex for + index in MOI.get(model, MOI.ListOfConstraintIndices{F,S}()) + ] + original_names = fill("c", length(constraints)) + original_names[end-1] = "" + original_names[end] = "c_1" + MOI.set.(model, MOI.ConstraintName(), constraints, original_names) + MOI.FileFormats.create_unique_constraint_names(model, false, ()) + typed_names = MOI.get.(model, MOI.ConstraintName(), constraints) + @test typed_names[1:2] == ["c", "c_2"] + @test typed_names[end] == "c_1" + @test all(!isempty, typed_names) + @test allunique(typed_names) + # Force the generic ModelLike traversal over exactly the same storage. + generic = GenericNamingModel(model) + MOI.set.(model, MOI.ConstraintName(), constraints, original_names) + MOI.FileFormats.create_unique_constraint_names(generic, false, ()) + @test MOI.get.(model, MOI.ConstraintName(), constraints) == typed_names + MOI.FileFormats.create_generic_constraint_names(model) + @test MOI.get.(model, MOI.ConstraintName(), constraints) == + ["R$i" for i in eachindex(constraints)] + MOI.set.(model, MOI.ConstraintName(), constraints, original_names) + MOI.FileFormats.create_generic_constraint_names(generic) + @test MOI.get.(model, MOI.ConstraintName(), constraints) == + ["R$i" for i in eachindex(constraints)] + @test MOI.is_valid(model, bound) + @test !MOI.is_valid(model, deleted) + end + return +end + +function test_constraint_type_traversal_allocations() + model = MOI.Utilities.Model{Float64}() + x = MOI.add_variable(model) + MOI.add_constraint(model, x, MOI.GreaterThan(0.0)) + MOI.add_constraint(model, 1.0 * x, MOI.LessThan(1.0)) + MOI.add_constraint(model, 1.0 * x, MOI.EqualTo(1.0)) + deleted = MOI.add_constraint(model, 1.0 * x, MOI.Interval(0.0, 1.0)) + MOI.delete(model, deleted) + count = Ref(0) + visit = function (F, S) + count[] += 1 + return + end + @test (@inferred MOI.FileFormats._for_each_constraint_type( + visit, + model, + )) === nothing + @test count[] == 2 + count[] = 0 + @test (@allocated MOI.FileFormats._for_each_constraint_type( + visit, + model, + )) == 0 + @test count[] == 2 + MOI.empty!(model) + count[] = 0 + MOI.FileFormats._for_each_constraint_type(visit, model) + @test count[] == 0 + return +end + end TestFileFormats.runtests() diff --git a/test/JuliaC/MyApp/src/MyApp.jl b/test/JuliaC/MyApp/src/MyApp.jl index 3d2c400a94..0a9881901d 100644 --- a/test/JuliaC/MyApp/src/MyApp.jl +++ b/test/JuliaC/MyApp/src/MyApp.jl @@ -28,9 +28,10 @@ function run_mathoptformat(args::Vector{String})::Cint x = MOI.add_variable(model) MOI.set(model, MOI.VariableName(), x, "x") MOI.add_constraint(model, x, MOI.GreaterThan(0.0)) - f = MOI.ScalarNonlinearFunction( - :+, - Any[MOI.ScalarNonlinearFunction(:sin, Any[x]), 2, 3.0], + f = MOI.ScalarQuadraticFunction( + [MOI.ScalarQuadraticTerm(2.0, x, x)], + [MOI.ScalarAffineTerm(1.0, x)], + 0.0, ) c = MOI.add_constraint(model, f, MOI.LessThan(7.0)) MOI.set(model, MOI.ConstraintName(), c, "c") diff --git a/test/JuliaC/test_juliac.jl b/test/JuliaC/test_juliac.jl index 5130bb520d..5d788dcc96 100644 --- a/test/JuliaC/test_juliac.jl +++ b/test/JuliaC/test_juliac.jl @@ -30,7 +30,7 @@ function compile() image_recipe = JuliaC.ImageRecipe( output_type = "--output-exe", file = joinpath(@__DIR__, "MyApp"), - trim_mode = "no", + trim_mode = "unsafe-warn", add_ccallables = false, verbose = true, ) @@ -76,15 +76,17 @@ function test_mathoptformat(output_dir) con_c = Dict( "name" => "c", "function" => Dict( - "type" => "ScalarNonlinearFunction", - "root" => Dict("type" => "node", "index" => 2), - "node_list" => [ - Dict("type" => "sin", "args" => ["x"]), + "type" => "ScalarQuadraticFunction", + "affine_terms" => + [Dict("coefficient" => 1.0, "variable" => "x")], + "quadratic_terms" => [ Dict( - "type" => "+", - "args" => [Dict("type" => "node", "index" => 1), 2, 3.0], + "coefficient" => 2.0, + "variable_1" => "x", + "variable_2" => "x", ), ], + "constant" => 0.0, ), "set" => Dict("type" => "LessThan", "upper" => 7.0), ) diff --git a/test/Utilities/test_mutable_arithmetics.jl b/test/Utilities/test_mutable_arithmetics.jl index 33a30d72c5..6e8aff4263 100644 --- a/test/Utilities/test_mutable_arithmetics.jl +++ b/test/Utilities/test_mutable_arithmetics.jl @@ -96,6 +96,68 @@ end test_scaling_Float64() = _test_scaling(Float64) test_scaling_Float32() = _test_scaling(Float32) +function _test_vector_mutable_copy(f, term_fields) + for copy_function in (MA.mutable_copy, MA.copy_if_mutable) + g = @inferred copy_function(f) + @test g isa typeof(f) + @test g ≈ f + @test g.constants !== f.constants + coefficients = copy(f.constants) + copied_coefficients = copy(g.constants) + for field in term_fields + terms = getfield(f, field) + copied_terms = getfield(g, field) + @test copied_terms !== terms + @test MOI.term_indices.(copied_terms) == MOI.term_indices.(terms) + append!(coefficients, MOI.coefficient.(terms)) + append!(copied_coefficients, MOI.coefficient.(copied_terms)) + end + for (coefficient, copied_coefficient) in + zip(coefficients, copied_coefficients) + @test copied_coefficient == coefficient + if MA.mutability(typeof(coefficient)) == MA.IsMutable() + @test copied_coefficient !== coefficient + MA.operate!(*, copied_coefficient, 2) + @test copied_coefficient == 2 * coefficient + end + end + end + return +end + +function test_mutable_copy_VectorAffineFunction() + x = MOI.VariableIndex(1) + for T in (Float64, BigInt, BigFloat) + f = MOI.VectorAffineFunction( + [MOI.VectorAffineTerm(2, MOI.ScalarAffineTerm(T(2), x))], + T[3, 4], + ) + _test_vector_mutable_copy(f, (:terms,)) + f = MOI.VectorAffineFunction(MOI.VectorAffineTerm{T}[], T[]) + _test_vector_mutable_copy(f, (:terms,)) + end + return +end + +function test_mutable_copy_VectorQuadraticFunction() + x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) + for T in (Float64, BigInt, BigFloat) + f = MOI.VectorQuadraticFunction( + [MOI.VectorQuadraticTerm(2, MOI.ScalarQuadraticTerm(T(2), x, y))], + [MOI.VectorAffineTerm(1, MOI.ScalarAffineTerm(T(3), y))], + T[4, 5], + ) + _test_vector_mutable_copy(f, (:quadratic_terms, :affine_terms)) + f = MOI.VectorQuadraticFunction( + MOI.VectorQuadraticTerm{T}[], + MOI.VectorAffineTerm{T}[], + T[], + ) + _test_vector_mutable_copy(f, (:quadratic_terms, :affine_terms)) + end + return +end + function test_unary_minus() for T in [Float64, Float32] x = MOI.VariableIndex(1)