Skip to content

Commit 59fc2f2

Browse files
committed
Restore the optimizer LOA displaces; refuse to re-solve its result cache
1 parent ec74744 commit 59fc2f2

4 files changed

Lines changed: 164 additions & 20 deletions

File tree

src/datatypes.jl

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -739,9 +739,9 @@ mutable struct GDPData{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, C
739739
# Solution data
740740
solution_method::Union{Nothing, AbstractSolutionMethod}
741741
ready_to_optimize::Bool
742-
# Real solver stashed by an LOA solve so a later reformulation
743-
# re-solve can replace the injected solution optimizer.
744-
loa_solver::Any
742+
# The optimizer an LOA solve displaced (it solves the NLP in place),
743+
# stashed so a later reformulation re-solve can put it back.
744+
displaced_optimizer::Any
745745

746746
# Default constructor
747747
function GDPData{M, V, C}() where {M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, C}

src/loa.jl

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ function _optimize_hook(model::JuMP.AbstractModel, method::LOA; kwargs...)
4242
master = _build_loa_master(problem, method)
4343
_relax_binaries(problem)
4444
nlp = problem.nlp
45-
JuMP.set_optimizer(nlp, method.nlp_optimizer)
45+
displaced_optimizer = _install_nlp_optimizer(nlp, method.nlp_optimizer,
46+
gdp_data(model).displaced_optimizer)
4647
JuMP.set_silent(nlp)
4748
t_start = time()
4849
overall_deadline = t_start + method.time_limit
@@ -181,7 +182,8 @@ function _optimize_hook(model::JuMP.AbstractModel, method::LOA; kwargs...)
181182
end
182183
outcome = (results = feasible_results, status = status,
183184
raw_status = message, bound = bound,
184-
solve_time = time() - t_start, solver_name = solver)
185+
solve_time = time() - t_start, solver_name = solver,
186+
displaced_optimizer = displaced_optimizer)
185187
# Injection is not a reformulation; a later method must rebuild.
186188
_set_solution_method(model, method)
187189
_set_ready_to_optimize(model, false)
@@ -402,6 +404,50 @@ function _restore_binaries(problem::_LOAProblem)
402404
return
403405
end
404406

