Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/src/background.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
[...]
```
Expand Down
9 changes: 9 additions & 0 deletions docs/src/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 10 additions & 0 deletions docs/src/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,13 @@ Optimizer
```@docs
ConstraintsInterpretation
```

## `SolveWithoutVariables`
```@docs
SolveWithoutVariables
```

## `NoVariablesFeasibilityTolerance`
```@docs
NoVariablesFeasibilityTolerance
```
153 changes: 139 additions & 14 deletions src/MOI_wrapper.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1660,18 +1664,26 @@ function MOI.get(
return MOI.get(model.optimizer, attr, v)
end

function MOI.get(
model::Optimizer,
attr::T,
) where {
T<:Union{
MOI.TerminationStatus,
MOI.ObjectiveValue,
MOI.DualObjectiveValue,
MOI.PrimalStatus,
MOI.DualStatus,
},
# 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::_NoVariablesAttribute)
solution = model.no_variables_solution
if solution !== nothing
return _no_variables_value(model, attr, solution)
end
return MOI.get(model.optimizer, attr)
end

Expand All @@ -1692,6 +1704,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,
Expand Down Expand Up @@ -2016,6 +2059,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
#
Expand All @@ -2025,9 +2143,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
Expand Down
30 changes: 30 additions & 0 deletions src/ParametricOptInterface.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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}(
Expand Down Expand Up @@ -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}(),
)
Expand Down Expand Up @@ -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
18 changes: 14 additions & 4 deletions src/duals.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading