@@ -39,14 +39,24 @@ function test_set_covering_combos()
3939
4040 combos = DP. _set_covering_combinations (model)
4141
42- # Should cover both Y[1] and Y[2]
43- all_active = Set ()
44- for combo in combos
45- for (ind, active) in combo
46- active && push! (all_active, ind)
47- end
48- end
49- @test length (all_active) == 2
42+ # K = 2 combinations, each activating exactly one indicator, and
43+ # together covering both.
44+ @test length (combos) == 2
45+ @test all (count (values (combo)) == 1 for combo in combos)
46+ @test combos[1 ][Y[1 ]] && ! combos[1 ][Y[2 ]]
47+ @test combos[2 ][Y[2 ]] && ! combos[2 ][Y[1 ]]
48+ end
49+
50+ function test_oa_cut_terms ()
51+ # The `<= 0` cut directions per set, at a scalar linearization value
52+ # of 5: LessThan/GreaterThan give one signed direction, EqualTo and
53+ # Interval give both, unknown sets fall back to RHS 0.
54+ @test DP. _oa_cut_terms (MOI. LessThan (2.0 ), 5.0 ) == (3.0 ,)
55+ @test DP. _oa_cut_terms (MOI. GreaterThan (2.0 ), 5.0 ) == (- 3.0 ,)
56+ @test DP. _oa_cut_terms (MOI. EqualTo (2.0 ), 5.0 ) == (3.0 , - 3.0 )
57+ @test DP. _oa_cut_terms (MOI. Interval (1.0 , 4.0 ), 5.0 ) == (1.0 , - 4.0 )
58+ @test DP. _set_rhs (MOI. ZeroOne ()) == 0.0
59+ @test DP. _oa_cut_terms (MOI. ZeroOne (), 5.0 ) == (5.0 ,)
5060end
5161
5262function test_no_good_cut ()
@@ -68,16 +78,25 @@ function test_no_good_cut()
6878 num_cons_before = length (JuMP. all_constraints (
6979 master_model;
7080 include_variable_in_set_constraints = false ))
71- DP. avoid_combination (master. model, combo, master. variable_map)
81+ cref = DP. avoid_combination (master. model, combo, master. variable_map)
7282 num_cons_after = length (JuMP. all_constraints (
7383 master_model;
7484 include_variable_in_set_constraints = false ))
7585
7686 @test num_cons_after == num_cons_before + 1
87+ # The cut is (1 - y1) + y2 >= 1, i.e. normalized -y1 + y2 >= 0:
88+ # excludes exactly the (Y1 active, Y2 inactive) combination.
89+ binary_map = DP. _indicator_to_binary (model)
90+ y1 = master. variable_map[binary_map[Y[1 ]]]
91+ y2 = master. variable_map[binary_map[Y[2 ]]]
92+ @test JuMP. normalized_coefficient (cref, y1) == - 1.0
93+ @test JuMP. normalized_coefficient (cref, y2) == 1.0
94+ @test JuMP. normalized_rhs (cref) == 0.0
7795end
7896
7997function test_loa_reformulate_simple ()
8098 model = GDPModel (HiGHS. Optimizer)
99+ set_silent (model)
81100 @variable (model, 0 <= x <= 10 )
82101 @variable (model, Y[1 : 2 ], Logical)
83102 @constraint (model, x <= 3 , Disjunct (Y[1 ]))
@@ -89,6 +108,10 @@ function test_loa_reformulate_simple()
89108 DP. reformulate_model (model, method)
90109
91110 @test DP. _ready_to_optimize (model)
111+ # The committed model solves to the LOA incumbent: x = 7 via Y[2].
112+ JuMP. optimize! (model, ignore_optimize_hook = true )
113+ @test objective_value (model) ≈ 7.0 atol = 1e-6
114+ @test value (x) ≈ 7.0 atol = 1e-6
92115end
93116
94117function test_loa_solve_simple ()
@@ -181,6 +204,121 @@ function test_loa_nonlinear_global()
181204 @test objective_value (model) ≈ 5.0 atol = 1e-3
182205end
183206
207+ function test_loa_nonlinear_equality_global ()
208+ # max x s.t. x^2 == 25 (global nonlinear equality), (x <= 3) ∨
209+ # (x <= 8), 0 <= x <= 10. The Y[1] seed is NLP-infeasible (x <= 3
210+ # contradicts x = 5), so NLPF slacks the inequality while keeping
211+ # the equality exact. The equality emits BOTH cut directions into
212+ # the master. Optimum: x = 5 via Y[2].
213+ ipopt = optimizer_with_attributes (Ipopt. Optimizer,
214+ " print_level" => 0 , " sb" => " yes" )
215+ model = GDPModel (ipopt)
216+ set_silent (model)
217+ @variable (model, 0 <= x <= 10 )
218+ @constraint (model, x^ 2 == 25 )
219+ @variable (model, Y[1 : 2 ], Logical)
220+ @constraint (model, x <= 3 , Disjunct (Y[1 ]))
221+ @constraint (model, x <= 8 , Disjunct (Y[2 ]))
222+ @disjunction (model, Y)
223+ @objective (model, Max, x)
224+ optimize! (model,
225+ gdp_method = LOA (ipopt; mip_optimizer = HiGHS. Optimizer))
226+ @test termination_status (model) in (MOI. OPTIMAL, MOI. LOCALLY_SOLVED)
227+ @test objective_value (model) ≈ 5.0 atol = 1e-3
228+ @test value (x) ≈ 5.0 atol = 1e-3
229+ @test value (Y[2 ]) ≈ 1.0 atol = 1e-6
230+ end
231+
232+ function test_loa_nonlinear_equality_disjunct ()
233+ # Nonlinear equality inside a disjunct: Y1: x <= 3, Y2: x^2 == 64.
234+ # The disjunct cut emits both gated directions. Optimum: x = 8.
235+ ipopt = optimizer_with_attributes (Ipopt. Optimizer,
236+ " print_level" => 0 , " sb" => " yes" )
237+ juniper = optimizer_with_attributes (Juniper. Optimizer,
238+ " nl_solver" => ipopt, " log_levels" => [])
239+ model = GDPModel (juniper)
240+ set_silent (model)
241+ @variable (model, 0 <= x <= 10 )
242+ @variable (model, Y[1 : 2 ], Logical)
243+ @constraint (model, x <= 3 , Disjunct (Y[1 ]))
244+ @constraint (model, x^ 2 == 64 , Disjunct (Y[2 ]))
245+ @disjunction (model, Y)
246+ @objective (model, Max, x)
247+ optimize! (model,
248+ gdp_method = LOA (juniper; mip_optimizer = HiGHS. Optimizer))
249+ @test termination_status (model) in (MOI. OPTIMAL, MOI. LOCALLY_SOLVED)
250+ @test objective_value (model) ≈ 8.0 atol = 1e-3
251+ @test value (Y[2 ]) ≈ 1.0 atol = 1e-6
252+ end
253+
254+ function test_loa_nonlinear_interval_disjunct ()
255+ # Nonlinear Interval constraint inside a disjunct: Y1: x <= 3,
256+ # Y2: 36 <= x^2 <= 64. The disjunct cut emits both gated
257+ # directions. Optimum: x = 8 via Y2.
258+ ipopt = optimizer_with_attributes (Ipopt. Optimizer,
259+ " print_level" => 0 , " sb" => " yes" )
260+ juniper = optimizer_with_attributes (Juniper. Optimizer,
261+ " nl_solver" => ipopt, " log_levels" => [])
262+ model = GDPModel (juniper)
263+ set_silent (model)
264+ @variable (model, 0 <= x <= 10 )
265+ @variable (model, Y[1 : 2 ], Logical)
266+ @constraint (model, x <= 3 , Disjunct (Y[1 ]))
267+ @constraint (model, 36 <= x^ 2 <= 64 , Disjunct (Y[2 ]))
268+ @disjunction (model, Y)
269+ @objective (model, Max, x)
270+ optimize! (model,
271+ gdp_method = LOA (juniper; mip_optimizer = HiGHS. Optimizer))
272+ @test termination_status (model) in (MOI. OPTIMAL, MOI. LOCALLY_SOLVED)
273+ @test objective_value (model) ≈ 8.0 atol = 1e-3
274+ @test value (Y[2 ]) ≈ 1.0 atol = 1e-6
275+ end
276+
277+ function test_loa_restores_prior_time_limit ()
278+ # A time limit set by the user before LOA must survive the loop's
279+ # per-solve caps when only `iteration_time_limit` is finite (the
280+ # `_restore_time_limit(::Real)` path).
281+ model = GDPModel (HiGHS. Optimizer)
282+ set_silent (model)
283+ @variable (model, 0 <= x <= 10 )
284+ @variable (model, Y[1 : 2 ], Logical)
285+ @constraint (model, x <= 3 , Disjunct (Y[1 ]))
286+ @constraint (model, x <= 7 , Disjunct (Y[2 ]))
287+ @disjunction (model, Y)
288+ @objective (model, Max, x)
289+ set_time_limit_sec (model, 90.0 )
290+ optimize! (model, gdp_method = LOA (HiGHS. Optimizer;
291+ iteration_time_limit = 60.0 , time_limit = Inf ))
292+ @test time_limit_sec (model) == 90.0
293+ @test termination_status (model) == MOI. OPTIMAL
294+ @test objective_value (model) ≈ 7.0 atol = 1e-4
295+ end
296+
297+ function test_loa_limit_hit_report ()
298+ # Stop the main loop on `max_iter = 1` after the master has produced
299+ # a bound: the report must label the run "limit hit" (not converged),
300+ # and the single loop iteration still finds the off-diagonal optimum.
301+ model = GDPModel (HiGHS. Optimizer)
302+ set_silent (model)
303+ @variable (model, 0 <= x <= 10 )
304+ @variable (model, 0 <= z <= 10 )
305+ @variable (model, Y[1 : 2 ], Logical)
306+ @variable (model, W[1 : 2 ], Logical)
307+ @constraint (model, x <= 4 , Disjunct (Y[1 ]))
308+ @constraint (model, x >= 6 , Disjunct (Y[2 ]))
309+ @disjunction (model, Y)
310+ @constraint (model, z <= 4 , Disjunct (W[1 ]))
311+ @constraint (model, z >= 6 , Disjunct (W[2 ]))
312+ @disjunction (model, W)
313+ @objective (model, Min, x - z)
314+ method = LOA (HiGHS. Optimizer; max_iter = 1 )
315+ @test_logs (:info , r" limit hit" ) match_mode = :any begin
316+ DP. reformulate_model (model, method)
317+ end
318+ JuMP. optimize! (model, ignore_optimize_hook = true )
319+ @test objective_value (model) ≈ - 10.0 atol = 1e-4
320+ end
321+
184322function test_loa_complement_indicator_nonlinear_disjunct ()
185323 # Regression: complement-form indicators carry `1 - y_base` (an
186324 # AffExpr) as their binary reference. When the complement disjunct has a
@@ -510,13 +648,19 @@ end
510648@testset " LOA" begin
511649 test_loa_datatype ()
512650 test_set_covering_combos ()
651+ test_oa_cut_terms ()
513652 test_no_good_cut ()
514653 test_loa_reformulate_simple ()
515654 test_loa_solve_simple ()
516655 test_loa_solve_simple_with_mbm ()
517656 test_loa_solve_two_disjunctions ()
518657 test_loa_error_fallback ()
519658 test_loa_nonlinear_global ()
659+ test_loa_nonlinear_equality_global ()
660+ test_loa_nonlinear_equality_disjunct ()
661+ test_loa_nonlinear_interval_disjunct ()
662+ test_loa_restores_prior_time_limit ()
663+ test_loa_limit_hit_report ()
520664 test_loa_complement_indicator_nonlinear_disjunct ()
521665 test_loa_nlpf_infeasible_disjunct ()
522666 test_loa_sense_primitives ()
0 commit comments