diff --git a/server/Project.toml b/server/Project.toml index 77e5fc2..c009495 100644 --- a/server/Project.toml +++ b/server/Project.toml @@ -2,7 +2,6 @@ HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -JuMP = "4076af6c-e467-56ae-b986-b466b2749572" MathOptInterface = "b8f27783-ece8-5eb3-8dc8-9495eed66fee" NexOR = "8018e1f0-eec4-4af0-933d-cc21e2f6b695" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" @@ -11,6 +10,5 @@ Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" HTTP = "1, 2" HiGHS = "1" JSON = "1" -JuMP = "1.30" MathOptInterface = "1.34" julia = "1.10" diff --git a/server/solver.jl b/server/solver.jl index 10ea40f..3589050 100644 --- a/server/solver.jl +++ b/server/solver.jl @@ -6,7 +6,9 @@ # Solve one problem directory produced by server.jl: read envelope.json, # build an MOI model from the embedded MathOptFormat problem, solve it with # the requested solver and write solution.json (a Solution Envelope v1, see -# ~/nexor docs/worker-protocol.md §7) plus the final status.json. +# ~/nexor docs/worker-protocol.md §7) plus the final status.json. The +# solution is the wire format of NexOR/src/solution.jl: the MOI solution +# attributes by name plus the primal vector in MathOptFormat order. # # Included by server.jl, the single entry point: the solve runs in a spawned # `julia server.jl ` process, or in a task of the serving @@ -14,12 +16,10 @@ # avoid Julia startup per solve). import JSON -import JuMP import MathOptInterface as MOI import NexOR -function envelope_status(summary) - status = summary.termination_status +function envelope_status(status::MOI.TerminationStatusCode) if status == MOI.OPTIMAL return "optimal" elseif status == MOI.LOCALLY_SOLVED || status == MOI.ALMOST_OPTIMAL @@ -34,24 +34,6 @@ function envelope_status(summary) return "error" end -# JSON has no Inf/NaN/missing and no enums, hence the lowering rules. -json_value(x) = x -json_value(x::Enum) = string(x) -json_value(::Missing) = nothing -json_value(x::AbstractFloat) = isfinite(x) ? x : nothing -json_value(x::AbstractDict) = Dict(k => json_value(v) for (k, v) in x) -json_value(x::AbstractVector) = [json_value(v) for v in x] - -# The wire format of the solution is the `JuMP._SolutionSummary` struct, -# written field by field, plus the vector of all variable values in the -# order of the variables of the MathOptFormat file. -function summary_json(summary) - return Dict( - string(name) => json_value(getfield(summary, name)) for - name in fieldnames(typeof(summary)) - ) -end - function solve_envelope(envelope, solver, dir) path = joinpath(dir, "problem.mof.json") write(path, JSON.json(envelope["problem"])) @@ -59,18 +41,14 @@ function solve_envelope(envelope, solver, dir) MOI.read_from_file(mof, path) optimizer = MOI.instantiate(solver; with_bridge_type = Float64, with_cache_type = Float64) - # A zero-copy JuMP view of the optimizer, only for `solution_summary`. - # It must be created while the backend is still empty (`direct_model` - # asserts that); the non-verbose summary never queries variables, so it - # does not mind the model being loaded behind its back by `MOI.copy_to`. - model = JuMP.direct_model(optimizer) index_map = MOI.copy_to(optimizer, mof) started = time() MOI.optimize!(optimizer) wall_seconds = time() - started - summary = JuMP.solution_summary(model) + attributes = NexOR.solution_attributes(optimizer) + has_values = MOI.get(optimizer, MOI.PrimalStatus()) != MOI.NO_SOLUTION primal = - summary.has_values ? + has_values ? [ MOI.get(optimizer, MOI.VariablePrimal(), index_map[vi]) for vi in MOI.get(mof, MOI.ListOfVariableIndices()) @@ -78,9 +56,9 @@ function solve_envelope(envelope, solver, dir) log_path = joinpath(dir, "worker.log") return Dict( "api_version" => "1", - "status" => envelope_status(summary), - "objective" => summary.has_values ? json_value(summary.objective_value) : nothing, - "solution" => Dict("summary" => summary_json(summary), "primal" => primal), + "status" => envelope_status(MOI.get(optimizer, MOI.TerminationStatus())), + "objective" => get(attributes, "objective_value", nothing), + "solution" => Dict("attributes" => attributes, "primal" => primal), "log" => isfile(log_path) ? first(read(log_path, String), 100_000) : nothing, "metering" => Dict("wall_seconds" => wall_seconds, "cpu_seconds" => nothing), ) diff --git a/src/MOI_wrapper.jl b/src/MOI_wrapper.jl index ead5ee7..5303d48 100644 --- a/src/MOI_wrapper.jl +++ b/src/MOI_wrapper.jl @@ -15,8 +15,10 @@ An `MOI.AbstractOptimizer` that sends the problem to a remote NexOR server at `MOI.optimize!` and caches the returned solution so that all solution queries are answered locally. -The model is stored in a `MOI.FileFormats.MOF.Model` filled through -`MOI.copy_to`; the incremental interface is not supported. +The model is stored in a `MOI.Utilities.MockOptimizer` wrapping a +`MOI.FileFormats.MOF.Model`, filled through `MOI.copy_to`; the incremental +interface is not supported. The solution received from the server is loaded +into the mock, which then answers every solution query locally. ## Attributes @@ -34,36 +36,38 @@ attributes, ...) is stored in the solver spec and applied by the remote solver. """ mutable struct Optimizer <: MOI.AbstractOptimizer - model::MOF.Model{Float64} + model::MOI.Utilities.MockOptimizer{MOF.Model{Float64},Float64} server_url::String api_key::String solver::OptimizerWithAttributes problem_id::Union{Nothing,String} - summary::Any # `JuMP._SolutionSummary` of the remote solve, as a JSON dict - primal::Vector{Float64} + # `MockOptimizer` cannot store it (yet) and `JuMP.solution_summary` + # requires it unconditionally + raw_status::String end function Optimizer() return Optimizer( - MOF.Model(), + # The mock serves the stored solution instead of evaluating it + MOI.Utilities.MockOptimizer( + MOF.Model(); + eval_objective_value = false, + eval_dual_objective_value = false, + ), get(ENV, "NEXOR_SERVER_URL", "https://solve.nexoropt.com"), get(ENV, "NEXOR_API_KEY", ""), OptimizerWithAttributes("undef"), nothing, - nothing, - Float64[], + "optimize not called", ) end -function MOI.is_empty(model::Optimizer) - return MOI.is_empty(model.model) && model.summary === nothing -end +MOI.is_empty(model::Optimizer) = MOI.is_empty(model.model) function MOI.empty!(model::Optimizer) MOI.empty!(model.model) model.problem_id = nothing - model.summary = nothing - empty!(model.primal) + model.raw_status = "optimize not called" return end @@ -230,7 +234,7 @@ end function _problem(model::Optimizer) io = IOBuffer() - write(io, model.model) + write(io, model.model.inner_model) return JSON.parse(String(take!(io))) end @@ -256,122 +260,25 @@ function MOI.optimize!(model::Optimizer) end if problem["status"] == "failed" || problem["status"] == "cancelled" err = problem["error"] - model.summary = Dict{String,Any}( - "termination_status" => "OTHER_ERROR", - "primal_status" => "NO_SOLUTION", - "dual_status" => "NO_SOLUTION", - "result_count" => 0, - "raw_status" => - err === nothing ? problem["status"] : "$(err["code"]): $(err["message"])", - ) + model.raw_status = + err === nothing ? problem["status"] : "$(err["code"]): $(err["message"])" + MOI.set(model.model, MOI.TerminationStatus(), MOI.OTHER_ERROR) return end delivery = _request(model, "GET", "/problems/$(model.problem_id)/solution") solution = delivery["solution"] # the Solution Envelope v1 - model.summary = solution["solution"]["summary"] - primal = solution["solution"]["primal"] - model.primal = primal === nothing ? Float64[] : Float64.(primal) + load_solution!(model.model, solution["solution"]) + model.raw_status = get(solution["solution"]["attributes"], "raw_status", "") if !MOI.get(model, MOI.Silent()) && solution["log"] isa String print(solution["log"]) end return end -# Solution attributes: served from the cached summary, never from the server - -function MOI.get(model::Optimizer, ::MOI.SolverName) - return "NexOR($(model.solver.optimizer))" -end - -function _summary(model::Optimizer) - if model.summary === nothing - throw(MOI.OptimizeNotCalled()) - end - return model.summary -end - -function _field(model::Optimizer, attr::MOI.AnyAttribute, key::String) - value = get(_summary(model), key, nothing) - if value === nothing - throw(MOI.GetAttributeNotAllowed(attr, "not provided by the remote solver")) - end - return value -end - -function _status(::Type{E}, name::String) where {E} - return instances(E)[findfirst(x -> string(x) == name, instances(E))] -end - -_scalar(value::Real) = Float64(value) -_scalar(value::Vector) = Float64.(value) - -function MOI.get(model::Optimizer, ::MOI.TerminationStatus) - if model.summary === nothing - return MOI.OPTIMIZE_NOT_CALLED - end - return _status(MOI.TerminationStatusCode, model.summary["termination_status"]) -end - -function MOI.get(model::Optimizer, attr::MOI.PrimalStatus) - if model.summary === nothing || attr.result_index != 1 - return MOI.NO_SOLUTION - end - return _status(MOI.ResultStatusCode, model.summary["primal_status"]) -end - -function MOI.get(model::Optimizer, attr::MOI.DualStatus) - if model.summary === nothing || attr.result_index != 1 - return MOI.NO_SOLUTION - end - return _status(MOI.ResultStatusCode, model.summary["dual_status"]) -end - -function MOI.get(model::Optimizer, ::MOI.ResultCount) - return model.summary === nothing ? 0 : Int(model.summary["result_count"]) -end - -function MOI.get(model::Optimizer, ::MOI.RawStatusString) - return _summary(model)["raw_status"]::String -end - -function MOI.get(model::Optimizer, attr::MOI.ObjectiveValue) - MOI.check_result_index_bounds(model, attr) - return _scalar(_field(model, attr, "objective_value")) -end - -function MOI.get(model::Optimizer, attr::MOI.ObjectiveBound) - return _scalar(_field(model, attr, "objective_bound")) -end - -function MOI.get(model::Optimizer, attr::MOI.RelativeGap) - return Float64(_field(model, attr, "relative_gap")) -end - -function MOI.get(model::Optimizer, attr::MOI.DualObjectiveValue) - MOI.check_result_index_bounds(model, attr) - return Float64(_field(model, attr, "dual_objective_value")) -end - -function MOI.get(model::Optimizer, attr::MOI.SolveTimeSec) - return Float64(_field(model, attr, "solve_time")) -end +# Solution attributes: every query is answered by the mock through the +# generic forwards above; only the two attributes the mock cannot answer are +# implemented here. -function MOI.get(model::Optimizer, attr::MOI.BarrierIterations) - return Int(_field(model, attr, "barrier_iterations")) -end +MOI.get(model::Optimizer, ::MOI.SolverName) = "NexOR($(model.solver.optimizer))" -function MOI.get(model::Optimizer, attr::MOI.SimplexIterations) - return Int(_field(model, attr, "simplex_iterations")) -end - -function MOI.get(model::Optimizer, attr::MOI.NodeCount) - return Int(_field(model, attr, "node_count")) -end - -function MOI.get(model::Optimizer, attr::MOI.VariablePrimal, vi::MOI.VariableIndex) - MOI.check_result_index_bounds(model, attr) - if isempty(model.primal) # the result has no primal, e.g., a dual certificate - throw(MOI.ResultIndexBoundsError(attr, 0)) - end - return model.primal[vi.value] -end +MOI.get(model::Optimizer, ::MOI.RawStatusString) = model.raw_status diff --git a/src/NexOR.jl b/src/NexOR.jl index 645ca1b..05ed615 100644 --- a/src/NexOR.jl +++ b/src/NexOR.jl @@ -9,6 +9,7 @@ import JSON import MathOptInterface as MOI include("solver.jl") +include("solution.jl") include("MOI_wrapper.jl") include("http.jl") diff --git a/src/solution.jl b/src/solution.jl new file mode 100644 index 0000000..a32b180 --- /dev/null +++ b/src/solution.jl @@ -0,0 +1,83 @@ +# Copyright (c) 2026: NexOR Optimization SRL +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE.md file or at https://opensource.org/licenses/MIT. + +# The solution wire format: the MOI solution attributes of the remote solve, +# by name, plus the primal value of each variable in the order of the +# variables of the MathOptFormat file. The client loads them into the +# `MOI.Utilities.MockOptimizer` of `NexOR.Optimizer`, so every solution +# query is answered locally by MOI itself — no getters to implement on +# either side. Attributes `MockOptimizer` cannot store yet (`SolveTimeSec`, +# ...) are simply not supported; `RawStatusString` is the one exception, +# carried outside the mock because `JuMP.solution_summary` requires it +# unconditionally. + +const _SOLUTION_ATTRIBUTES = Dict{String,MOI.AbstractModelAttribute}( + "termination_status" => MOI.TerminationStatus(), + "primal_status" => MOI.PrimalStatus(), + "dual_status" => MOI.DualStatus(), + "result_count" => MOI.ResultCount(), + "objective_value" => MOI.ObjectiveValue(), + "dual_objective_value" => MOI.DualObjectiveValue(), + "raw_status" => MOI.RawStatusString(), +) + +function _status(::Type{E}, name::String) where {E} + return instances(E)[findfirst(status -> string(status) == name, instances(E))] +end + +_lift(::MOI.TerminationStatus, value) = _status(MOI.TerminationStatusCode, value) + +function _lift(::Union{MOI.PrimalStatus,MOI.DualStatus}, value) + return _status(MOI.ResultStatusCode, value) +end + +_lift(::MOI.AbstractModelAttribute, value) = value + +""" + solution_attributes(optimizer::MOI.ModelLike) + +Return the values of the [`_SOLUTION_ATTRIBUTES`](@ref) provided by +`optimizer`, keyed by wire name, with enums as strings. Used by the server +after the solve. +""" +function solution_attributes(optimizer::MOI.ModelLike) + attributes = Dict{String,Any}() + for (name, attr) in _SOLUTION_ATTRIBUTES + value = try + MOI.get(optimizer, attr) + catch # the solver is free not to provide this attribute + continue + end + if value isa AbstractFloat && !isfinite(value) + continue # JSON has no Inf/NaN + end + attributes[name] = value isa Enum ? string(value) : value + end + return attributes +end + +""" + load_solution!(mock::MOI.Utilities.MockOptimizer, solution) + +Load the `{"attributes": ..., "primal": ...}` solution of the wire into +`mock`. The inverse of [`solution_attributes`](@ref), used by the client. +""" +function load_solution!(mock::MOI.Utilities.MockOptimizer, solution) + for (name, value) in solution["attributes"] + if name == "raw_status" # not storable in the mock, kept by the caller + continue + end + attr = _SOLUTION_ATTRIBUTES[String(name)] + MOI.set(mock, attr, _lift(attr, value)) + end + primal = solution["primal"] + if primal !== nothing + variables = MOI.get(mock, MOI.ListOfVariableIndices()) + for (vi, value) in zip(variables, primal) + MOI.set(mock, MOI.VariablePrimal(), vi, Float64(value)) + end + end + return +end diff --git a/test/runtests.jl b/test/runtests.jl index 3eb51f2..89ee666 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -61,7 +61,6 @@ end @test objective_value(model) ≈ 9.0 @test value(model[:x]) ≈ 4.0 @test value(model[:y]) ≈ 1.0 - @test solve_time(model) >= 0.0 @test solver_name(model) == "NexOR(HiGHS)" # What reached the server: the solver spec of the submit envelope nexor = unsafe_backend(model) @@ -115,7 +114,7 @@ end JuMP.optimize!(model) @test JuMP.termination_status(model) == JuMP.MOI.INFEASIBLE @test JuMP.primal_status(model) == JuMP.MOI.NO_SOLUTION - @test_throws JuMP.MOI.ResultIndexBoundsError JuMP.value(x) + @test !JuMP.has_values(model) end @testset "unbounded" begin