Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add test for large models, free model memory, and require options field #8

Merged
merged 6 commits into from
Sep 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 47 additions & 51 deletions src/SolverAPI.jl
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,14 @@ function response(
end
res["solver_version"] = string(solver_name, '_', solver_ver)

res["termination_status"] = MOI.get(model, MOI.TerminationStatus())
res["termination_status"] = string(MOI.get(model, MOI.TerminationStatus()))

options = get(() -> Dict{String,Any}(), json, :options)
format = get(options, :print_format, nothing)
format = get(json.options, :print_format, nothing)
if !isnothing(format)
res["model_string"] = print_model(model, format)
end

if Bool(get(options, :print_only, false))
if Bool(get(json.options, :print_only, false))
return res
end

Expand All @@ -117,7 +116,7 @@ function response(
for idx in 1:result_count
r = results[idx]

r["primal_status"] = MOI.get(model, MOI.PrimalStatus(idx))
r["primal_status"] = string(MOI.get(model, MOI.PrimalStatus(idx)))

# TODO: It is redundant to return the names for every result, since they are fixed -
# try relying on fixed vector ordering and don't return names.
Expand Down Expand Up @@ -178,12 +177,23 @@ function solve(fn, json::Request, solver::MOI.AbstractOptimizer)
errors = validate(json)
isempty(errors) || return response(; errors)

# TODO (dba) `SolverAPI.jl` should be decoupled from any solver specific code.
solver_info = Dict{Symbol,Any}()
if lowercase(get(json.options, :solver, "highs")) == "minizinc"
T = Int
solver_info[:use_indicator] = false
else
T = Float64
solver_info[:use_indicator] = true
end

model = MOI.instantiate(() -> solver; with_cache_type = T, with_bridge_type = T)

try
T, solver_info, model = initialize(json, solver)
load!(json, T, solver_info, model)
set_options!(model, json.options)
load!(model, json, T, solver_info)
fn(model)
options = get(() -> Dict{String,Any}(), json, :options)
if !Bool(get(options, :print_only, false))
if !Bool(get(json.options, :print_only, false))
MOI.optimize!(model)
end
return response(json, model, solver)
Expand All @@ -208,6 +218,8 @@ function solve(fn, json::Request, solver::MOI.AbstractOptimizer)
else
rethrow()
end
finally
MOI.empty!(model)
end
end
solve(request::Request, solver::MOI.AbstractOptimizer) =
Expand All @@ -232,30 +244,30 @@ function print_model(model::MOI.ModelLike, format::String)
format_lower = lowercase(format)
if format_lower == "moi" || format_lower == "latex"
mime = MIME(format_lower == "latex" ? "text/latex" : "text/plain")
options = MOI.Utilities._PrintOptions(
print_options = MOI.Utilities._PrintOptions(
mime;
simplify_coefficients = true,
print_types = false,
)
return sprint() do io
return MOI.Utilities._print_model(io, options, model)
return MOI.Utilities._print_model(io, print_options, model)
end
elseif format_lower == "latex"
# NOTE: there are options for latex_formulation, e.g. don't print MOI function/set types
# https://jump.dev/MathOptInterface.jl/dev/submodules/Utilities/reference/#MathOptInterface.Utilities.latex_formulation
return sprint(print, MOI.Utilities.latex_formulation(model))
elseif format_lower == "mof"
options = (; format = MOI.FileFormats.FORMAT_MOF, print_compact = true)
print_options = (; format = MOI.FileFormats.FORMAT_MOF, print_compact = true)
elseif format_lower == "lp"
options = (; format = MOI.FileFormats.FORMAT_LP)
print_options = (; format = MOI.FileFormats.FORMAT_LP)
elseif format_lower == "mps"
options = (; format = MOI.FileFormats.FORMAT_MPS)
print_options = (; format = MOI.FileFormats.FORMAT_MPS)
elseif format_lower == "nl"
options = (; format = MOI.FileFormats.FORMAT_NL)
print_options = (; format = MOI.FileFormats.FORMAT_NL)
else
throw(Error(Unsupported, "File type \"$format\" not supported."))
end
dest = MOI.FileFormats.Model(; options...)
dest = MOI.FileFormats.Model(; print_options...)
MOI.copy_to(dest, model)
return sprint(write, dest)
end
Expand All @@ -265,16 +277,15 @@ function print_model(request::Request; T = Float64)
throw(CompositeException(errors))
end

options = get(() -> Dict{String,Any}(), request, :options)
# Default to MOI format.
format = get(options, :print_format, "MOI")
format = get(request.options, :print_format, "MOI")

# TODO cleanup/refactor solver_info logic.
use_indicator = T == Float64
solver_info = Dict{Symbol,Any}(:use_indicator => use_indicator)
model = MOI.Utilities.Model{T}()

load!(request, T, solver_info, model)
load!(model, request, T, solver_info)
return print_model(model, format)
end
print_model(request::Dict) = print_model(JSON3.read(JSON3.write(request)))
Expand All @@ -289,7 +300,7 @@ function validate(json::Request)#::Vector{Error}
valid_shape = true

# Syntax.
for k in [:version, :sense, :variables, :constraints, :objectives]
for k in [:version, :sense, :variables, :constraints, :objectives, :options]
if !haskey(json, k)
valid_shape = false
_err("Missing required field `$(k)`.")
Expand All @@ -307,7 +318,7 @@ function validate(json::Request)#::Vector{Error}
_err("Invalid version `$(repr(json.version))`. Only `\"0.1\"` is supported.")
end

if haskey(json, :options) && !isa(json.options, JSON3.Object)
if !isa(json.options, JSON3.Object)
_err("Invalid `options` field. Must be an object.")
end

Expand All @@ -326,20 +337,18 @@ function validate(json::Request)#::Vector{Error}
_err("Invalid `sense` field. Must be one of `feas`, `min`, or `max`.")
end

if haskey(json, :options)
for (T, k) in [(String, :print_format), (Number, :time_limit_sec)]
if haskey(json.options, k) && !isa(json.options[k], T)
_err("Invalid `options.$(k)` field. Must be of type `$(T)`.")
end
for (T, k) in [(String, :print_format), (Number, :time_limit_sec)]
if haskey(json.options, k) && !isa(json.options[k], T)
_err("Invalid `options.$(k)` field. Must be of type `$(T)`.")
end
end

for k in [:silent, :print_only]
if haskey(json.options, k)
val = json.options[k]
# We allow `0` and `1` for convenience.
if !isa(val, Bool) && val isa Number && val != 0 && val != 1
_err("Invalid `options.$(k)` field. Must be a boolean.")
end
for k in [:silent, :print_only]
if haskey(json.options, k)
val = json.options[k]
# We allow `0` and `1` for convenience.
if !isa(val, Bool) && val isa Number && val != 0 && val != 1
_err("Invalid `options.$(k)` field. Must be a boolean.")
end
end
end
Expand All @@ -357,29 +366,16 @@ function validate(json::Request)#::Vector{Error}
return out
end

function initialize(json::Request, solver::MOI.AbstractOptimizer)#::Tuple{Type, Dict{Symbol, Any}, MOI.ModelLike}
solver_info = Dict{Symbol,Any}()

# TODO (dba) `SolverAPI.jl` should be decoupled from any solver specific code.
options = get(() -> Dict{String,Any}(), json, :options)
if lowercase(get(options, :solver, "highs")) == "minizinc"
T = Int
solver_info[:use_indicator] = false
else
T = Float64
solver_info[:use_indicator] = true
end

model = MOI.instantiate(() -> solver; with_cache_type = T, with_bridge_type = T)

# Set solver options.
function set_options!(model::MOI.ModelLike, options::JSON3.Object)#::Nothing
if MOI.supports(model, MOI.TimeLimitSec())
# Set time limit, defaulting to 5min.
MOI.set(model, MOI.TimeLimitSec(), Float64(get(options, :time_limit_sec, 300.0)))
end

# Set other solver options.
for (key, val) in options
if key in [:solver, :print_format, :print_only, :time_limit_sec]
# Skip - these are handled elsewhere.
continue
elseif key == :silent
MOI.set(model, MOI.Silent(), Bool(val))
Expand All @@ -392,10 +388,10 @@ function initialize(json::Request, solver::MOI.AbstractOptimizer)#::Tuple{Type,
end
end

return (T, solver_info, model)
return nothing
end

function load!(json::Request, T::Type, solver_info::Dict{Symbol,Any}, model::MOI.ModelLike)#::Nothing
function load!(model::MOI.ModelLike, json::Request, T::Type, solver_info::Dict{Symbol,Any})#::Nothing
# handle variables
vars_map = Dict{String,MOI.VariableIndex}()
vars = MOI.add_variables(model, length(json.variables))
Expand Down
98 changes: 66 additions & 32 deletions test/all_tests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import MiniZinc
import JSON3
import MathOptInterface as MOI

export run_solve, read_json
export get_solver, run_solve, read_json

function _get_solver(solver_name::String)
if solver_name == "MiniZinc"
function get_solver(solver_name::String)
solver_name_lower = lowercase(solver_name)
if solver_name_lower == "minizinc"
return MiniZinc.Optimizer{Int}("chuffed")
elseif solver_name == "HiGHS"
elseif solver_name_lower == "highs"
return HiGHS.Optimizer()
else
error("Solver $solver_name not supported.")
Expand All @@ -20,18 +21,41 @@ end

function run_solve(input::String)
json = deserialize(input)
solver = _get_solver(json.options.solver)
solution = solve(json, solver)
return String(serialize(solution))
solver_name = try
json.options.solver
catch
"highs"
end
solver = get_solver(solver_name)
output = solve(json, solver)
return String(serialize(output))
end

read_json(in_out::String, name::String) =
read(joinpath(@__DIR__, in_out, name * ".json"), String)

end # end of setup module.

@testitem "print" setup = [SolverSetup] begin
using SolverAPI: print_model

json = Dict(
:version => "0.1",
:sense => "min",
:variables => ["x"],
:constraints => [["==", "x", 1], ["Int", "x"]],
:objectives => ["x"],
:options => Dict(:print_format => "none"),
)

# check MOI model printing for each format
@testset "$f" for f in ["moi", "latex", "mof", "lp", "mps", "nl"]
json[:options][:print_format] = f
@test print_model(json) isa String
end
end

@testitem "solve" setup = [SolverSetup] begin
using SolverAPI
import JSON3

# names of JSON files in inputs/ and outputs/ folders
Expand All @@ -45,41 +69,23 @@ end # end of setup module.
"n_queens",
]

# solve and check output is expected for each input json file
@testset "$j" for j in json_names
result = JSON3.read(run_solve(read_json("inputs", j)))
@test result.solver_version isa String
@test result.solve_time_sec isa Float64

output = JSON3.read(run_solve(read_json("inputs", j)))
@test output.solver_version isa String
@test output.solve_time_sec isa Float64
expect = JSON3.read(read_json("outputs", j))
for (key, expect_value) in pairs(expect)
@test result[key] == expect_value
@test output[key] == expect_value
end
end
end

@testitem "print" setup = [SolverSetup] begin
using SolverAPI

tiny_min = Dict(
:version => "0.1",
:sense => "min",
:variables => ["x"],
:constraints => [["==", "x", 1], ["Int", "x"]],
:objectives => ["x"],
)

# test each format
for format in ["moi", "latex", "mof", "lp", "mps", "nl"]
options = Dict(:print_format => format)
@test print_model(Dict(tiny_min..., :options => options)) isa String
end
end

@testitem "errors" setup = [SolverSetup] begin
using SolverAPI
import JSON3

# scenarios with incorrect format
# names of JSON files in inputs/ folder and expected error types
json_names_and_errors = [
# missing field variables
("missing_vars", "InvalidFormat"),
Expand All @@ -91,6 +97,8 @@ end
("missing_sense", "InvalidFormat"),
# missing field version
("missing_version", "InvalidFormat"),
# missing field options
("missing_options", "InvalidFormat"),
# field variables is not a string
("vars_is_not_str", "InvalidFormat"),
# field variables is not an array
Expand Down Expand Up @@ -125,9 +133,35 @@ end
("unsupported_print_format", "Unsupported"),
]

# check that expected error types are returned for each json input
@testset "$j" for (j, es...) in json_names_and_errors
result = JSON3.read(run_solve(read_json("inputs", j)))
@test haskey(result, :errors) && length(result.errors) >= 1
@test Set(e.type for e in result.errors) == Set(es)
end
end

@testitem "large-model" setup = [SolverSetup] begin
using SolverAPI: solve

# setup linear objective model with n variables
n = 1000
vars = ["x$i" for i in 1:n]
json = Dict(
:version => "0.1",
:sense => "max",
:variables => vars,
:constraints => [["Bin", v] for v in vars],
:objectives => [vcat("+", 1, [["*", i, v] for (i, v) in enumerate(vars)])],
:options => Dict(:solver => "HiGHS"),
)

# check that model is solved correctly without errors
@testset "solve n=$n" begin
output = solve(json, get_solver("HiGHS"))
@test output isa Dict{String,Any}
@test !haskey(output, "errors")
@test output["termination_status"] == "OPTIMAL"
@test output["results"][1]["objective_value"] ≈ 1 + div(n * (n + 1), 2)
end
end
1 change: 1 addition & 0 deletions test/inputs/missing_options.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"version":"0.1","sense":"feas","variables":["x"],"constraints":[["==","x",1],["Int","x"]],"objectives":[]}