407+
################################################################################
408+
# OPTIMIZER HANDOVER
409+
################################################################################
410+
# Install the NLP solver, returning the optimizer it displaces. LOA
411+
# solves the NLP on the model itself, so that one is the user's own. A
412+
# re-solve displaces the last run's cache instead, so `previous` (the
413+
# optimizer already stashed) carries through untouched.
414+
function _install_nlp_optimizer(
415+
nlp::JuMP.AbstractModel,
416+
nlp_optimizer,
417+
previous
418+
)
419+
backend = JuMP.backend(nlp)
420+
displaced = if backend isa _MOI.Utilities.CachingOptimizer &&
421+
backend.optimizer !== nothing
422+
JuMP.unsafe_backend(nlp)
423+
else
424+
nothing
425+
end
426+
JuMP.set_optimizer(nlp, nlp_optimizer)
427+
return displaced isa _LOAResultCache ? previous : displaced
428+
end
429+
430+
# Model types without a MOI backend of their own never carry a cache.
431+
function _has_loa_cache(model::JuMP.GenericModel)
432+
backend = JuMP.backend(model)
433+
return backend isa _MOI.Utilities.CachingOptimizer &&
434+
backend.optimizer isa _LOAResultCache
435+
end
436+
_has_loa_cache(::JuMP.AbstractModel) = false
437+
438+
# Restore the displaced optimizer, emptied so the re-solve copies the
439+
# model in. Without one, drop the cache rather than serve its results.
440+
function _restore_displaced_optimizer(model::JuMP.AbstractModel)
441+
displaced = gdp_data(model).displaced_optimizer
442+
if displaced === nothing
443+
_MOI.Utilities.drop_optimizer(JuMP.backend(model))
444+
else
445+
_MOI.empty!(displaced)
446+
JuMP.set_optimizer(model, () -> displaced)
447+
end
448+
return
449+
end
450+
405451
################################################################################
406452
# MASTER CONSTRUCTION
407453
################################################################################
@@ -602,12 +648,18 @@ end
602648
# LOA RESULT CACHE (MOI LAYER)
603649
################################################################################
604650
# `_LOAResultCache` delegates the MOI interface to its inner mock. Only
605-
# `SolverName` (the mock hardcodes "Mock") and the bound attributes (a
651+
# `SolverName` (the mock hardcodes "Mock"), the bound attributes (a
606652
# raw `KeyError` from the mock's generic storage when the master
607-
# produced no bound) are intercepted.
653+
# produced no bound), and `optimize!` are intercepted.
608654
_MOI.is_empty(cache::_LOAResultCache) = _MOI.is_empty(cache.mock)
609655
_MOI.empty!(cache::_LOAResultCache) = _MOI.empty!(cache.mock)
610-
_MOI.optimize!(cache::_LOAResultCache) = _MOI.optimize!(cache.mock)
656+
# "Solving" the cache would clear JuMP's dirty flag and serve the stored
657+
# results again, so an edited model would report a stale point as fresh.
658+
function _MOI.optimize!(cache::_LOAResultCache)
659+
return error("This model holds the results of an LOA solve and " *
660+
"cannot be re-solved directly (`ignore_optimize_hook = true`). " *
661+
"Call `optimize!(model, gdp_method = ...)` to rebuild and solve.")
662+
end
611663
_MOI.copy_to(cache::_LOAResultCache, src::_MOI.ModelLike) =
612664
_MOI.copy_to(cache.mock, src)
613665
_MOI.add_variable(cache::_LOAResultCache) = _MOI.add_variable(cache.mock)
@@ -817,7 +869,7 @@ function _load_solution(
817869
_MOI.Utilities.drop_optimizer(JuMP.backend(problem.nlp))
818870
_restore_binaries(problem)
819871
_inject_solution(problem.nlp, problem, outcome)
820-
gdp_data(model).loa_solver = method.nlp_optimizer
872+
gdp_data(model).displaced_optimizer = outcome.displaced_optimizer
821873
return
822874
end
823875

src/model.jl

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,10 @@ function _optimize_hook(
5555
method::AbstractReformulationMethod;
5656
kwargs...
5757
)
58-
# A prior LOA solve left an injected solution optimizer in the slot;
59-
# swap the real solver back before a genuine re-solve, but only if the
60-
# user has not already set their own optimizer since.
61-
solver = gdp_data(model).loa_solver
62-
if solver !== nothing
63-
backend = JuMP.backend(model)
64-
backend isa _MOI.Utilities.CachingOptimizer &&
65-
backend.optimizer isa _LOAResultCache &&
66-
JuMP.set_optimizer(model, solver)
67-
gdp_data(model).loa_solver = nothing
68-
end
58+
# A prior LOA solve left its result cache in the slot; put the
59+
# optimizer it displaced back, unless the user has since set another.
60+
_has_loa_cache(model) && _restore_displaced_optimizer(model)
61+
gdp_data(model).displaced_optimizer = nothing
6962
if !_ready_to_optimize(model) || _solution_method(model) != method
7063
reformulate_model(model, method)
7164
end

test/constraints/loa.jl

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,101 @@ function test_loa_restores_prior_time_limit()
369369
@test objective_value(model) 7.0 atol = 1e-4
370370
end
371371

372+
function test_loa_restores_displaced_optimizer()
373+
# LOA solves the NLP on the model itself, so it displaces the
374+
# model's own optimizer. A later reformulation solve must get that
375+
# optimizer back, not LOA's NLP solver.
376+
model = GDPModel(HiGHS.Optimizer)
377+
set_silent(model)
378+
set_optimizer_attribute(model, "mip_rel_gap", 0.25)
379+
@variable(model, 0 <= x <= 10)
380+
@variable(model, Y[1:2], Logical)
381+
@constraint(model, x <= 3, Disjunct(Y[1]))
382+
@constraint(model, x <= 7, Disjunct(Y[2]))
383+
@disjunction(model, Y)
384+
@objective(model, Max, x)
385+
displaced = JuMP.unsafe_backend(model)
386+
387+
optimize!(model, gdp_method = LOA(HiGHS.Optimizer))
388+
@test DP.gdp_data(model).displaced_optimizer === displaced
389+
390+
# The reformulation solve restores it, attributes intact.
391+
optimize!(model, gdp_method = BigM())
392+
@test JuMP.unsafe_backend(model) === displaced
393+
@test get_optimizer_attribute(model, "mip_rel_gap") == 0.25
394+
@test DP.gdp_data(model).displaced_optimizer === nothing
395+
@test termination_status(model) == MOI.OPTIMAL
396+
@test objective_value(model) 7.0 atol = 1e-4
397+
end
398+
399+
function test_loa_repeated_solve_keeps_displaced_optimizer()
400+
# A second LOA solve displaces the first one's result cache, not the
401+
# model's own optimizer. The stash must still hold the latter.
402+
model = GDPModel(HiGHS.Optimizer)
403+
set_silent(model)
404+
@variable(model, 0 <= x <= 10)
405+
@variable(model, Y[1:2], Logical)
406+
@constraint(model, x <= 3, Disjunct(Y[1]))
407+
@constraint(model, x <= 7, Disjunct(Y[2]))
408+
@disjunction(model, Y)
409+
@objective(model, Max, x)
410+
displaced = JuMP.unsafe_backend(model)
411+
412+
optimize!(model, gdp_method = LOA(HiGHS.Optimizer))
413+
optimize!(model, gdp_method = LOA(HiGHS.Optimizer))
414+
@test DP.gdp_data(model).displaced_optimizer === displaced
415+
416+
optimize!(model, gdp_method = BigM())
417+
@test JuMP.unsafe_backend(model) === displaced
418+
@test termination_status(model) == MOI.OPTIMAL
419+
@test objective_value(model) 7.0 atol = 1e-4
420+
end
421+
422+
function test_loa_no_optimizer_to_restore()
423+
# With no optimizer to put back, the result cache must be dropped
424+
# rather than left to serve its stored results as a fresh solve.
425+
model = GDPModel()
426+
@variable(model, 0 <= x <= 10)
427+
@variable(model, Y[1:2], Logical)
428+
@constraint(model, x <= 3, Disjunct(Y[1]))
429+
@constraint(model, x <= 7, Disjunct(Y[2]))
430+
@disjunction(model, Y)
431+
@objective(model, Max, x)
432+
433+
optimize!(model, gdp_method = LOA(HiGHS.Optimizer))
434+
@test DP.gdp_data(model).displaced_optimizer === nothing
435+
@test objective_value(model) 7.0 atol = 1e-4
436+
437+
@test_throws JuMP.NoOptimizer optimize!(model, gdp_method = BigM())
438+
set_optimizer(model, HiGHS.Optimizer)
439+
set_silent(model)
440+
optimize!(model, gdp_method = BigM())
441+
@test objective_value(model) 7.0 atol = 1e-4
442+
end
443+
444+
function test_loa_cache_refuses_direct_solve()
445+
# `ignore_optimize_hook = true` would "solve" the result cache and
446+
# clear JuMP's dirty flag, reporting a stale point as freshly
447+
# solved. It must error instead.
448+
model = GDPModel(HiGHS.Optimizer)
449+
set_silent(model)
450+
@variable(model, 0 <= x <= 10)
451+
@variable(model, Y[1:2], Logical)
452+
@constraint(model, x <= 3, Disjunct(Y[1]))
453+
@constraint(model, x <= 7, Disjunct(Y[2]))
454+
@disjunction(model, Y)
455+
@objective(model, Max, x)
456+
optimize!(model, gdp_method = LOA(HiGHS.Optimizer))
457+
@test_throws ErrorException optimize!(model;
458+
ignore_optimize_hook = true)
459+
460+
# Still true once the model has been edited underneath the cache.
461+
@constraint(model, x <= 1)
462+
@test !has_values(model)
463+
@test_throws ErrorException optimize!(model;
464+
ignore_optimize_hook = true)
465+
end
466+
372467
function test_loa_limit_hit_report()
373468
# Stop the main loop on `max_iter = 1` after the master has produced
374469
# a bound: the report must label the run "limit hit" (not converged),
@@ -901,6 +996,10 @@ end
901996
test_loa_nonlinear_equality_disjunct()
902997
test_loa_nonlinear_interval_disjunct()
903998
test_loa_restores_prior_time_limit()
999+
test_loa_restores_displaced_optimizer()
1000+
test_loa_repeated_solve_keeps_displaced_optimizer()
1001+
test_loa_no_optimizer_to_restore()
1002+
test_loa_cache_refuses_direct_solve()
9041003
test_loa_limit_hit_report()
9051004
test_loa_complement_indicator_nonlinear_disjunct()
9061005
test_loa_nlpf_infeasible_disjunct()

0 commit comments

Comments
 (0)