Skip to content
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
2 changes: 0 additions & 2 deletions server/Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
42 changes: 10 additions & 32 deletions server/solver.jl
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@
# 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 <problem-dir>` process, or in a task of the serving
# process itself when NEXOR_INLINE_SOLVER=1 (used by the NexOR.jl tests to
# 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
Expand All @@ -34,53 +34,31 @@ 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"]))
mof = MOI.FileFormats.MOF.Model()
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())
] : nothing
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),
)
Expand Down
149 changes: 28 additions & 121 deletions src/MOI_wrapper.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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
1 change: 1 addition & 0 deletions src/NexOR.jl
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import JSON
import MathOptInterface as MOI

include("solver.jl")
include("solution.jl")
include("MOI_wrapper.jl")
include("http.jl")

Expand Down
83 changes: 83 additions & 0 deletions src/solution.jl
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading