From 3fa1902bc9c4516ccdc29b648c0c584a18152730 Mon Sep 17 00:00:00 2001 From: joaquimg Date: Sun, 16 Aug 2026 16:06:58 -0300 Subject: [PATCH 1/3] Allow solver with no decision variables (only parameters). --- docs/src/changelog.md | 9 + docs/src/index.md | 36 +++ docs/src/reference.md | 10 + src/MOI_wrapper.jl | 202 +++++++++++- src/ParametricOptInterface.jl | 30 ++ src/duals.jl | 18 +- src/no_variables.jl | 172 ++++++++++ test/test_no_variables.jl | 569 ++++++++++++++++++++++++++++++++++ 8 files changed, 1030 insertions(+), 16 deletions(-) create mode 100644 src/no_variables.jl create mode 100644 test/test_no_variables.jl diff --git a/docs/src/changelog.md b/docs/src/changelog.md index 877df444..3dc8af88 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -7,6 +7,15 @@ CurrentModule = ParametricOptInterface The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Models that contain parameters but no decision variables are now solved by + `Optimizer` itself instead of being sent to the inner optimizer, which + usually rejects them. The new `SolveWithoutVariables` and + `NoVariablesFeasibilityTolerance` attributes control this behavior. + ## Version 0.15.2 (March 15, 2026) ### Fixed diff --git a/docs/src/index.md b/docs/src/index.md index 8ae05e36..1c86c3ac 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -141,3 +141,39 @@ set_parameter_value(p, 3) optimize!(model) value(x) # x = 3 / 2p = 0.5 ``` + +## Models with no decision variables + +A model that contains parameters but no decision variables has no degrees of +freedom, so there is nothing for the inner optimizer to solve. Most solvers +reject such a model. POI solves it instead: + +```@repl +using JuMP, HiGHS +import ParametricOptInterface as POI +model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) +set_silent(model) +@variable(model, p in Parameter(2)) +@variable(model, q in Parameter(3)) +@objective(model, Min, 4p + 5q) +optimize!(model) +objective_value(model) +dual(VariableInSetRef(p)) +``` + +The solution is written down directly instead of being computed by the inner +optimizer: + + * the primal solution is the vector of parameter values; + * the objective value is the objective function evaluated at those values; + * every constraint is a statement about the parameter values, so it cannot be + active in the KKT sense and its dual is zero; + * consequently the dual of each parameter is the derivative of the objective + function with respect to it. + +If the parameter values violate a constraint, the termination status is +`MOI.INFEASIBLE`. The tolerance used for that check is the +[`NoVariablesFeasibilityTolerance`](@ref) attribute. + +Set the [`SolveWithoutVariables`](@ref) attribute to `false` to send the model +to the inner optimizer instead. diff --git a/docs/src/reference.md b/docs/src/reference.md index 02cb56ef..3a52b932 100644 --- a/docs/src/reference.md +++ b/docs/src/reference.md @@ -21,3 +21,13 @@ Optimizer ```@docs ConstraintsInterpretation ``` + +## `SolveWithoutVariables` +```@docs +SolveWithoutVariables +``` + +## `NoVariablesFeasibilityTolerance` +```@docs +NoVariablesFeasibilityTolerance +``` diff --git a/src/MOI_wrapper.jl b/src/MOI_wrapper.jl index c41d7374..5f1f9f19 100644 --- a/src/MOI_wrapper.jl +++ b/src/MOI_wrapper.jl @@ -232,6 +232,10 @@ function MOI.empty!(model::Optimizer{T}) where {T} # parameters_in_conflict empty!(model.parameters_in_conflict) # [SKIP] warn_quad_affine_ambiguous + # [SKIP] solve_without_variables + # [SKIP] no_variables_feasibility_tolerance + # no_variables_solution + model.no_variables_solution = nothing # ext empty!(model.ext) return @@ -1660,18 +1664,79 @@ function MOI.get( return MOI.get(model.optimizer, attr, v) end +function MOI.get(model::Optimizer, attr::MOI.TerminationStatus) + solution = model.no_variables_solution + if solution !== nothing + return solution.termination_status + end + return MOI.get(model.optimizer, attr) +end + +function MOI.get(model::Optimizer, attr::MOI.PrimalStatus) + solution = model.no_variables_solution + if solution !== nothing + return attr.result_index == 1 ? solution.primal_status : MOI.NO_SOLUTION + end + return MOI.get(model.optimizer, attr) +end + +function MOI.get(model::Optimizer, attr::MOI.DualStatus) + solution = model.no_variables_solution + if solution !== nothing + return attr.result_index == 1 ? solution.dual_status : MOI.NO_SOLUTION + end + return MOI.get(model.optimizer, attr) +end + +function MOI.get(model::Optimizer, attr::MOI.ResultCount) + if model.no_variables_solution !== nothing + return 1 + end + return MOI.get(model.optimizer, attr) +end + +function MOI.get(model::Optimizer, attr::MOI.RawStatusString) + solution = model.no_variables_solution + if solution !== nothing + return solution.raw_status_string + end + return MOI.get(model.optimizer, attr) +end + +function MOI.get(model::Optimizer, attr::MOI.SolveTimeSec) + if model.no_variables_solution !== nothing + return 0.0 + end + return MOI.get(model.optimizer, attr) +end + function MOI.get( model::Optimizer, attr::T, -) where { - T<:Union{ - MOI.TerminationStatus, - MOI.ObjectiveValue, - MOI.DualObjectiveValue, - MOI.PrimalStatus, - MOI.DualStatus, - }, -} +) where {T<:Union{MOI.ObjectiveValue,MOI.DualObjectiveValue}} + solution = model.no_variables_solution + if solution !== nothing + MOI.check_result_index_bounds(model, attr) + # There are no variables, so the primal and dual objective values + # coincide with the objective function evaluated at the parameter + # values. + return solution.objective_value + end + return MOI.get(model.optimizer, attr) +end + +function MOI.get(model::Optimizer, attr::MOI.ObjectiveBound) + solution = model.no_variables_solution + if solution !== nothing + return solution.objective_value + end + return MOI.get(model.optimizer, attr) +end + +function MOI.get(model::Optimizer{T}, attr::MOI.RelativeGap) where {T} + if model.no_variables_solution !== nothing + return zero(T) + end return MOI.get(model.optimizer, attr) end @@ -1692,6 +1757,37 @@ function MOI.get( return MOI.get(model.optimizer, attr, optimizer_ci) end +function MOI.get( + model::Optimizer, + attr::MOI.ConstraintDual, + c::MOI.ConstraintIndex{F,S}, +) where {F<:MOI.AbstractScalarFunction,S} + if model.no_variables_solution !== nothing + MOI.check_result_index_bounds(model, attr) + end + return _constraint_dual( + model, + attr, + get(model.constraint_outer_to_inner, c, c), + ) +end + +function MOI.get( + model::Optimizer, + attr::MOI.ConstraintDual, + c::MOI.ConstraintIndex{F,S}, +) where {F<:MOI.AbstractVectorFunction,S} + if model.no_variables_solution !== nothing + MOI.check_result_index_bounds(model, attr) + end + return _constraint_dual( + model, + attr, + get(model.constraint_outer_to_inner, c, c), + MOI.dimension(MOI.get(model, MOI.ConstraintSet(), c)), + ) +end + function MOI.get( model::Optimizer, attr::MOI.ConstraintPrimal, @@ -2016,6 +2112,81 @@ function MOI.set( return end +""" + SolveWithoutVariables <: MOI.AbstractOptimizerAttribute + +An attribute that controls whether [`Optimizer`](@ref) solves a model that +contains parameters but no decision variables, instead of passing it to +the inner optimizer. + +Such a model has no degrees of freedom, so its solution can be written down +directly: the primal solution is the vector of parameter values, every +constraint dual is zero, and therefore the dual of each parameter is the +derivative of the objective function with respect to it. + +Most solvers reject a model with no variables, so set this attribute to `false` +only if the inner optimizer handles such a model itself. Defaults to `true`. + +See also [`NoVariablesFeasibilityTolerance`](@ref). +""" +struct SolveWithoutVariables <: MOI.AbstractOptimizerAttribute end + +MOI.supports(::Optimizer, ::SolveWithoutVariables) = true + +function MOI.get(model::Optimizer, ::SolveWithoutVariables) + return model.solve_without_variables +end + +function MOI.set(model::Optimizer, ::SolveWithoutVariables, value::Bool) + model.solve_without_variables = value + return +end + +""" + NoVariablesFeasibilityTolerance <: MOI.AbstractOptimizerAttribute + +An attribute for the tolerance used to decide whether the parameter values +satisfy the constraints of a model that contains parameters but no decision +variables. Such a constraint is a statement about the parameter values alone, so +it is checked by evaluating it and measuring the distance to its set; a distance +greater than this tolerance makes the model `MOI.INFEASIBLE`. + +Only used when [`SolveWithoutVariables`](@ref) is `true`. Defaults to +`1e-6` if `T` is a floating point type and to `zero(T)` otherwise, +because exact arithmetic needs no slack. + +# Example + +```jldoctest +julia> import MathOptInterface as MOI + +julia> import ParametricOptInterface as POI + +julia> model = POI.Optimizer(MOI.Utilities.Model{Float64}()); + +julia> MOI.set(model, POI.NoVariablesFeasibilityTolerance(), 1e-5) + +julia> MOI.get(model, POI.NoVariablesFeasibilityTolerance()) +1.0e-5 +``` +""" +struct NoVariablesFeasibilityTolerance <: MOI.AbstractOptimizerAttribute end + +MOI.supports(::Optimizer, ::NoVariablesFeasibilityTolerance) = true + +function MOI.get(model::Optimizer, ::NoVariablesFeasibilityTolerance) + return model.no_variables_feasibility_tolerance +end + +function MOI.set( + model::Optimizer, + ::NoVariablesFeasibilityTolerance, + value::Real, +) + model.no_variables_feasibility_tolerance = value + return +end + # # Optimize # @@ -2025,9 +2196,16 @@ function MOI.optimize!(model::Optimizer) MOI.Utilities.final_touch(model, nothing) update_parameters!(model) end - MOI.optimize!(model.optimizer) - if MOI.get(model, MOI.DualStatus()) != MOI.NO_SOLUTION && - model.evaluate_duals + model.no_variables_solution = nothing + if model.solve_without_variables && _has_no_variables(model) + _optimize_without_variables!(model) + else + MOI.optimize!(model.optimizer) + end + if model.evaluate_duals && ( + model.no_variables_solution !== nothing || + MOI.get(model, MOI.DualStatus()) != MOI.NO_SOLUTION + ) _compute_dual_of_parameters!(model) end return diff --git a/src/ParametricOptInterface.jl b/src/ParametricOptInterface.jl index de7f0875..a8326b60 100644 --- a/src/ParametricOptInterface.jl +++ b/src/ParametricOptInterface.jl @@ -70,6 +70,25 @@ include("cubic_parser.jl") include("parametric_functions.jl") include("parametric_cubic_function.jl") +""" + _NoVariablesSolution{T} + +The solution of a model that contains parameters but no decision variables. + +Such a model has no degrees of freedom, so [`Optimizer`](@ref) solves it itself +instead of calling the inner optimizer, and caches the result here. The primal +solution is not stored because it is the vector of parameter values, which +`model.parameters` already holds, and the constraint duals are not stored +because they are all zero. +""" +struct _NoVariablesSolution{T} + termination_status::MOI.TerminationStatusCode + primal_status::MOI.ResultStatusCode + dual_status::MOI.ResultStatusCode + objective_value::T + raw_status_string::String +end + """ Optimizer{T}( optimizer::Union{MOI.ModelLike,Any}; @@ -212,6 +231,10 @@ mutable struct Optimizer{T,OT<:MOI.ModelLike} <: MOI.AbstractOptimizer save_original_objective_and_constraints::Bool parameters_in_conflict::Set{MOI.VariableIndex} warn_quad_affine_ambiguous::Bool + # solving models with parameters but no decision variables + solve_without_variables::Bool + no_variables_feasibility_tolerance::T + no_variables_solution::Union{Nothing,_NoVariablesSolution{T}} ext::Dict{Symbol,Any} function Optimizer{T}( @@ -286,6 +309,12 @@ mutable struct Optimizer{T,OT<:MOI.ModelLike} <: MOI.AbstractOptimizer Set{MOI.VariableIndex}(), # warn_quad_affine_ambiguous true, + # solve_without_variables + true, + # no_variables_feasibility_tolerance + _default_no_variables_feasibility_tolerance(T), + # no_variables_solution + nothing, # ext Dict{Symbol,Any}(), ) @@ -331,5 +360,6 @@ include("duals.jl") include("update_parameters.jl") include("MOI_wrapper.jl") include("cubic_objective.jl") +include("no_variables.jl") end # module diff --git a/src/duals.jl b/src/duals.jl index b9444b75..a8305a9e 100644 --- a/src/duals.jl +++ b/src/duals.jl @@ -120,7 +120,7 @@ function _compute_parameters_in_ci!( pf::ParametricAffineFunction{T}, ci::MOI.ConstraintIndex{F,S}, ) where {F,S,T} - cons_dual = MOI.get(model.optimizer, MOI.ConstraintDual(), ci) + cons_dual = _constraint_dual(model, MOI.ConstraintDual(), ci) for term in affine_parameter_terms(pf) model.dual_value_of_parameters[p_val(term.variable)] -= cons_dual * term.coefficient @@ -133,7 +133,7 @@ function _compute_parameters_in_ci!( pf::ParametricQuadraticFunction{T}, ci::MOI.ConstraintIndex{F,S}, ) where {F,S,T} - cons_dual = MOI.get(model.optimizer, MOI.ConstraintDual(), ci) + cons_dual = _constraint_dual(model, MOI.ConstraintDual(), ci) for term in affine_parameter_terms(pf) model.dual_value_of_parameters[p_val(term.variable)] -= cons_dual * term.coefficient @@ -156,7 +156,12 @@ function _compute_parameters_in_ci!( pf::ParametricVectorAffineFunction{T}, ci::MOI.ConstraintIndex{F,S}, ) where {F<:MOI.VectorAffineFunction{T},S} where {T} - cons_dual = MOI.get(model.optimizer, MOI.ConstraintDual(), ci) + cons_dual = _constraint_dual( + model, + MOI.ConstraintDual(), + ci, + length(pf.current_constant), + ) for term in vector_affine_parameter_terms(pf) model.dual_value_of_parameters[p_val(term.scalar_term.variable)] -= cons_dual[term.output_index] * term.scalar_term.coefficient @@ -297,7 +302,12 @@ function _compute_parameters_in_ci!( pf::ParametricVectorQuadraticFunction{T}, ci::MOI.ConstraintIndex{F,S}, ) where {F,S,T} - cons_dual = MOI.get(model.optimizer, MOI.ConstraintDual(), ci) + cons_dual = _constraint_dual( + model, + MOI.ConstraintDual(), + ci, + length(pf.current_constant), + ) for term in vector_affine_parameter_terms(pf) model.dual_value_of_parameters[p_val(term.scalar_term.variable)] -= cons_dual[term.output_index] * term.scalar_term.coefficient diff --git a/src/no_variables.jl b/src/no_variables.jl new file mode 100644 index 00000000..cc99ca09 --- /dev/null +++ b/src/no_variables.jl @@ -0,0 +1,172 @@ +# Copyright (c) 2020: Tomás Gutierrez and contributors +# +# 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. + +""" + _default_no_variables_feasibility_tolerance(::Type{T}) + +The default value of the [`NoVariablesFeasibilityTolerance`](@ref) attribute. +""" +_default_no_variables_feasibility_tolerance(::Type{T}) where {T} = zero(T) + +function _default_no_variables_feasibility_tolerance( + ::Type{T}, +) where {T<:AbstractFloat} + return T(1e-6) +end + +""" + _has_no_variables(model::Optimizer) + +Return `true` if `model` contains no decision variables. Parameters are not +decision variables, so a model that contains parameters only returns `true`. +""" +function _has_no_variables(model::Optimizer) + return iszero(MOI.get(model, NumberOfPureVariables())) +end + +""" + _optimize_without_variables!(model::Optimizer) + +Solve a model that has no decision variables and cache the result in +`model.no_variables_solution`, so that from then on the model has a solution +that the solution attributes are answered from. + +The inner optimizer is never asked to `optimize!`, but `MOI.optimize!` still +flushes pending parameter updates to it beforehand so that its state stays +consistent with POI's caches. Parameter updates are incremental, so skipping +them would corrupt a later solve of a model that has gained decision variables. +""" +function _optimize_without_variables!(model::Optimizer{T}) where {T} + objective_value = _objective_value_no_variables(model) + if _is_feasible_no_variables(model) + model.no_variables_solution = _NoVariablesSolution{T}( + MOI.OPTIMAL, + MOI.FEASIBLE_POINT, + # Every constraint dual is zero, which is a feasible dual solution. + MOI.FEASIBLE_POINT, + objective_value, + "Solved by ParametricOptInterface: the model has no decision variables, so the parameter values are the solution.", + ) + else + model.no_variables_solution = _NoVariablesSolution{T}( + MOI.INFEASIBLE, + # The parameter values are a point, it just violates a constraint. + MOI.INFEASIBLE_POINT, + # There is no certificate of infeasibility to report. + MOI.NO_SOLUTION, + objective_value, + "Solved by ParametricOptInterface: the model has no decision variables and the parameter values violate at least one constraint.", + ) + end + return +end + +""" + _constraint_dual(model::Optimizer, attr::MOI.ConstraintDual, ci) + _constraint_dual(model::Optimizer, attr::MOI.ConstraintDual, ci, dimension) + +Return the dual of the inner constraint `ci`, which is zero (or a vector of +`dimension` zeros) if the model was solved without decision variables. +""" +function _constraint_dual( + model::Optimizer{T}, + attr::MOI.ConstraintDual, + ci::MOI.ConstraintIndex, +) where {T} + if model.no_variables_solution !== nothing + return zero(T) + end + return MOI.get(model.optimizer, attr, ci) +end + +function _constraint_dual( + model::Optimizer{T}, + attr::MOI.ConstraintDual, + ci::MOI.ConstraintIndex, + dimension::Int, +)::Vector{T} where {T} + if model.no_variables_solution !== nothing + return zeros(T, dimension) + end + return MOI.get(model.optimizer, attr, ci) +end + +""" + _objective_value_no_variables(model::Optimizer{T})::T + +Evaluate the objective function of a model with no decision variables at the +current parameter values. +""" +function _objective_value_no_variables(model::Optimizer{T})::T where {T} + if model.cubic_objective_cache !== nothing + return _parametric_constant(model, model.cubic_objective_cache) + elseif model.quadratic_objective_cache !== nothing + return _parametric_constant(model, model.quadratic_objective_cache) + elseif model.affine_objective_cache !== nothing + return _parametric_constant(model, model.affine_objective_cache) + end + # The objective contains neither variables nor parameters, so it is a + # constant. + F = MOI.get(model.original_objective_cache, MOI.ObjectiveFunctionType()) + f = MOI.get(model.original_objective_cache, MOI.ObjectiveFunction{F}()) + return MOI.Utilities.eval_variables(p -> model.parameters[p_idx(p)]::T, f) +end + +""" + _is_feasible_no_variables(model::Optimizer) + +Return `true` if every constraint of a model with no decision variables is +satisfied by the current parameter values, up to +`model.no_variables_feasibility_tolerance`. + +Since the constraints contain no decision variables, each one is a statement +about the parameter values alone and can be checked by evaluating it and +measuring the distance to its set. +""" +function _is_feasible_no_variables(model::Optimizer{T}) where {T} + for (F, S) in MOI.get(model, MOI.ListOfConstraintTypesPresent()) + if F === MOI.VariableIndex && S <: MOI.Parameter + # A parameter is always equal to its own value. + continue + end + for ci in MOI.get(model, MOI.ListOfConstraintIndices{F,S}()) + if !_is_satisfied_no_variables(model, ci) + return false + end + end + end + return true +end + +function _is_satisfied_no_variables( + model::Optimizer{T}, + ci::MOI.ConstraintIndex, +) where {T} + f = MOI.get(model, MOI.ConstraintFunction(), ci) + set = MOI.get(model, MOI.ConstraintSet(), ci) + value = MOI.Utilities.eval_variables(p -> model.parameters[p_idx(p)]::T, f) + distance = try + MOI.Utilities.distance_to_set(value, set) + catch err + # `distance_to_set` has no method for this set. Rather than silently + # declaring the constraint satisfied, point the user at the reason POI + # needs it. + if err isa ErrorException + error( + "Cannot determine whether the constraint $ci is satisfied " * + "because `MOI.Utilities.distance_to_set` is not implemented " * + "for the set type $(typeof(set)). This check is required to " * + "solve a model that has parameters but no decision " * + "variables. Set the `SolveWithoutVariables` attribute to " * + "`false` to defer to the inner optimizer instead, or " * + "implement `MOI.Utilities.distance_to_set` for " * + "$(typeof(set)).\nOriginal error: " * + sprint(showerror, err), + ) + end + rethrow(err) + end + return distance <= model.no_variables_feasibility_tolerance +end diff --git a/test/test_no_variables.jl b/test/test_no_variables.jl new file mode 100644 index 00000000..eb79047f --- /dev/null +++ b/test/test_no_variables.jl @@ -0,0 +1,569 @@ +# Copyright (c) 2020: Tomás Gutierrez and contributors +# +# 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. + +module TestNoVariablesTests + +using Test +using JuMP + +import HiGHS +import MathOptInterface as MOI +import ParametricOptInterface as POI + +# A set for which `MOI.Utilities.distance_to_set` is not implemented, so the +# feasibility of a model with no decision variables cannot be checked. +struct UncheckableSet <: MOI.AbstractVectorSet + dimension::Int +end + +# A set whose `MOI.Utilities.distance_to_set` fails for a reason other than +# being unimplemented, which POI must not relabel as unimplemented. +struct ThrowingSet <: MOI.AbstractVectorSet + dimension::Int +end + +function MOI.Utilities.distance_to_set( + ::MOI.Utilities.ProjectionUpperBoundDistance, + ::AbstractVector, + ::ThrowingSet, +) + return throw(DimensionMismatch("some unrelated failure")) +end + +function runtests() + for name in names(@__MODULE__; all = true) + if startswith("$name", "test_") + @testset "$(name)" begin + getfield(@__MODULE__, name)() + end + end + end + return +end + +function _new_optimizer(; kwargs...) + optimizer = POI.Optimizer(HiGHS.Optimizer(); kwargs...) + MOI.set(optimizer, MOI.Silent(), true) + return optimizer +end + +function test_affine_objective() + optimizer = _new_optimizer() + p, cp = MOI.add_constrained_variable(optimizer, MOI.Parameter(2.0)) + q, cq = MOI.add_constrained_variable(optimizer, MOI.Parameter(3.0)) + f = MOI.ScalarAffineFunction(MOI.ScalarAffineTerm.([4.0, 5.0], [p, q]), 1.0) + MOI.set(optimizer, MOI.ObjectiveSense(), MOI.MIN_SENSE) + MOI.set(optimizer, MOI.ObjectiveFunction{typeof(f)}(), f) + MOI.optimize!(optimizer) + @test MOI.get(optimizer, MOI.TerminationStatus()) == MOI.OPTIMAL + @test MOI.get(optimizer, MOI.PrimalStatus()) == MOI.FEASIBLE_POINT + @test MOI.get(optimizer, MOI.DualStatus()) == MOI.FEASIBLE_POINT + @test MOI.get(optimizer, MOI.ResultCount()) == 1 + @test MOI.get(optimizer, MOI.ObjectiveValue()) ≈ 4 * 2 + 5 * 3 + 1 + @test MOI.get(optimizer, MOI.DualObjectiveValue()) ≈ 4 * 2 + 5 * 3 + 1 + @test MOI.get(optimizer, MOI.VariablePrimal(), p) ≈ 2.0 + @test MOI.get(optimizer, MOI.VariablePrimal(), q) ≈ 3.0 + @test MOI.get(optimizer, MOI.ConstraintDual(), cp) ≈ 4.0 + @test MOI.get(optimizer, MOI.ConstraintDual(), cq) ≈ 5.0 + # updating a parameter and re-solving + MOI.set(optimizer, MOI.ConstraintSet(), cp, MOI.Parameter(7.0)) + MOI.optimize!(optimizer) + @test MOI.get(optimizer, MOI.ObjectiveValue()) ≈ 4 * 7 + 5 * 3 + 1 + @test MOI.get(optimizer, MOI.ConstraintDual(), cp) ≈ 4.0 + return +end + +function test_max_sense_objective() + optimizer = _new_optimizer() + p, cp = MOI.add_constrained_variable(optimizer, MOI.Parameter(2.0)) + f = MOI.ScalarAffineFunction([MOI.ScalarAffineTerm(4.0, p)], 0.0) + MOI.set(optimizer, MOI.ObjectiveSense(), MOI.MAX_SENSE) + MOI.set(optimizer, MOI.ObjectiveFunction{typeof(f)}(), f) + MOI.optimize!(optimizer) + @test MOI.get(optimizer, MOI.ObjectiveValue()) ≈ 8.0 + @test MOI.get(optimizer, MOI.ConstraintDual(), cp) ≈ -4.0 + return +end + +function test_quadratic_objective() + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + @variable(model, p in Parameter(3.0)) + @variable(model, q in Parameter(5.0)) + @objective(model, Min, p^2 + 2p * q + 4q + 7) + optimize!(model) + @test termination_status(model) == MOI.OPTIMAL + @test MOI.get(unsafe_backend(model), POI.ParametricObjectiveType()) == + POI.ParametricQuadraticFunction{Float64} + @test objective_value(model) ≈ 9 + 2 * 15 + 4 * 5 + 7 + # ∂/∂p = 2p + 2q and ∂/∂q = 2p + 4 + @test dual(ParameterRef(p)) ≈ 2 * 3 + 2 * 5 + @test dual(ParameterRef(q)) ≈ 2 * 3 + 4 + # updating a parameter and re-solving + set_parameter_value(p, 1.0) + optimize!(model) + @test objective_value(model) ≈ 1 + 2 * 5 + 4 * 5 + 7 + @test dual(ParameterRef(p)) ≈ 2 * 1 + 2 * 5 + @test dual(ParameterRef(q)) ≈ 2 * 1 + 4 + return +end + +function test_quadratic_objective_max_sense() + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + @variable(model, p in Parameter(3.0)) + @objective(model, Max, p^2) + optimize!(model) + @test objective_value(model) ≈ 9.0 + @test dual(ParameterRef(p)) ≈ -2 * 3 + return +end + +function test_cubic_objective() + # a cubic objective reaches POI as a `ScalarNonlinearFunction`, and `p^3` + # needs no decision variables to be a valid cubic + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + @variable(model, p in Parameter(2.0)) + @objective(model, Min, p^3) + optimize!(model) + @test termination_status(model) == MOI.OPTIMAL + @test MOI.get(unsafe_backend(model), POI.ParametricObjectiveType()) == + POI.ParametricCubicFunction{Float64} + @test objective_value(model) ≈ 8.0 + # ∂(p³)/∂p = 3p² + @test dual(ParameterRef(p)) ≈ 3 * 4 + set_parameter_value(p, 3.0) + optimize!(model) + @test objective_value(model) ≈ 27.0 + @test dual(ParameterRef(p)) ≈ 3 * 9 + return +end + +function test_cubic_objective_distinct_parameters() + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + @variable(model, p in Parameter(2.0)) + @variable(model, q in Parameter(3.0)) + @variable(model, r in Parameter(4.0)) + @objective(model, Min, p * q * r) + optimize!(model) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 24.0 + # ∂(pqr)/∂p = qr, and so on + @test dual(ParameterRef(p)) ≈ 3 * 4 + @test dual(ParameterRef(q)) ≈ 2 * 4 + @test dual(ParameterRef(r)) ≈ 2 * 3 + return +end + +function test_cubic_objective_mixed_degrees() + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + @variable(model, p in Parameter(2.0)) + @variable(model, q in Parameter(3.0)) + # cubic, quadratic and affine parameter terms together + @objective(model, Min, 2 * p^2 * q + p) + optimize!(model) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 2 * 4 * 3 + 2 + # ∂/∂p = 4pq + 1 and ∂/∂q = 2p² + @test dual(ParameterRef(p)) ≈ 4 * 2 * 3 + 1 + @test dual(ParameterRef(q)) ≈ 2 * 4 + return +end + +function test_cubic_objective_max_sense() + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + @variable(model, p in Parameter(2.0)) + @objective(model, Max, p^3) + optimize!(model) + @test objective_value(model) ≈ 8.0 + @test dual(ParameterRef(p)) ≈ -3 * 4 + return +end + +function test_no_objective() + optimizer = _new_optimizer() + p, cp = MOI.add_constrained_variable(optimizer, MOI.Parameter(2.0)) + MOI.optimize!(optimizer) + @test MOI.get(optimizer, MOI.TerminationStatus()) == MOI.OPTIMAL + @test MOI.get(optimizer, MOI.ObjectiveValue()) ≈ 0.0 + @test MOI.get(optimizer, MOI.ConstraintDual(), cp) ≈ 0.0 + return +end + +function test_constant_objective() + optimizer = _new_optimizer() + p, cp = MOI.add_constrained_variable(optimizer, MOI.Parameter(2.0)) + f = MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], 3.0) + MOI.set(optimizer, MOI.ObjectiveSense(), MOI.MIN_SENSE) + MOI.set(optimizer, MOI.ObjectiveFunction{typeof(f)}(), f) + MOI.optimize!(optimizer) + @test MOI.get(optimizer, MOI.TerminationStatus()) == MOI.OPTIMAL + @test MOI.get(optimizer, MOI.ObjectiveValue()) ≈ 3.0 + @test MOI.get(optimizer, MOI.ConstraintDual(), cp) ≈ 0.0 + return +end + +function test_feasible_scalar_constraint_has_zero_dual() + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + @variable(model, p in Parameter(2.0)) + @constraint(model, c, 3p <= 12) + @objective(model, Min, 4p) + optimize!(model) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 8.0 + @test value(c) ≈ 6.0 + @test dual(c) ≈ 0.0 + # the constraint dual is zero, so only the objective contributes + @test dual(ParameterRef(p)) ≈ 4.0 + return +end + +function test_feasible_vector_constraint_has_zero_dual() + # HiGHS needs the bridges to handle `Zeros` + model = + direct_model(POI.Optimizer(HiGHS.Optimizer; with_bridge_type = Float64)) + set_silent(model) + @variable(model, p[1:2] in Parameter.([1.0, 2.0])) + @constraint(model, c, [p[1] - 1, p[2] - 2] in MOI.Zeros(2)) + @objective(model, Min, p[1] + 3p[2]) + optimize!(model) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 7.0 + @test dual(c) ≈ [0.0, 0.0] + @test dual(ParameterRef(p[1])) ≈ 1.0 + @test dual(ParameterRef(p[2])) ≈ 3.0 + return +end + +function test_feasible_quadratic_constraint_has_zero_dual() + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + @variable(model, p in Parameter(2.0)) + @variable(model, q in Parameter(3.0)) + # a `pp` constraint, so the dual loop reads a scalar quadratic constraint + @constraint(model, c, p * q <= 12) + @objective(model, Min, 4p + 5q) + optimize!(model) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 4 * 2 + 5 * 3 + @test dual(c) ≈ 0.0 + # `c` contributes nothing, so the duals are the objective coefficients even + # though `∂(p*q)/∂p = q` is nonzero + @test dual(ParameterRef(p)) ≈ 4.0 + @test dual(ParameterRef(q)) ≈ 5.0 + return +end + +function test_feasible_vector_quadratic_constraint_has_zero_dual() + # HiGHS needs the bridges to handle `Nonnegatives` + model = + direct_model(POI.Optimizer(HiGHS.Optimizer; with_bridge_type = Float64)) + set_silent(model) + @variable(model, p in Parameter(2.0)) + @variable(model, q in Parameter(3.0)) + # a vector `pp` constraint, so the dual loop reads a vector quadratic + # constraint and needs a zero vector of the right dimension + @constraint(model, c, [12 - p * q, 6 - p * p] in MOI.Nonnegatives(2)) + @objective(model, Min, 4p + 5q) + optimize!(model) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 4 * 2 + 5 * 3 + @test dual(c) ≈ [0.0, 0.0] + @test dual(ParameterRef(p)) ≈ 4.0 + @test dual(ParameterRef(q)) ≈ 5.0 + return +end + +function test_solution_is_cached() + optimizer = _new_optimizer() + p, _ = MOI.add_constrained_variable(optimizer, MOI.Parameter(2.0)) + f = MOI.ScalarAffineFunction([MOI.ScalarAffineTerm(4.0, p)], 0.0) + MOI.set(optimizer, MOI.ObjectiveSense(), MOI.MIN_SENSE) + MOI.set(optimizer, MOI.ObjectiveFunction{typeof(f)}(), f) + @test optimizer.no_variables_solution === nothing + MOI.optimize!(optimizer) + solution = optimizer.no_variables_solution + @test solution isa POI._NoVariablesSolution{Float64} + @test solution.termination_status == MOI.OPTIMAL + @test solution.objective_value ≈ 8.0 + # a solve that the inner optimizer handles must clear the cache + x = MOI.add_variable(optimizer) + MOI.add_constraint(optimizer, x, MOI.GreaterThan(1.0)) + MOI.optimize!(optimizer) + @test optimizer.no_variables_solution === nothing + @test MOI.get(optimizer, MOI.TerminationStatus()) == MOI.OPTIMAL + return +end + +function test_violated_constraint_is_infeasible() + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + @variable(model, p in Parameter(20.0)) + @constraint(model, c, p <= 10) + @objective(model, Min, 4p) + optimize!(model) + @test termination_status(model) == MOI.INFEASIBLE + @test primal_status(model) == MOI.INFEASIBLE_POINT + @test dual_status(model) == MOI.NO_SOLUTION + # the parameter values are still available + @test value(p) ≈ 20.0 + @test objective_value(model) ≈ 80.0 + # the dual of a parameter is the derivative of the objective function, which + # is well defined even though the parameter values violate `c` + @test dual(ParameterRef(p)) ≈ 4.0 + @test dual(c) ≈ 0.0 + return +end + +function test_solution_attributes() + optimizer = _new_optimizer() + p, _ = MOI.add_constrained_variable(optimizer, MOI.Parameter(2.0)) + f = MOI.ScalarAffineFunction([MOI.ScalarAffineTerm(4.0, p)], 0.0) + MOI.set(optimizer, MOI.ObjectiveSense(), MOI.MIN_SENSE) + MOI.set(optimizer, MOI.ObjectiveFunction{typeof(f)}(), f) + MOI.optimize!(optimizer) + # the solution is exact, so there is no gap and no bound to close + @test MOI.get(optimizer, MOI.ObjectiveBound()) ≈ 8.0 + @test MOI.get(optimizer, MOI.RelativeGap()) ≈ 0.0 + @test MOI.get(optimizer, MOI.SolveTimeSec()) == 0.0 + @test occursin( + "ParametricOptInterface", + MOI.get(optimizer, MOI.RawStatusString()), + ) + return +end + +function test_feasibility_within_tolerance() + # a violation strictly between zero and the default tolerance, so the test + # fails if the default becomes exact + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + @variable(model, p in Parameter(1e-9)) + @constraint(model, c, p == 0) + @objective(model, Min, 2p) + optimize!(model) + @test 0 < + 1e-9 < + MOI.get(unsafe_backend(model), POI.NoVariablesFeasibilityTolerance()) + @test termination_status(model) == MOI.OPTIMAL + # just beyond the default tolerance + set_parameter_value(p, 1e-3) + optimize!(model) + @test termination_status(model) == MOI.INFEASIBLE + return +end + +function test_feasibility_tolerance_is_respected() + for (tolerance, status) in ((1e-3, MOI.OPTIMAL), (1e-9, MOI.INFEASIBLE)) + optimizer = _new_optimizer() + MOI.set(optimizer, POI.NoVariablesFeasibilityTolerance(), tolerance) + p, _ = MOI.add_constrained_variable(optimizer, MOI.Parameter(1e-6)) + f = MOI.ScalarAffineFunction([MOI.ScalarAffineTerm(1.0, p)], 0.0) + MOI.add_constraint(optimizer, f, MOI.EqualTo(0.0)) + MOI.optimize!(optimizer) + @test MOI.get(optimizer, MOI.TerminationStatus()) == status + end + return +end + +function test_attribute_defaults_and_round_trip() + optimizer = _new_optimizer() + @test MOI.supports(optimizer, POI.SolveWithoutVariables()) + @test MOI.supports(optimizer, POI.NoVariablesFeasibilityTolerance()) + @test MOI.get(optimizer, POI.SolveWithoutVariables()) == true + @test MOI.get(optimizer, POI.NoVariablesFeasibilityTolerance()) == 1e-6 + MOI.set(optimizer, POI.SolveWithoutVariables(), false) + MOI.set(optimizer, POI.NoVariablesFeasibilityTolerance(), 1e-5) + @test MOI.get(optimizer, POI.SolveWithoutVariables()) == false + @test MOI.get(optimizer, POI.NoVariablesFeasibilityTolerance()) == 1e-5 + # `MOI.empty!` resets the solution but keeps the configuration + MOI.empty!(optimizer) + @test MOI.get(optimizer, POI.SolveWithoutVariables()) == false + @test MOI.get(optimizer, POI.NoVariablesFeasibilityTolerance()) == 1e-5 + # exact arithmetic needs no slack + exact = POI.Optimizer{Int}(MOI.Utilities.Model{Int}()) + @test MOI.get(exact, POI.NoVariablesFeasibilityTolerance()) == 0 + return +end + +function test_attributes_through_jump() + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + set_attribute(model, POI.SolveWithoutVariables(), false) + @test get_attribute(model, POI.SolveWithoutVariables()) == false + set_attribute(model, POI.NoVariablesFeasibilityTolerance(), 1e-5) + @test get_attribute(model, POI.NoVariablesFeasibilityTolerance()) == 1e-5 + return +end + +function test_solve_without_variables_false() + optimizer = _new_optimizer() + MOI.set(optimizer, POI.SolveWithoutVariables(), false) + p, _ = MOI.add_constrained_variable(optimizer, MOI.Parameter(2.0)) + f = MOI.ScalarAffineFunction([MOI.ScalarAffineTerm(4.0, p)], 0.0) + MOI.set(optimizer, MOI.ObjectiveSense(), MOI.MIN_SENSE) + MOI.set(optimizer, MOI.ObjectiveFunction{typeof(f)}(), f) + MOI.optimize!(optimizer) + # HiGHS rejects a model with no variables + @test MOI.get(optimizer, MOI.TerminationStatus()) == MOI.INVALID_MODEL + @test optimizer.no_variables_solution === nothing + return +end + +function test_evaluate_duals_false() + optimizer = _new_optimizer(; evaluate_duals = false) + p, cp = MOI.add_constrained_variable(optimizer, MOI.Parameter(2.0)) + f = MOI.ScalarAffineFunction([MOI.ScalarAffineTerm(4.0, p)], 0.0) + MOI.set(optimizer, MOI.ObjectiveSense(), MOI.MIN_SENSE) + MOI.set(optimizer, MOI.ObjectiveFunction{typeof(f)}(), f) + MOI.optimize!(optimizer) + @test MOI.get(optimizer, MOI.TerminationStatus()) == MOI.OPTIMAL + @test MOI.get(optimizer, MOI.ObjectiveValue()) ≈ 8.0 + @test_throws( + MOI.GetAttributeNotAllowed, + MOI.get(optimizer, MOI.ConstraintDual(), cp), + ) + return +end + +function test_adding_a_variable_after_solving() + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + @variable(model, p in Parameter(2.0)) + @constraint(model, c1, 3p <= 12) + @objective(model, Min, 4p) + optimize!(model) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 8.0 + # the model now gains a decision variable, so the inner optimizer solves it + # and must still hold data consistent with the parameter values + @variable(model, x >= 0) + @constraint(model, c2, x >= 2p) + @objective(model, Min, 4p + x) + set_parameter_value(p, 3.0) + optimize!(model) + @test termination_status(model) == MOI.OPTIMAL + @test value(x) ≈ 6.0 + @test objective_value(model) ≈ 4 * 3 + 6 + # 4 from the objective and 2 from the dual of `c2` + @test dual(ParameterRef(p)) ≈ 4 + 2 + return +end + +function test_deleting_the_last_variable() + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + @variable(model, p in Parameter(2.0)) + @variable(model, x >= 0) + @objective(model, Min, 4p + x) + optimize!(model) + @test objective_value(model) ≈ 8.0 + delete(model, x) + optimize!(model) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 8.0 + @test dual(ParameterRef(p)) ≈ 4.0 + return +end + +function test_result_index_bounds() + # `MOI.ConstraintDual` must respect `attr.result_index` both when POI + # answers from its own solution and when the inner optimizer answers + optimizer = _new_optimizer() + p, cp = MOI.add_constrained_variable(optimizer, MOI.Parameter(2.0)) + f = MOI.ScalarAffineFunction([MOI.ScalarAffineTerm(1.0, p)], 0.0) + c = MOI.add_constraint(optimizer, f, MOI.LessThan(10.0)) + MOI.set(optimizer, MOI.ObjectiveSense(), MOI.MIN_SENSE) + MOI.set(optimizer, MOI.ObjectiveFunction{typeof(f)}(), f) + MOI.optimize!(optimizer) + @test MOI.get(optimizer, MOI.ResultCount()) == 1 + @test MOI.get(optimizer, MOI.ConstraintDual(1), c) ≈ 0.0 + @test_throws( + MOI.ResultIndexBoundsError, + MOI.get(optimizer, MOI.ConstraintDual(2), c), + ) + @test_throws( + MOI.ResultIndexBoundsError, + MOI.get(optimizer, MOI.ObjectiveValue(2)), + ) + @test MOI.get(optimizer, MOI.PrimalStatus(2)) == MOI.NO_SOLUTION + @test MOI.get(optimizer, MOI.DualStatus(2)) == MOI.NO_SOLUTION + return +end + +function test_result_index_bounds_vector() + inner = MOI.Utilities.UniversalFallback(MOI.Utilities.Model{Float64}()) + optimizer = POI.Optimizer(MOI.Utilities.MockOptimizer(inner)) + p, _ = MOI.add_constrained_variable(optimizer, MOI.Parameter(0.0)) + f = MOI.VectorAffineFunction( + [MOI.VectorAffineTerm(1, MOI.ScalarAffineTerm(1.0, p))], + [0.0, 0.0], + ) + c = MOI.add_constraint(optimizer, f, MOI.Zeros(2)) + MOI.optimize!(optimizer) + @test MOI.get(optimizer, MOI.ConstraintDual(1), c) ≈ [0.0, 0.0] + @test_throws( + MOI.ResultIndexBoundsError, + MOI.get(optimizer, MOI.ConstraintDual(2), c), + ) + return +end + +function test_empty_model() + optimizer = _new_optimizer() + MOI.optimize!(optimizer) + @test MOI.get(optimizer, MOI.TerminationStatus()) == MOI.OPTIMAL + @test MOI.get(optimizer, MOI.ObjectiveValue()) ≈ 0.0 + MOI.empty!(optimizer) + @test MOI.get(optimizer, MOI.TerminationStatus()) == MOI.OPTIMIZE_NOT_CALLED + return +end + +function test_unsupported_set_for_feasibility_check() + # `UniversalFallback` stores the constraint even though `Model` does not + # support the set, so the feasibility check is what fails. + inner = MOI.Utilities.UniversalFallback(MOI.Utilities.Model{Float64}()) + optimizer = POI.Optimizer(MOI.Utilities.MockOptimizer(inner)) + p, _ = MOI.add_constrained_variable(optimizer, MOI.Parameter(2.0)) + f = MOI.VectorAffineFunction( + [MOI.VectorAffineTerm(1, MOI.ScalarAffineTerm(1.0, p))], + [0.0], + ) + MOI.add_constraint(optimizer, f, UncheckableSet(1)) + err = try + MOI.optimize!(optimizer) + nothing + catch e + e + end + @test err isa ErrorException + @test occursin("distance_to_set", err.msg) + @test occursin("SolveWithoutVariables", err.msg) + return +end + +function test_unrelated_error_in_feasibility_check_is_rethrown() + # a failure that is not "the method is missing" must reach the user as + # itself, not relabelled as an unimplemented `distance_to_set` + inner = MOI.Utilities.UniversalFallback(MOI.Utilities.Model{Float64}()) + optimizer = POI.Optimizer(MOI.Utilities.MockOptimizer(inner)) + p, _ = MOI.add_constrained_variable(optimizer, MOI.Parameter(2.0)) + f = MOI.VectorAffineFunction( + [MOI.VectorAffineTerm(1, MOI.ScalarAffineTerm(1.0, p))], + [0.0], + ) + MOI.add_constraint(optimizer, f, ThrowingSet(1)) + @test_throws DimensionMismatch MOI.optimize!(optimizer) + return +end + +end # module + +TestNoVariablesTests.runtests() From e81ea881d94638f3da23680dfaa0d3a8f7c9126e Mon Sep 17 00:00:00 2001 From: joaquimg Date: Sun, 16 Aug 2026 20:31:28 -0300 Subject: [PATCH 2/3] fix tests and docs --- docs/src/background.md | 1 + test/test_no_variables.jl | 8 +++----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/src/background.md b/docs/src/background.md index b1823bd3..cf647684 100644 --- a/docs/src/background.md +++ b/docs/src/background.md @@ -125,6 +125,7 @@ ERROR: Constraints of type MathOptInterface.ScalarQuadraticFunction{Float64}-in- If you expected the solver to support your problem, you may have an error in your formulation. Otherwise, consider using a different solver. The list of available solvers, along with the problem types they support, is available at https://jump.dev/JuMP.jl/stable/installation/#Supported-solvers. + Stacktrace: [...] ``` diff --git a/test/test_no_variables.jl b/test/test_no_variables.jl index eb79047f..bdaa2333 100644 --- a/test/test_no_variables.jl +++ b/test/test_no_variables.jl @@ -24,11 +24,9 @@ struct ThrowingSet <: MOI.AbstractVectorSet dimension::Int end -function MOI.Utilities.distance_to_set( - ::MOI.Utilities.ProjectionUpperBoundDistance, - ::AbstractVector, - ::ThrowingSet, -) +# the two argument form is what POI calls, and unlike the three argument form it +# does not depend on `ProjectionUpperBoundDistance` being available +function MOI.Utilities.distance_to_set(::AbstractVector, ::ThrowingSet) return throw(DimensionMismatch("some unrelated failure")) end From 750175d2e620544cb2c560f68696d3dba3545e8f Mon Sep 17 00:00:00 2001 From: joaquimg Date: Sun, 16 Aug 2026 22:51:15 -0300 Subject: [PATCH 3/3] simplify MOI facing side --- src/MOI_wrapper.jl | 85 +++++++++------------------------------------ src/no_variables.jl | 83 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 69 deletions(-) diff --git a/src/MOI_wrapper.jl b/src/MOI_wrapper.jl index 5f1f9f19..27829682 100644 --- a/src/MOI_wrapper.jl +++ b/src/MOI_wrapper.jl @@ -1664,78 +1664,25 @@ function MOI.get( return MOI.get(model.optimizer, attr, v) end -function MOI.get(model::Optimizer, attr::MOI.TerminationStatus) - solution = model.no_variables_solution - if solution !== nothing - return solution.termination_status - end - return MOI.get(model.optimizer, attr) -end - -function MOI.get(model::Optimizer, attr::MOI.PrimalStatus) - solution = model.no_variables_solution - if solution !== nothing - return attr.result_index == 1 ? solution.primal_status : MOI.NO_SOLUTION - end - return MOI.get(model.optimizer, attr) -end - -function MOI.get(model::Optimizer, attr::MOI.DualStatus) - solution = model.no_variables_solution - if solution !== nothing - return attr.result_index == 1 ? solution.dual_status : MOI.NO_SOLUTION - end - return MOI.get(model.optimizer, attr) -end - -function MOI.get(model::Optimizer, attr::MOI.ResultCount) - if model.no_variables_solution !== nothing - return 1 - end - return MOI.get(model.optimizer, attr) -end - -function MOI.get(model::Optimizer, attr::MOI.RawStatusString) - solution = model.no_variables_solution - if solution !== nothing - return solution.raw_status_string - end - return MOI.get(model.optimizer, attr) -end - -function MOI.get(model::Optimizer, attr::MOI.SolveTimeSec) - if model.no_variables_solution !== nothing - return 0.0 - end - return MOI.get(model.optimizer, attr) -end - -function MOI.get( - model::Optimizer, - attr::T, -) where {T<:Union{MOI.ObjectiveValue,MOI.DualObjectiveValue}} - solution = model.no_variables_solution - if solution !== nothing - MOI.check_result_index_bounds(model, attr) - # There are no variables, so the primal and dual objective values - # coincide with the objective function evaluated at the parameter - # values. - return solution.objective_value - end - return MOI.get(model.optimizer, attr) -end +# The solution attributes that `Optimizer` answers itself when the model was +# solved without decision variables. +const _NoVariablesAttribute = Union{ + MOI.DualObjectiveValue, + MOI.DualStatus, + MOI.ObjectiveBound, + MOI.ObjectiveValue, + MOI.PrimalStatus, + MOI.RawStatusString, + MOI.RelativeGap, + MOI.ResultCount, + MOI.SolveTimeSec, + MOI.TerminationStatus, +} -function MOI.get(model::Optimizer, attr::MOI.ObjectiveBound) +function MOI.get(model::Optimizer, attr::_NoVariablesAttribute) solution = model.no_variables_solution if solution !== nothing - return solution.objective_value - end - return MOI.get(model.optimizer, attr) -end - -function MOI.get(model::Optimizer{T}, attr::MOI.RelativeGap) where {T} - if model.no_variables_solution !== nothing - return zero(T) + return _no_variables_value(model, attr, solution) end return MOI.get(model.optimizer, attr) end diff --git a/src/no_variables.jl b/src/no_variables.jl index cc99ca09..c74aa89b 100644 --- a/src/no_variables.jl +++ b/src/no_variables.jl @@ -63,6 +63,89 @@ function _optimize_without_variables!(model::Optimizer{T}) where {T} return end +""" + _no_variables_value(model::Optimizer, attr, solution::_NoVariablesSolution) + +Return the value of the solution attribute `attr` of a model that was solved +without decision variables. Dispatched on `attr` from the single `MOI.get` +method for [`_NoVariablesAttribute`](@ref) in `src/MOI_wrapper.jl`. +""" +function _no_variables_value( + ::Optimizer, + ::MOI.TerminationStatus, + solution::_NoVariablesSolution, +) + return solution.termination_status +end + +function _no_variables_value( + ::Optimizer, + attr::MOI.PrimalStatus, + solution::_NoVariablesSolution, +) + return attr.result_index == 1 ? solution.primal_status : MOI.NO_SOLUTION +end + +function _no_variables_value( + ::Optimizer, + attr::MOI.DualStatus, + solution::_NoVariablesSolution, +) + return attr.result_index == 1 ? solution.dual_status : MOI.NO_SOLUTION +end + +function _no_variables_value( + ::Optimizer, + ::MOI.ResultCount, + ::_NoVariablesSolution, +) + return 1 +end + +function _no_variables_value( + ::Optimizer, + ::MOI.RawStatusString, + solution::_NoVariablesSolution, +) + return solution.raw_status_string +end + +function _no_variables_value( + ::Optimizer, + ::MOI.SolveTimeSec, + ::_NoVariablesSolution, +) + return 0.0 +end + +function _no_variables_value( + ::Optimizer{T}, + ::MOI.RelativeGap, + ::_NoVariablesSolution{T}, +) where {T} + # the solution is exact, so there is no gap + return zero(T) +end + +function _no_variables_value( + ::Optimizer, + ::MOI.ObjectiveBound, + solution::_NoVariablesSolution, +) + return solution.objective_value +end + +function _no_variables_value( + model::Optimizer, + attr::Union{MOI.ObjectiveValue,MOI.DualObjectiveValue}, + solution::_NoVariablesSolution, +) + MOI.check_result_index_bounds(model, attr) + # There are no variables, so the primal and dual objective values coincide + # with the objective function evaluated at the parameter values. + return solution.objective_value +end + """ _constraint_dual(model::Optimizer, attr::MOI.ConstraintDual, ci) _constraint_dual(model::Optimizer, attr::MOI.ConstraintDual, ci, dimension)