From f3955690ad4e3302627c7aaf47a3a566ed42e6e5 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 22 Mar 2026 20:39:03 -0400 Subject: [PATCH 01/72] inital split --- src/variables.jl | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/variables.jl b/src/variables.jl index f96cfe02..e1d2d2b3 100644 --- a/src/variables.jl +++ b/src/variables.jl @@ -424,8 +424,7 @@ function _interrogate_variables(interrogator::Function, nlp::JuMP.GenericNonline for arg in nlp.args _interrogate_variables(interrogator, arg) end - # TODO avoid recursion. See InfiniteOpt.jl for alternate method that avoids stackoverflow errors with deeply nested expressions: - # https://github.com/infiniteopt/InfiniteOpt.jl/blob/cb6dd6ae40fe0144b1dd75da0739ea6e305d5357/src/expressions.jl#L520-L534 + return end From ed2eb87d71d741d643cf594cff140fc5d0b29117 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 23 Mar 2026 09:09:23 -0400 Subject: [PATCH 02/72] . --- src/variables.jl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/variables.jl b/src/variables.jl index e1d2d2b3..f96cfe02 100644 --- a/src/variables.jl +++ b/src/variables.jl @@ -424,7 +424,8 @@ function _interrogate_variables(interrogator::Function, nlp::JuMP.GenericNonline for arg in nlp.args _interrogate_variables(interrogator, arg) end - + # TODO avoid recursion. See InfiniteOpt.jl for alternate method that avoids stackoverflow errors with deeply nested expressions: + # https://github.com/infiniteopt/InfiniteOpt.jl/blob/cb6dd6ae40fe0144b1dd75da0739ea6e305d5357/src/expressions.jl#L520-L534 return end From 9e7211e755da8aa58b554c0cc4e7313c093af4a8 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 23 Mar 2026 09:28:15 -0400 Subject: [PATCH 03/72] . --- src/variables.jl | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/variables.jl b/src/variables.jl index f96cfe02..e1d2d2b3 100644 --- a/src/variables.jl +++ b/src/variables.jl @@ -424,8 +424,7 @@ function _interrogate_variables(interrogator::Function, nlp::JuMP.GenericNonline for arg in nlp.args _interrogate_variables(interrogator, arg) end - # TODO avoid recursion. See InfiniteOpt.jl for alternate method that avoids stackoverflow errors with deeply nested expressions: - # https://github.com/infiniteopt/InfiniteOpt.jl/blob/cb6dd6ae40fe0144b1dd75da0739ea6e305d5357/src/expressions.jl#L520-L534 + return end From 616b7faef8dd7745f4c51d325ba46c6d3c049c26 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 23 Mar 2026 09:34:01 -0400 Subject: [PATCH 04/72] . --- src/variables.jl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/variables.jl b/src/variables.jl index e1d2d2b3..f96cfe02 100644 --- a/src/variables.jl +++ b/src/variables.jl @@ -424,7 +424,8 @@ function _interrogate_variables(interrogator::Function, nlp::JuMP.GenericNonline for arg in nlp.args _interrogate_variables(interrogator, arg) end - + # TODO avoid recursion. See InfiniteOpt.jl for alternate method that avoids stackoverflow errors with deeply nested expressions: + # https://github.com/infiniteopt/InfiniteOpt.jl/blob/cb6dd6ae40fe0144b1dd75da0739ea6e305d5357/src/expressions.jl#L520-L534 return end From 6f04419352987f195a6b31ccf7e5b1cd80f670f2 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 22 Mar 2026 20:39:03 -0400 Subject: [PATCH 05/72] inital split --- src/utilities.jl | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/utilities.jl b/src/utilities.jl index b0203d70..69d3f4ca 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -129,6 +129,19 @@ function get_constant(expr::JuMP.AbstractVariableRef) return zero(JuMP.value_type(typeof(JuMP.owner_model(expr)))) end +################################################################################ +# ZERO EXPRESSION CONSTRUCTORS +################################################################################ +# Create a type-correct zero affine expression for the model. +_zero_aff(model::JuMP.AbstractModel) = zero( + JuMP.GenericAffExpr{JuMP.value_type(typeof(model)), + JuMP.variable_ref_type(model)}) + +# Create a type-correct zero quadratic expression for the model. +_zero_quad(model::JuMP.AbstractModel) = zero( + JuMP.GenericQuadExpr{JuMP.value_type(typeof(model)), + JuMP.variable_ref_type(model)}) + ################################################################################ # MODEL COPYING ################################################################################ From 2a7b894a4c1c4cb2e9416e717d8261a1a2a62c21 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 23 Mar 2026 00:01:36 -0400 Subject: [PATCH 06/72] inital split --- Project.toml | 15 +++++++++------ src/extension_api.jl | 10 +++++----- test/solve.jl | 12 ++++++++++-- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/Project.toml b/Project.toml index a5320aa7..3346659a 100644 --- a/Project.toml +++ b/Project.toml @@ -9,25 +9,28 @@ Reexport = "189a3867-3050-52da-a836-e630ba90ab69" [weakdeps] InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" +Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" [extensions] -InfiniteDisjunctiveProgramming = "InfiniteOpt" +InfiniteDisjunctiveProgramming = ["InfiniteOpt", "Interpolations"] [compat] Aqua = "0.8" +InfiniteOpt = "0.6" +Interpolations = "0.16.2" +Ipopt = "1.9.0" JuMP = "1.18" +Juniper = "0.9.3" Reexport = "1" julia = "1.10" -Juniper = "0.9.3" -Ipopt = "1.9.0" -InfiniteOpt = "0.6" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" Juniper = "2ddba703-00a4-53a7-87a5-e8b9971dde84" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Aqua", "HiGHS", "Test", "Juniper", "Ipopt", "InfiniteOpt"] +test = ["Aqua", "HiGHS", "Test", "Juniper", "Ipopt", "Interpolations"] diff --git a/src/extension_api.jl b/src/extension_api.jl index ad3566f6..6d046450 100644 --- a/src/extension_api.jl +++ b/src/extension_api.jl @@ -1,8 +1,8 @@ """ InfiniteGDPModel(args...; kwargs...) -Creates an `InfiniteOpt.InfiniteModel` that is compatible with the -capabiltiies provided by DisjunctiveProgramming.jl. This requires +Creates an `InfiniteOpt.InfiniteModel` that is compatible with the +capabiltiies provided by DisjunctiveProgramming.jl. This requires that InfiniteOpt be imported first. **Example** @@ -18,9 +18,9 @@ function InfiniteGDPModel end """ InfiniteLogical(prefs...) -Allows users to create infinite logical variables. This is a tag -for the `@variable` macro that is a combination of `InfiniteOpt.Infinite` -and `DisjunctiveProgramming.Logical`. This requires that InfiniteOpt be +Allows users to create infinite logical variables. This is a tag +for the `@variable` macro that is a combination of `InfiniteOpt.Infinite` +and `DisjunctiveProgramming.Logical`. This requires that InfiniteOpt be first imported. **Example** diff --git a/test/solve.jl b/test/solve.jl index c3ca9441..3f9717b3 100644 --- a/test/solve.jl +++ b/test/solve.jl @@ -7,7 +7,7 @@ function test_linear_gdp_example(m, use_complements = false) @variable(m, Y2, Logical, logical_complement = Y1) Y = [Y1, Y2] else - @variable(m, Y[1:2], Logical) + @variable(m, Y[1:3], Logical) end @variable(m, W[1:2], Logical) @objective(m, Max, sum(x)) @@ -16,7 +16,13 @@ function test_linear_gdp_example(m, use_complements = false) @constraint(m, w2[i=1:2], [2,4][i] ≤ x[i] ≤ [3,5][i], Disjunct(W[2])) @constraint(m, y2[i=1:2], [8,1][i] ≤ x[i] ≤ [9,2][i], Disjunct(Y[2])) @disjunction(m, inner, [W[1], W[2]], Disjunct(Y[1])) - @disjunction(m, outer, [Y[1], Y[2]]) + if use_complements + @disjunction(m, outer, [Y[1], Y[2]]) + else + #Infeasible disjunct + @constraint(m, y3[i=1:2], x[i] ≤ [-44,44][i], Disjunct(Y[3])) + @disjunction(m, outer, [Y[1], Y[2], Y[3]]) + end @test optimize!(m, gdp_method = BigM()) isa Nothing @test termination_status(m) == MOI.OPTIMAL @@ -98,6 +104,8 @@ function test_quadratic_gdp_example(use_complements = false) #psplit does not wo @objective(m, Max, sum(x)) @constraint(m, y1_quad, x[1]^2 + x[2]^2 ≤ 16, Disjunct(Y[1])) + # This constraint is always satisfied + @constraint(m, y1_global, x[1] + x[2] ≤ 20, Disjunct(Y[1])) @constraint(m, w1[i=1:2], [1, 2][i] ≤ x[i] ≤ [3, 4][i], Disjunct(W[1])) @constraint(m, w1_quad, x[1]^2 ≥ 2, Disjunct(W[1])) From eab521cf6c60c1c9805bee069bab8785db54576b Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 20 Apr 2026 17:33:50 -0400 Subject: [PATCH 07/72] . --- test/solve.jl | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/test/solve.jl b/test/solve.jl index 3f9717b3..c3ca9441 100644 --- a/test/solve.jl +++ b/test/solve.jl @@ -7,7 +7,7 @@ function test_linear_gdp_example(m, use_complements = false) @variable(m, Y2, Logical, logical_complement = Y1) Y = [Y1, Y2] else - @variable(m, Y[1:3], Logical) + @variable(m, Y[1:2], Logical) end @variable(m, W[1:2], Logical) @objective(m, Max, sum(x)) @@ -16,13 +16,7 @@ function test_linear_gdp_example(m, use_complements = false) @constraint(m, w2[i=1:2], [2,4][i] ≤ x[i] ≤ [3,5][i], Disjunct(W[2])) @constraint(m, y2[i=1:2], [8,1][i] ≤ x[i] ≤ [9,2][i], Disjunct(Y[2])) @disjunction(m, inner, [W[1], W[2]], Disjunct(Y[1])) - if use_complements - @disjunction(m, outer, [Y[1], Y[2]]) - else - #Infeasible disjunct - @constraint(m, y3[i=1:2], x[i] ≤ [-44,44][i], Disjunct(Y[3])) - @disjunction(m, outer, [Y[1], Y[2], Y[3]]) - end + @disjunction(m, outer, [Y[1], Y[2]]) @test optimize!(m, gdp_method = BigM()) isa Nothing @test termination_status(m) == MOI.OPTIMAL @@ -104,8 +98,6 @@ function test_quadratic_gdp_example(use_complements = false) #psplit does not wo @objective(m, Max, sum(x)) @constraint(m, y1_quad, x[1]^2 + x[2]^2 ≤ 16, Disjunct(Y[1])) - # This constraint is always satisfied - @constraint(m, y1_global, x[1] + x[2] ≤ 20, Disjunct(Y[1])) @constraint(m, w1[i=1:2], [1, 2][i] ≤ x[i] ≤ [3, 4][i], Disjunct(W[1])) @constraint(m, w1_quad, x[1]^2 ≥ 2, Disjunct(W[1])) From a7ad3fd7283e90f33bcda87b6edc13103df986dc Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 20 Apr 2026 18:33:40 -0400 Subject: [PATCH 08/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 501 +++++++++++++++++++++++--- 1 file changed, 457 insertions(+), 44 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 426f7ac6..831820f7 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -1,24 +1,21 @@ module InfiniteDisjunctiveProgramming import JuMP.MOI as _MOI -import InfiniteOpt, JuMP +import InfiniteOpt, JuMP, Interpolations import DisjunctiveProgramming as DP ################################################################################ # MODEL ################################################################################ function DP.InfiniteGDPModel(args...; kwargs...) - return DP.GDPModel{ - InfiniteOpt.InfiniteModel, - InfiniteOpt.GeneralVariableRef, - InfiniteOpt.InfOptConstraintRef - }(args...; kwargs...) + return DP.GDPModel{InfiniteOpt.InfiniteModel, + InfiniteOpt.GeneralVariableRef, + InfiniteOpt.InfOptConstraintRef}(args...; kwargs...) end function DP.collect_all_vars(model::InfiniteOpt.InfiniteModel) vars = JuMP.all_variables(model) - derivs = InfiniteOpt.all_derivatives(model) - return append!(vars, derivs) + return append!(vars, InfiniteOpt.all_derivatives(model)) end ################################################################################ @@ -26,7 +23,7 @@ end ################################################################################ DP.InfiniteLogical(prefs...) = DP.Logical(InfiniteOpt.Infinite(prefs...)) -_is_parameter(vref::InfiniteOpt.GeneralVariableRef) = +_is_parameter(vref::InfiniteOpt.GeneralVariableRef) = _is_parameter(InfiniteOpt.dispatch_variable_ref(vref)) _is_parameter(::InfiniteOpt.DependentParameterRef) = true _is_parameter(::InfiniteOpt.IndependentParameterRef) = true @@ -41,20 +38,19 @@ end function DP.VariableProperties(vref::InfiniteOpt.GeneralVariableRef) info = DP.get_variable_info(vref) name = JuMP.name(vref) - set = nothing prefs = InfiniteOpt.parameter_refs(vref) var_type = !isempty(prefs) ? InfiniteOpt.Infinite(prefs...) : nothing - return DP.VariableProperties(info, name, set, var_type) + return DP.VariableProperties(info, name, nothing, var_type) end -# Extract parameter refs from expression and return VariableProperties with Infinite type +# Extract parameter refs from expression, return VariableProperties with +# Infinite type. function DP.VariableProperties( expr::Union{ JuMP.GenericAffExpr{C, InfiniteOpt.GeneralVariableRef}, JuMP.GenericQuadExpr{C, InfiniteOpt.GeneralVariableRef}, - JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef} - } -) where C + JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef}} + ) where C prefs = InfiniteOpt.parameter_refs(expr) info = DP._free_variable_info() var_type = !isempty(prefs) ? InfiniteOpt.Infinite(prefs...) : nothing @@ -66,9 +62,8 @@ function DP.VariableProperties( InfiniteOpt.GeneralVariableRef, JuMP.GenericAffExpr{<:Any, InfiniteOpt.GeneralVariableRef}, JuMP.GenericQuadExpr{<:Any, InfiniteOpt.GeneralVariableRef}, - JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef} - }} -) + JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef}}} + ) all_prefs = Set{InfiniteOpt.GeneralVariableRef}() for expr in exprs for pref in InfiniteOpt.parameter_refs(expr) @@ -90,9 +85,8 @@ end ################################################################################ function JuMP.add_constraint( model::InfiniteOpt.InfiniteModel, - c::JuMP.VectorConstraint{F, S}, - name::String = "" -) where {F, S <: DP.AbstractCardinalitySet} + c::JuMP.VectorConstraint{F, S}, name::String = "" + ) where {F, S <: DP.AbstractCardinalitySet} return DP._add_cardinality_constraint(model, c, name) end @@ -100,7 +94,7 @@ function JuMP.add_constraint( model::M, c::JuMP.ScalarConstraint{DP._LogicalExpr{M}, S}, name::String = "" -) where {S, M <: InfiniteOpt.InfiniteModel} + ) where {S, M <: InfiniteOpt.InfiniteModel} return DP._add_logical_constraint(model, c, name) end @@ -108,28 +102,29 @@ function JuMP.add_constraint( model::M, c::JuMP.ScalarConstraint{DP.LogicalVariableRef{M}, S}, name::String = "" -) where {M <: InfiniteOpt.InfiniteModel, S} - error("Cannot define constraint on single logical variable, use `fix` instead.") + ) where {M <: InfiniteOpt.InfiniteModel, S} + error("Cannot define constraint on single logical variable, " * + "use `fix` instead.") end function JuMP.add_constraint( model::M, c::JuMP.ScalarConstraint{ - JuMP.GenericAffExpr{C, DP.LogicalVariableRef{M}}, S - }, + JuMP.GenericAffExpr{C, DP.LogicalVariableRef{M}}, S}, name::String = "" -) where {M <: InfiniteOpt.InfiniteModel, S, C} - error("Cannot add, subtract, or multiply with logical variables.") + ) where {M <: InfiniteOpt.InfiniteModel, S, C} + error("Cannot add, subtract, or multiply with " * + "logical variables.") end function JuMP.add_constraint( model::M, c::JuMP.ScalarConstraint{ - JuMP.GenericQuadExpr{C, DP.LogicalVariableRef{M}}, S - }, + JuMP.GenericQuadExpr{C, DP.LogicalVariableRef{M}}, S}, name::String = "" -) where {M <: InfiniteOpt.InfiniteModel, S, C} - error("Cannot add, subtract, or multiply with logical variables.") + ) where {M <: InfiniteOpt.InfiniteModel, S, C} + error("Cannot add, subtract, or multiply with " * + "logical variables.") end ################################################################################ @@ -137,7 +132,7 @@ end ################################################################################ function DP.get_constant( expr::JuMP.GenericAffExpr{T, InfiniteOpt.GeneralVariableRef} -) where {T} + ) where {T} constant = JuMP.constant(expr) param_expr = zero(typeof(expr)) for (var, coeff) in expr.terms @@ -149,16 +144,16 @@ function DP.get_constant( end function DP.disaggregate_expression( - model::M, - aff::JuMP.GenericAffExpr, + model::M, aff::JuMP.GenericAffExpr, bvref::Union{JuMP.AbstractVariableRef, JuMP.GenericAffExpr}, method::DP._Hull -) where {M <: InfiniteOpt.InfiniteModel} + ) where {M <: InfiniteOpt.InfiniteModel} terms = Any[aff.constant * bvref] for (vref, coeff) in aff.terms if JuMP.is_binary(vref) push!(terms, coeff * vref) - elseif vref isa InfiniteOpt.GeneralVariableRef && _is_parameter(vref) + elseif vref isa InfiniteOpt.GeneralVariableRef && + _is_parameter(vref) push!(terms, coeff * vref * bvref) elseif !haskey(method.disjunct_variables, (vref, bvref)) push!(terms, coeff * vref) @@ -170,18 +165,436 @@ function DP.disaggregate_expression( return JuMP.@expression(model, sum(terms)) end +# Quadratic expression: handle parameter x parameter, parameter x variable, +# and variable x variable terms. +function DP.disaggregate_expression( + model::M, quad::JuMP.GenericQuadExpr, + bvref::Union{JuMP.AbstractVariableRef, JuMP.GenericAffExpr}, + method::DP._Hull + ) where {M <: InfiniteOpt.InfiniteModel} + # Affine part (uses InfiniteOpt override above) + new_expr = DP.disaggregate_expression(model, quad.aff, bvref, method) + ϵ = method.value + for (pair, coeff) in quad.terms + a_param = pair.a isa InfiniteOpt.GeneralVariableRef && + _is_parameter(pair.a) + b_param = pair.b isa InfiniteOpt.GeneralVariableRef && + _is_parameter(pair.b) + if a_param && b_param + # param × param: constant, scale by y + new_expr += coeff * pair.a * pair.b * bvref + elseif a_param + # param × var: perspective cancels y + db = method.disjunct_variables[pair.b, bvref] + new_expr += coeff * pair.a * db + elseif b_param + # var × param: perspective cancels y + da = method.disjunct_variables[pair.a, bvref] + new_expr += coeff * da * pair.b + else + # var × var: standard perspective + da = method.disjunct_variables[pair.a, bvref] + db = method.disjunct_variables[pair.b, bvref] + new_expr += coeff * da * db / ((1 - ϵ) * bvref + ϵ) + end + end + return new_expr +end + ################################################################################ -# ERROR MESSAGES +# MBM FOR INFINITEMODEL ################################################################################ -function DP.reformulate_model(::InfiniteOpt.InfiniteModel, ::DP.MBM) - error("The `MBM` method is not supported for `InfiniteModel`." * - "Please use `BigM`, `Hull`, `Indicator`, or `PSplit` instead.") +# Reuses the finite MBM infrastructure by overriding: +# copy_model_with_constraints (build mini InfiniteModel + +# transcribe to flat JuMP model), prepare_max_M_objective +# (expand infinite constraint into K flat objectives via +# _build_flat_map), and aggregate_M_values (interpolate flat +# values to parameter function). + +# Collect all parameter function refs from all disjunct constraints in +# the model. +function _all_param_functions( + model::InfiniteOpt.InfiniteModel + ) + pf_set = Set{InfiniteOpt.GeneralVariableRef}() + for (_, crefs) in DP._indicator_to_constraints(model) + for cref in crefs + cref isa DP.DisjunctConstraintRef || continue + con = JuMP.constraint_object(cref) + for v in InfiniteOpt.all_expression_variables( + con.func) + dv = InfiniteOpt.dispatch_variable_ref(v) + if dv isa InfiniteOpt.ParameterFunctionRef + push!(pf_set, v) + end + end + end + end + return pf_set +end + +# Build a flat map for support point k. Maps decision variables to their +# flat JuMP.VariableRef at support k (handling multi-parameter indexing) +# and evaluates parameter functions to their numerical values. pf_set is +# precomputed by the caller to avoid rescanning all disjunct constraints +# on every support point. +function _build_flat_map( + sub::DP.GDPSubmodel, k::Int, + prefs::Vector{InfiniteOpt.GeneralVariableRef}, + supports::Dict{InfiniteOpt.GeneralVariableRef,Vector{Float64}}, + full_shape::Tuple, + pf_set::Set{InfiniteOpt.GeneralVariableRef} + ) + ci = CartesianIndices(full_shape)[k] + + # Decision variables: map each to its variable-local index + flat_map = Dict{InfiniteOpt.GeneralVariableRef, Any}() + for (v, ws) in sub.fwd_map + if length(ws) == 1 + flat_map[v] = ws[1] + else + vp = InfiniteOpt.parameter_refs(v) + shape = Tuple(length(supports[p]) for p in vp) + idx = Tuple(ci[findfirst(==(p), prefs)] for p in vp) + flat_map[v] = ws[LinearIndices(shape)[idx...]] + end + end + + # Parameter functions: evaluate at support point k + sup_vals = Dict( + prefs[i] => supports[prefs[i]][ci[i]] + for i in 1:length(prefs)) + for pf in pf_set + fn = InfiniteOpt.raw_function(pf) + pf_prefs = InfiniteOpt.parameter_refs(pf) + pf_vals = Tuple(sup_vals[p] for p in pf_prefs) + flat_map[pf] = fn(pf_vals...) + end + return flat_map end -function DP.reformulate_model(::InfiniteOpt.InfiniteModel, ::DP.CuttingPlanes) - error("The `CuttingPlanes` method is not supported for `InfiniteModel`." * - "Please use `BigM`, `Hull`, `Indicator`, or `PSplit` instead.") +# Build mini InfiniteModel with only the given disjunct constraints, +# transcribe to flat JuMP model, return GDPSubmodel with forward map. +function DP.copy_model_with_constraints( + model::InfiniteOpt.InfiniteModel, + constraints::Vector{<:DP.DisjunctConstraintRef}, + method::DP._MBM + ) + mini = InfiniteOpt.InfiniteModel() + ref_map = Dict{InfiniteOpt.GeneralVariableRef,InfiniteOpt.GeneralVariableRef}() + + # 1. Copy infinite parameters with their supports + for p in InfiniteOpt.all_parameters(model) + domain = InfiniteOpt.infinite_domain(p) + sups = Float64.(InfiniteOpt.supports(p)) + param = InfiniteOpt.build_parameter(error, domain; supports = sups) + new_p = InfiniteOpt.add_parameter(mini, param, JuMP.name(p)) + ref_map[p] = new_p + end + + # 2. Copy decision variables with bounds (skip parameters) + for v in JuMP.all_variables(model) + _is_parameter(v) && continue + prefs = InfiniteOpt.parameter_refs(v) + var_type = isempty(prefs) ? nothing : + InfiniteOpt.Infinite(Tuple(ref_map[p] for p in prefs)...) + props = DP.VariableProperties(DP.get_variable_info(v),"", nothing, var_type) + ref_map[v] = DP.create_variable(mini, props) + end + + # 3. Copy derivatives with their bounds + for d in InfiniteOpt.all_derivatives(model) + vref = InfiniteOpt.derivative_argument(d) + pref = InfiniteOpt.operator_parameter(d) + new_d = InfiniteOpt.deriv(ref_map[vref], ref_map[pref]) + info = DP.get_variable_info(d) + info.has_lb && JuMP.set_lower_bound(new_d, info.lower_bound) + info.has_ub && JuMP.set_upper_bound(new_d, info.upper_bound) + ref_map[d] = new_d + end + + # 4. Copy parameter functions from ALL disjuncts (needed for + # constraint transcription) + pf_set = _all_param_functions(model) + for pf in pf_set + fn = InfiniteOpt.raw_function(pf) + prefs = InfiniteOpt.parameter_refs(pf) + mapped_prefs = Tuple(ref_map[p] for p in prefs) + new_pf = _make_parameter_function(mini, fn, mapped_prefs...) + ref_map[pf] = new_pf + end + + # 5. Add disjunct constraints using existing ref_map + for cref in constraints + cref isa DP.DisjunctConstraintRef || continue + con = JuMP.constraint_object(cref) + new_func = DP._replace_variables_in_constraint(con.func, ref_map) + T = one(JuMP.value_type(typeof(mini))) + JuMP.@constraint(mini, new_func * T in con.set) + end + + # 6. Transcribe mini InfiniteModel to flat JuMP model + flat, tr_fwd = transcribe_to_flat(mini) + + # 7. Remap fwd_map: original model var -> flat JuMP VarRef + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() + for (orig, mapped) in ref_map + _is_parameter(orig) && continue + haskey(tr_fwd, mapped) || continue + fwd_map[orig] = tr_fwd[mapped] + end + + decision_vars = collect(keys(fwd_map)) + JuMP.set_optimizer(flat, method.optimizer) + JuMP.set_silent(flat) + return DP.GDPSubmodel(flat, decision_vars, fwd_map) end +# Prepare objectives for all support points. Expands an infinite +# constraint into K flat objectives via _build_flat_map with +# multi-parameter indexing and parameter function evaluation. +function DP.prepare_max_M_objective( + model::InfiniteOpt.InfiniteModel, + obj::JuMP.ScalarConstraint{T, S}, + sub::DP.GDPSubmodel + ) where {T, S <: _MOI.LessThan} + prefs, supports = _collect_parameters(model) + full_shape = Tuple(length(supports[p]) for p in prefs) + K = prod(full_shape) + pf_set = _all_param_functions(model) + objectives = Vector{JuMP.AbstractJuMPScalar}(undef, K) + for k in 1:K + flat_map = _build_flat_map(sub, k, prefs, supports, full_shape, pf_set) + objectives[k] = -obj.set.upper + + DP._replace_variables_in_constraint(obj.func, flat_map) + end + return objectives +end +function DP.prepare_max_M_objective( + model::InfiniteOpt.InfiniteModel, + obj::JuMP.ScalarConstraint{T, S}, + sub::DP.GDPSubmodel + ) where {T, S <: _MOI.GreaterThan} + prefs, supports = _collect_parameters(model) + full_shape = Tuple(length(supports[p]) for p in prefs) + K = prod(full_shape) + pf_set = _all_param_functions(model) + objectives = Vector{JuMP.AbstractJuMPScalar}(undef, K) + for k in 1:K + flat_map = _build_flat_map(sub, k, prefs, supports,full_shape, pf_set) + objectives[k] = obj.set.lower - + DP._replace_variables_in_constraint(obj.func, flat_map) + end + return objectives end +# Solve the submodel for a vector of objectives (one per +# support point). Returns aggregated result or nothing. +function DP._raw_M( + sub::DP.GDPSubmodel, + objectives::Vector{<:JuMP.AbstractJuMPScalar}, + method::DP._MBM + ) + M_vals = typeof(method.default_M)[] + for obj_expr in objectives + JuMP.set_start_value.(JuMP.all_variables(sub.model), nothing) + JuMP.@objective(sub.model, Max, obj_expr) + JuMP.optimize!(sub.model) + if JuMP.is_solved_and_feasible(sub.model) + push!(M_vals, max( + JuMP.objective_value(sub.model), + zero(method.default_M))) + elseif JuMP.termination_status(sub.model) == + JuMP.MOI.INFEASIBLE + return nothing + else + push!(M_vals, method.default_M) + end + end + model = JuMP.owner_model( + first(keys(sub.fwd_map))) + return aggregate_M_values(model, M_vals) +end + +# Condense flat per-support values to final form (MBM path). +function aggregate_M_values( + model::InfiniteOpt.InfiniteModel, + vals::AbstractVector{<:Real} + ) + if all(==(vals[1]), vals) + return vals[1] + end + prefs, supports = _collect_parameters(model) + return condense_to_pf(model, vals, prefs, supports) +end + +# Interpolate flat per-support values into a parameter function. Computes +# grids/shape from supports, reshapes, interpolates, and registers on +# the model. +function condense_to_pf( + model::InfiniteOpt.InfiniteModel, + vals::AbstractVector{<:Real}, + prefs::Union{Vector{InfiniteOpt.GeneralVariableRef}, + Tuple{Vararg{InfiniteOpt.GeneralVariableRef}}}, + supports::Dict{InfiniteOpt.GeneralVariableRef, + Vector{Float64}} + ) + grids = Tuple(supports[p] for p in prefs) + shape = Tuple(length(supports[p]) for p in prefs) + nd = reshape(vals, shape) + fn = Interpolations.linear_interpolation(grids, nd, + extrapolation_bc = Interpolations.Line()) + return _make_parameter_function(model, fn, prefs...) +end + +################################################################################ +# TRANSCRIPTION HELPERS +################################################################################ + +# Create a parameter function programmatically. Uses +# build_parameter_function + add_parameter_function (the lower-level +# API behind @parameter_function) since the macro doesn't support +# programmatic use. The closure wrapper handles non-Function callables +# like Interpolations.Extrapolation. Accepts any number of prefs via +# varargs: _make_parameter_function(m, f, t) for 1D, (m, f, t, x) for 2D. +function _make_parameter_function( + model::InfiniteOpt.InfiniteModel, fn, + prefs::InfiniteOpt.GeneralVariableRef... + ) + f = fn isa Function ? fn : ((args...) -> fn(args...)) + pref_arg = length(prefs) == 1 ? prefs[1] : prefs + pfunc = InfiniteOpt.build_parameter_function(error, f, pref_arg) + return InfiniteOpt.add_parameter_function(model, pfunc) +end + +# Collect all infinite parameters and their supports from the model. +function _collect_parameters(model::InfiniteOpt.InfiniteModel) + params = collect(InfiniteOpt.all_parameters(model)) + if isempty(params) + error("Model has no infinite parameters.") + end + prefs = InfiniteOpt.GeneralVariableRef[p for p in params] + supports = Dict{InfiniteOpt.GeneralVariableRef, Vector{Float64}}( + p => Float64.(InfiniteOpt.supports(p)) for p in prefs) + return prefs, supports +end + +# Transcribe an InfiniteModel to a flat JuMP.Model with forward variable +# map. Shared by MBM and CP paths. +function transcribe_to_flat(model::InfiniteOpt.InfiniteModel) + InfiniteOpt.build_transformation_backend!(model) + flat = InfiniteOpt.transformation_model(model) + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() + for v in DP.collect_all_vars(model) + tv = InfiniteOpt.transformation_variable(v) + vprefs = InfiniteOpt.parameter_refs(v) + fwd_map[v] = isempty(vprefs) ? [tv] : vec(tv) + end + return flat, fwd_map +end + +################################################################################ +# CUTTING PLANES FOR INFINITEMODEL +################################################################################ + +# Build CP subproblem: reformulate the InfiniteModel, transcribe to a flat +# JuMP copy, and wrap in GDPSubmodel with forward variable map. +function DP.copy_and_reformulate( + model::InfiniteOpt.InfiniteModel, + decision_vars::Vector{InfiniteOpt.GeneralVariableRef}, + reform_method::DP.AbstractReformulationMethod, + method::DP.CuttingPlanes + ) + DP.reformulate_model(model, reform_method) + flat, tr_fwd = transcribe_to_flat(model) + sub_copy, copy_map = JuMP.copy_model(flat) + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() + for v in decision_vars + haskey(tr_fwd, v) || continue + fwd_map[v] = [copy_map[tv] for tv in tr_fwd[v]] + end + sub = DP.GDPSubmodel(sub_copy, decision_vars, fwd_map) + JuMP.set_optimizer(sub.model, method.optimizer) + JuMP.set_silent(sub.model) + return sub +end + +# Full CP loop for InfiniteModel: cannot solve in-place, so both SEP +# and rBM are separate transcribed flat copies. +function DP.reformulate_model( + model::InfiniteOpt.InfiniteModel, + method::DP.CuttingPlanes + ) + decision_vars = DP.collect_cutting_planes_vars(model) + separation = DP.copy_and_reformulate( + model, decision_vars, DP.Hull(), method) + JuMP.relax_integrality(separation.model) + rBM = DP.copy_and_reformulate( + model, decision_vars, DP.BigM(method.M_value), method) + JuMP.relax_integrality(rBM.model) + for iter in 1:method.max_iter + JuMP.optimize!(rBM.model, ignore_optimize_hook = true) + rBM_sol = DP.extract_solution(rBM) + sep_obj, sep_sol = DP._solve_separation(separation, rBM_sol) + sep_obj <= method.seperation_tolerance && break + _add_infinite_cut(rBM, model, rBM_sol, sep_sol) + end + DP._set_solution_method(model, method) + DP._set_ready_to_optimize(model, true) + return +end + +# Add cut to both the flat rBM model and the original InfiniteModel. +function _add_infinite_cut( + rBM::DP.GDPSubmodel{<:Any, <:InfiniteOpt.GeneralVariableRef, <:Any}, + model::InfiniteOpt.InfiniteModel, + rBM_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}}, + sep_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}} + ) + cut_expr = zero(JuMP.GenericAffExpr{ + JuMP.value_type(typeof(rBM.model)), + JuMP.variable_ref_type(rBM.model)}) + for var in rBM.decision_vars + sub_vars = rBM.fwd_map[var] + rbm_vals = rBM_sol[var] + sep_vals = sep_sol[var] + for k in 1:length(sub_vars) + xi = 2 * (sep_vals[k] - rbm_vals[k]) + JuMP.add_to_expression!(cut_expr, xi, sub_vars[k]) + JuMP.add_to_expression!(cut_expr, -xi * sep_vals[k]) + end + end + JuMP.@constraint(rBM.model, cut_expr >= 0) + prefs, sups = _collect_parameters(model) + inf_terms = Any[] + cut_scalar = zero(JuMP.GenericAffExpr{ + JuMP.value_type(typeof(model)), + JuMP.variable_ref_type(model)}) + for var in rBM.decision_vars + haskey(rBM_sol, var) || continue + haskey(sep_sol, var) || continue + vprefs = InfiniteOpt.parameter_refs(var) + if isempty(vprefs) + xi = 2 * (sep_sol[var][1] - rBM_sol[var][1]) + sp = sep_sol[var][1] + cut_scalar += xi * (var - sp) + else + xi_vals = 2 .* (sep_sol[var] .- rBM_sol[var]) + sp_vals = sep_sol[var] + xi_pf = condense_to_pf(model, xi_vals, vprefs, sups) + sp_pf = condense_to_pf(model, sp_vals, vprefs, sups) + push!(inf_terms, xi_pf * var - xi_pf * sp_pf) + end + end + if !isempty(inf_terms) + inf_expr = JuMP.@expression(model, sum(inf_terms)) + for p in prefs + inf_expr = InfiniteOpt.integral(inf_expr, p) + end + cut_scalar += inf_expr + end + JuMP.@constraint(model, cut_scalar >= 0) + return +end + +end From 8fa5f3e184525665f05ba468ab6dcfe5fec576ef Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 20 Apr 2026 20:24:12 -0400 Subject: [PATCH 09/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 272 +++++++------- src/mbm.jl | 22 +- test/constraints/mbm.jl | 14 +- .../InfiniteDisjunctiveProgramming.jl | 334 +++++++++++++++++- 4 files changed, 480 insertions(+), 162 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 831820f7..8f5cd1e0 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -8,14 +8,17 @@ import DisjunctiveProgramming as DP # MODEL ################################################################################ function DP.InfiniteGDPModel(args...; kwargs...) - return DP.GDPModel{InfiniteOpt.InfiniteModel, + return DP.GDPModel{ + InfiniteOpt.InfiniteModel, InfiniteOpt.GeneralVariableRef, - InfiniteOpt.InfOptConstraintRef}(args...; kwargs...) + InfiniteOpt.InfOptConstraintRef + }(args...; kwargs...) end function DP.collect_all_vars(model::InfiniteOpt.InfiniteModel) vars = JuMP.all_variables(model) - return append!(vars, InfiniteOpt.all_derivatives(model)) + derivs = InfiniteOpt.all_derivatives(model) + return append!(vars, derivs) end ################################################################################ @@ -38,19 +41,20 @@ end function DP.VariableProperties(vref::InfiniteOpt.GeneralVariableRef) info = DP.get_variable_info(vref) name = JuMP.name(vref) + set = nothing prefs = InfiniteOpt.parameter_refs(vref) var_type = !isempty(prefs) ? InfiniteOpt.Infinite(prefs...) : nothing - return DP.VariableProperties(info, name, nothing, var_type) + return DP.VariableProperties(info, name, set, var_type) end -# Extract parameter refs from expression, return VariableProperties with -# Infinite type. +# Extract parameter refs from expression and return VariableProperties with Infinite type function DP.VariableProperties( expr::Union{ JuMP.GenericAffExpr{C, InfiniteOpt.GeneralVariableRef}, JuMP.GenericQuadExpr{C, InfiniteOpt.GeneralVariableRef}, - JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef}} - ) where C + JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef} + } +) where C prefs = InfiniteOpt.parameter_refs(expr) info = DP._free_variable_info() var_type = !isempty(prefs) ? InfiniteOpt.Infinite(prefs...) : nothing @@ -62,8 +66,9 @@ function DP.VariableProperties( InfiniteOpt.GeneralVariableRef, JuMP.GenericAffExpr{<:Any, InfiniteOpt.GeneralVariableRef}, JuMP.GenericQuadExpr{<:Any, InfiniteOpt.GeneralVariableRef}, - JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef}}} - ) + JuMP.GenericNonlinearExpr{InfiniteOpt.GeneralVariableRef} + }} +) all_prefs = Set{InfiniteOpt.GeneralVariableRef}() for expr in exprs for pref in InfiniteOpt.parameter_refs(expr) @@ -85,8 +90,9 @@ end ################################################################################ function JuMP.add_constraint( model::InfiniteOpt.InfiniteModel, - c::JuMP.VectorConstraint{F, S}, name::String = "" - ) where {F, S <: DP.AbstractCardinalitySet} + c::JuMP.VectorConstraint{F, S}, + name::String = "" +) where {F, S <: DP.AbstractCardinalitySet} return DP._add_cardinality_constraint(model, c, name) end @@ -94,7 +100,7 @@ function JuMP.add_constraint( model::M, c::JuMP.ScalarConstraint{DP._LogicalExpr{M}, S}, name::String = "" - ) where {S, M <: InfiniteOpt.InfiniteModel} +) where {S, M <: InfiniteOpt.InfiniteModel} return DP._add_logical_constraint(model, c, name) end @@ -102,29 +108,28 @@ function JuMP.add_constraint( model::M, c::JuMP.ScalarConstraint{DP.LogicalVariableRef{M}, S}, name::String = "" - ) where {M <: InfiniteOpt.InfiniteModel, S} - error("Cannot define constraint on single logical variable, " * - "use `fix` instead.") +) where {M <: InfiniteOpt.InfiniteModel, S} + error("Cannot define constraint on single logical variable, use `fix` instead.") end function JuMP.add_constraint( model::M, c::JuMP.ScalarConstraint{ - JuMP.GenericAffExpr{C, DP.LogicalVariableRef{M}}, S}, + JuMP.GenericAffExpr{C, DP.LogicalVariableRef{M}}, S + }, name::String = "" - ) where {M <: InfiniteOpt.InfiniteModel, S, C} - error("Cannot add, subtract, or multiply with " * - "logical variables.") +) where {M <: InfiniteOpt.InfiniteModel, S, C} + error("Cannot add, subtract, or multiply with logical variables.") end function JuMP.add_constraint( model::M, c::JuMP.ScalarConstraint{ - JuMP.GenericQuadExpr{C, DP.LogicalVariableRef{M}}, S}, + JuMP.GenericQuadExpr{C, DP.LogicalVariableRef{M}}, S + }, name::String = "" - ) where {M <: InfiniteOpt.InfiniteModel, S, C} - error("Cannot add, subtract, or multiply with " * - "logical variables.") +) where {M <: InfiniteOpt.InfiniteModel, S, C} + error("Cannot add, subtract, or multiply with logical variables.") end ################################################################################ @@ -132,7 +137,7 @@ end ################################################################################ function DP.get_constant( expr::JuMP.GenericAffExpr{T, InfiniteOpt.GeneralVariableRef} - ) where {T} +) where {T} constant = JuMP.constant(expr) param_expr = zero(typeof(expr)) for (var, coeff) in expr.terms @@ -144,16 +149,16 @@ function DP.get_constant( end function DP.disaggregate_expression( - model::M, aff::JuMP.GenericAffExpr, + model::M, + aff::JuMP.GenericAffExpr, bvref::Union{JuMP.AbstractVariableRef, JuMP.GenericAffExpr}, method::DP._Hull - ) where {M <: InfiniteOpt.InfiniteModel} +) where {M <: InfiniteOpt.InfiniteModel} terms = Any[aff.constant * bvref] for (vref, coeff) in aff.terms if JuMP.is_binary(vref) push!(terms, coeff * vref) - elseif vref isa InfiniteOpt.GeneralVariableRef && - _is_parameter(vref) + elseif vref isa InfiniteOpt.GeneralVariableRef && _is_parameter(vref) push!(terms, coeff * vref * bvref) elseif !haskey(method.disjunct_variables, (vref, bvref)) push!(terms, coeff * vref) @@ -208,8 +213,8 @@ end # copy_model_with_constraints (build mini InfiniteModel + # transcribe to flat JuMP model), prepare_max_M_objective # (expand infinite constraint into K flat objectives via -# _build_flat_map), and aggregate_M_values (interpolate flat -# values to parameter function). +# _build_flat_map), and raw_M (vector dispatch aggregates +# K per-support M values into a parameter function). # Collect all parameter function refs from all disjunct constraints in # the model. @@ -221,8 +226,7 @@ function _all_param_functions( for cref in crefs cref isa DP.DisjunctConstraintRef || continue con = JuMP.constraint_object(cref) - for v in InfiniteOpt.all_expression_variables( - con.func) + for v in InfiniteOpt.all_expression_variables(con.func) dv = InfiniteOpt.dispatch_variable_ref(v) if dv isa InfiniteOpt.ParameterFunctionRef push!(pf_set, v) @@ -241,7 +245,7 @@ end function _build_flat_map( sub::DP.GDPSubmodel, k::Int, prefs::Vector{InfiniteOpt.GeneralVariableRef}, - supports::Dict{InfiniteOpt.GeneralVariableRef,Vector{Float64}}, + supports::Dict{InfiniteOpt.GeneralVariableRef, Vector{Float64}}, full_shape::Tuple, pf_set::Set{InfiniteOpt.GeneralVariableRef} ) @@ -281,7 +285,8 @@ function DP.copy_model_with_constraints( method::DP._MBM ) mini = InfiniteOpt.InfiniteModel() - ref_map = Dict{InfiniteOpt.GeneralVariableRef,InfiniteOpt.GeneralVariableRef}() + ref_map = Dict{InfiniteOpt.GeneralVariableRef, + InfiniteOpt.GeneralVariableRef}() # 1. Copy infinite parameters with their supports for p in InfiniteOpt.all_parameters(model) @@ -298,7 +303,8 @@ function DP.copy_model_with_constraints( prefs = InfiniteOpt.parameter_refs(v) var_type = isempty(prefs) ? nothing : InfiniteOpt.Infinite(Tuple(ref_map[p] for p in prefs)...) - props = DP.VariableProperties(DP.get_variable_info(v),"", nothing, var_type) + props = DP.VariableProperties( + DP.get_variable_info(v), "", nothing, var_type) ref_map[v] = DP.create_variable(mini, props) end @@ -334,10 +340,19 @@ function DP.copy_model_with_constraints( end # 6. Transcribe mini InfiniteModel to flat JuMP model - flat, tr_fwd = transcribe_to_flat(mini) + InfiniteOpt.build_transformation_backend!(mini) + flat = InfiniteOpt.transformation_model(mini) + tr_fwd = Dict{InfiniteOpt.GeneralVariableRef, + Vector{JuMP.VariableRef}}() + for v in DP.collect_all_vars(mini) + tv = InfiniteOpt.transformation_variable(v) + vprefs = InfiniteOpt.parameter_refs(v) + tr_fwd[v] = isempty(vprefs) ? [tv] : vec(tv) + end # 7. Remap fwd_map: original model var -> flat JuMP VarRef - fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, + Vector{JuMP.VariableRef}}() for (orig, mapped) in ref_map _is_parameter(orig) && continue haskey(tr_fwd, mapped) || continue @@ -370,6 +385,7 @@ function DP.prepare_max_M_objective( end return objectives end + function DP.prepare_max_M_objective( model::InfiniteOpt.InfiniteModel, obj::JuMP.ScalarConstraint{T, S}, @@ -381,16 +397,16 @@ function DP.prepare_max_M_objective( pf_set = _all_param_functions(model) objectives = Vector{JuMP.AbstractJuMPScalar}(undef, K) for k in 1:K - flat_map = _build_flat_map(sub, k, prefs, supports,full_shape, pf_set) + flat_map = _build_flat_map(sub, k, prefs, supports, full_shape, pf_set) objectives[k] = obj.set.lower - DP._replace_variables_in_constraint(obj.func, flat_map) end return objectives end -# Solve the submodel for a vector of objectives (one per -# support point). Returns aggregated result or nothing. -function DP._raw_M( +# Solve the submodel for a vector of objectives (one per support point). +# Returns aggregated M value (scalar or parameter function) or nothing. +function DP.raw_M( sub::DP.GDPSubmodel, objectives::Vector{<:JuMP.AbstractJuMPScalar}, method::DP._MBM @@ -411,40 +427,8 @@ function DP._raw_M( push!(M_vals, method.default_M) end end - model = JuMP.owner_model( - first(keys(sub.fwd_map))) - return aggregate_M_values(model, M_vals) -end - -# Condense flat per-support values to final form (MBM path). -function aggregate_M_values( - model::InfiniteOpt.InfiniteModel, - vals::AbstractVector{<:Real} - ) - if all(==(vals[1]), vals) - return vals[1] - end - prefs, supports = _collect_parameters(model) - return condense_to_pf(model, vals, prefs, supports) -end - -# Interpolate flat per-support values into a parameter function. Computes -# grids/shape from supports, reshapes, interpolates, and registers on -# the model. -function condense_to_pf( - model::InfiniteOpt.InfiniteModel, - vals::AbstractVector{<:Real}, - prefs::Union{Vector{InfiniteOpt.GeneralVariableRef}, - Tuple{Vararg{InfiniteOpt.GeneralVariableRef}}}, - supports::Dict{InfiniteOpt.GeneralVariableRef, - Vector{Float64}} - ) - grids = Tuple(supports[p] for p in prefs) - shape = Tuple(length(supports[p]) for p in prefs) - nd = reshape(vals, shape) - fn = Interpolations.linear_interpolation(grids, nd, - extrapolation_bc = Interpolations.Line()) - return _make_parameter_function(model, fn, prefs...) + model = JuMP.owner_model(first(keys(sub.fwd_map))) + return _aggregate_M_values(model, M_vals) end ################################################################################ @@ -479,26 +463,49 @@ function _collect_parameters(model::InfiniteOpt.InfiniteModel) return prefs, supports end -# Transcribe an InfiniteModel to a flat JuMP.Model with forward variable -# map. Shared by MBM and CP paths. -function transcribe_to_flat(model::InfiniteOpt.InfiniteModel) - InfiniteOpt.build_transformation_backend!(model) - flat = InfiniteOpt.transformation_model(model) - fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() - for v in DP.collect_all_vars(model) - tv = InfiniteOpt.transformation_variable(v) - vprefs = InfiniteOpt.parameter_refs(v) - fwd_map[v] = isempty(vprefs) ? [tv] : vec(tv) +# Condense flat per-support M values into a scalar or parameter function. +function _aggregate_M_values( + model::InfiniteOpt.InfiniteModel, + vals::AbstractVector{<:Real} + ) + if all(==(vals[1]), vals) + return vals[1] end - return flat, fwd_map + prefs, supports = _collect_parameters(model) + return values_to_parameter_function(model, vals, prefs, supports) +end + +""" + values_to_parameter_function( + model, vals, prefs, supports + ) + +Interpolate flat per-support values into an InfiniteOpt parameter +function registered on `model`. Builds a grid from `supports`, +reshapes `vals`, fits a linear interpolation, and returns the +registered parameter function ref. +""" +function values_to_parameter_function( + model::InfiniteOpt.InfiniteModel, + vals::AbstractVector{<:Real}, + prefs::Union{Vector{InfiniteOpt.GeneralVariableRef}, + Tuple{Vararg{InfiniteOpt.GeneralVariableRef}}}, + supports::Dict{InfiniteOpt.GeneralVariableRef, Vector{Float64}} + ) + grids = Tuple(supports[p] for p in prefs) + shape = Tuple(length(supports[p]) for p in prefs) + nd = reshape(vals, shape) + fn = Interpolations.linear_interpolation(grids, nd, + extrapolation_bc = Interpolations.Line()) + return _make_parameter_function(model, fn, prefs...) end ################################################################################ # CUTTING PLANES FOR INFINITEMODEL ################################################################################ -# Build CP subproblem: reformulate the InfiniteModel, transcribe to a flat -# JuMP copy, and wrap in GDPSubmodel with forward variable map. +# Build CP subproblem: reformulate the InfiniteModel in-place, transcribe +# to a flat JuMP copy, and wrap in GDPSubmodel with forward variable map. function DP.copy_and_reformulate( model::InfiniteOpt.InfiniteModel, decision_vars::Vector{InfiniteOpt.GeneralVariableRef}, @@ -506,9 +513,18 @@ function DP.copy_and_reformulate( method::DP.CuttingPlanes ) DP.reformulate_model(model, reform_method) - flat, tr_fwd = transcribe_to_flat(model) + InfiniteOpt.build_transformation_backend!(model) + flat = InfiniteOpt.transformation_model(model) + tr_fwd = Dict{InfiniteOpt.GeneralVariableRef, + Vector{JuMP.VariableRef}}() + for v in DP.collect_all_vars(model) + tv = InfiniteOpt.transformation_variable(v) + vprefs = InfiniteOpt.parameter_refs(v) + tr_fwd[v] = isempty(vprefs) ? [tv] : vec(tv) + end sub_copy, copy_map = JuMP.copy_model(flat) - fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, + Vector{JuMP.VariableRef}}() for v in decision_vars haskey(tr_fwd, v) || continue fwd_map[v] = [copy_map[tv] for tv in tr_fwd[v]] @@ -519,70 +535,47 @@ function DP.copy_and_reformulate( return sub end -# Full CP loop for InfiniteModel: cannot solve in-place, so both SEP -# and rBM are separate transcribed flat copies. -function DP.reformulate_model( - model::InfiniteOpt.InfiniteModel, - method::DP.CuttingPlanes - ) - decision_vars = DP.collect_cutting_planes_vars(model) - separation = DP.copy_and_reformulate( - model, decision_vars, DP.Hull(), method) - JuMP.relax_integrality(separation.model) - rBM = DP.copy_and_reformulate( - model, decision_vars, DP.BigM(method.M_value), method) - JuMP.relax_integrality(rBM.model) - for iter in 1:method.max_iter - JuMP.optimize!(rBM.model, ignore_optimize_hook = true) - rBM_sol = DP.extract_solution(rBM) - sep_obj, sep_sol = DP._solve_separation(separation, rBM_sol) - sep_obj <= method.seperation_tolerance && break - _add_infinite_cut(rBM, model, rBM_sol, sep_sol) +# Extract per-support-point solutions from the InfiniteOpt transformation +# backend after optimize!(model, ignore_optimize_hook=true). +function DP.extract_solution(model::InfiniteOpt.InfiniteModel) + dvars = DP.collect_cutting_planes_vars(model) + V = eltype(dvars) + T = JuMP.value_type(typeof(model)) + sol = Dict{V, Vector{T}}() + for v in dvars + tv = InfiniteOpt.transformation_variable(v) + vprefs = InfiniteOpt.parameter_refs(v) + sol[v] = isempty(vprefs) ? [JuMP.value(tv)] : JuMP.value.(vec(tv)) end - DP._set_solution_method(model, method) - DP._set_ready_to_optimize(model, true) - return + return sol end -# Add cut to both the flat rBM model and the original InfiniteModel. -function _add_infinite_cut( - rBM::DP.GDPSubmodel{<:Any, <:InfiniteOpt.GeneralVariableRef, <:Any}, +# Add an infinite-form cut to the InfiniteModel. Infinite variables get +# interpolated parameter function coefficients wrapped in integrals; +# finite variables contribute scalar terms. +function DP.add_cut( model::InfiniteOpt.InfiniteModel, + decision_vars::Vector{InfiniteOpt.GeneralVariableRef}, rBM_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}}, sep_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}} ) - cut_expr = zero(JuMP.GenericAffExpr{ - JuMP.value_type(typeof(rBM.model)), - JuMP.variable_ref_type(rBM.model)}) - for var in rBM.decision_vars - sub_vars = rBM.fwd_map[var] - rbm_vals = rBM_sol[var] - sep_vals = sep_sol[var] - for k in 1:length(sub_vars) - xi = 2 * (sep_vals[k] - rbm_vals[k]) - JuMP.add_to_expression!(cut_expr, xi, sub_vars[k]) - JuMP.add_to_expression!(cut_expr, -xi * sep_vals[k]) - end - end - JuMP.@constraint(rBM.model, cut_expr >= 0) prefs, sups = _collect_parameters(model) inf_terms = Any[] - cut_scalar = zero(JuMP.GenericAffExpr{ - JuMP.value_type(typeof(model)), - JuMP.variable_ref_type(model)}) - for var in rBM.decision_vars + cut_scalar = DP._zero_aff(model) + for var in decision_vars haskey(rBM_sol, var) || continue haskey(sep_sol, var) || continue vprefs = InfiniteOpt.parameter_refs(var) if isempty(vprefs) xi = 2 * (sep_sol[var][1] - rBM_sol[var][1]) - sp = sep_sol[var][1] - cut_scalar += xi * (var - sp) + cut_scalar += xi * (var - sep_sol[var][1]) else xi_vals = 2 .* (sep_sol[var] .- rBM_sol[var]) sp_vals = sep_sol[var] - xi_pf = condense_to_pf(model, xi_vals, vprefs, sups) - sp_pf = condense_to_pf(model, sp_vals, vprefs, sups) + xi_pf = values_to_parameter_function( + model, xi_vals, vprefs, sups) + sp_pf = values_to_parameter_function( + model, sp_vals, vprefs, sups) push!(inf_terms, xi_pf * var - xi_pf * sp_pf) end end @@ -593,7 +586,8 @@ function _add_infinite_cut( end cut_scalar += inf_expr end - JuMP.@constraint(model, cut_scalar >= 0) + cref = JuMP.@constraint(model, cut_scalar >= 0) + push!(DP._reformulation_constraints(model), cref) return end diff --git a/src/mbm.jl b/src/mbm.jl index 973c0519..8d8fc1cf 100644 --- a/src/mbm.jl +++ b/src/mbm.jl @@ -242,7 +242,7 @@ end prepare_max_M_objective(model, obj::ScalarConstraint, sub::GDPSubmodel) Convert a constraint into an objective expression for M-value -maximization. Returns a single JuMP expression to pass to `_raw_M`. +maximization. Returns a single JuMP expression to pass to `raw_M`. """ function prepare_max_M_objective( ::JuMP.AbstractModel, @@ -266,7 +266,7 @@ end # Solve the submodel for a single objective expression. # Returns a scalar M value, or nothing if infeasible. -function _raw_M( +function raw_M( sub::GDPSubmodel, objective::JuMP.AbstractJuMPScalar, method::_MBM @@ -291,7 +291,7 @@ function _maximize_M( method::_MBM ) where {T, S <: Union{_MOI.LessThan, _MOI.GreaterThan}} sub = _get_submodel(model, constraints, method) - return _raw_M(sub, + return raw_M(sub, prepare_max_M_objective(model, objective, sub), method) end @@ -321,8 +321,8 @@ function _maximize_M( set_value = objective.set.value ge_obj = JuMP.ScalarConstraint(objective.func, MOI.GreaterThan(set_value)) le_obj = JuMP.ScalarConstraint(objective.func, MOI.LessThan(set_value)) - raw_lower = _raw_M(sub, prepare_max_M_objective(model, ge_obj, sub), method) - raw_upper = _raw_M(sub, prepare_max_M_objective(model, le_obj, sub), method) + raw_lower = raw_M(sub, prepare_max_M_objective(model, ge_obj, sub), method) + raw_upper = raw_M(sub, prepare_max_M_objective(model, le_obj, sub), method) (raw_lower === nothing || raw_upper === nothing) && return nothing return [raw_lower, raw_upper] @@ -341,8 +341,8 @@ function _maximize_M( MOI.GreaterThan(set_values[1])) le_obj = JuMP.ScalarConstraint(objective.func, MOI.LessThan(set_values[2])) - raw_lower = _raw_M(sub, prepare_max_M_objective(model, ge_obj, sub), method) - raw_upper = _raw_M(sub, prepare_max_M_objective(model, le_obj, sub), method) + raw_lower = raw_M(sub, prepare_max_M_objective(model, ge_obj, sub), method) + raw_upper = raw_M(sub, prepare_max_M_objective(model, le_obj, sub), method) (raw_lower === nothing || raw_upper === nothing) && return nothing return [raw_lower, raw_upper] @@ -361,7 +361,7 @@ function _maximize_M( for i in 1:objective.set.dimension le_obj = JuMP.ScalarConstraint( objective.func[i], MOI.LessThan(zero(val_type))) - raw = _raw_M(sub, + raw = raw_M(sub, prepare_max_M_objective(model, le_obj, sub), method) raw === nothing && return nothing @@ -384,7 +384,7 @@ function _maximize_M( ge_obj = JuMP.ScalarConstraint( objective.func[i], MOI.GreaterThan(zero(val_type))) - raw = _raw_M(sub, + raw = raw_M(sub, prepare_max_M_objective(model, ge_obj, sub), method) raw === nothing && return nothing @@ -410,10 +410,10 @@ function _maximize_M( le_obj = JuMP.ScalarConstraint( objective.func[i], MOI.LessThan(zero(val_type))) - raw_ge = _raw_M(sub, + raw_ge = raw_M(sub, prepare_max_M_objective(model, ge_obj, sub), method) - raw_le = _raw_M(sub, + raw_le = raw_M(sub, prepare_max_M_objective(model, le_obj, sub), method) (raw_ge === nothing || raw_le === nothing) && diff --git a/test/constraints/mbm.jl b/test/constraints/mbm.jl index d181c082..80f9a27f 100644 --- a/test/constraints/mbm.jl +++ b/test/constraints/mbm.jl @@ -134,19 +134,19 @@ function test_raw_M() DisjunctConstraintRef[con2], mbm) obj = DP.prepare_max_M_objective(model, constraint_object(con), sub) - @test DP._raw_M(sub, obj, mbm) == 0.0 + @test DP.raw_M(sub, obj, mbm) == 0.0 set_upper_bound(x, 1) sub2 = DP.copy_model_with_constraints(model, DisjunctConstraintRef[con], mbm) obj2 = DP.prepare_max_M_objective(model, constraint_object(con2), sub2) - @test DP._raw_M(sub2, obj2, mbm) == 15 + @test DP.raw_M(sub2, obj2, mbm) == 15 set_integer(y) @constraint(model, con3, y*x == 15, Disjunct(Y[1])) obj3 = DP.prepare_max_M_objective(model, constraint_object(con2), sub2) - @test DP._raw_M(sub2, obj3, mbm) == 15 + @test DP.raw_M(sub2, obj3, mbm) == 15 # Fresh _MBM after changing bounds JuMP.fix(y, 5; force=true) mbm2 = DP._MBM( @@ -155,7 +155,7 @@ function test_raw_M() DisjunctConstraintRef[con], mbm2) obj4 = DP.prepare_max_M_objective(model, constraint_object(con2), sub3) - @test DP._raw_M(sub3, obj4, mbm2) == 10 + @test DP.raw_M(sub3, obj4, mbm2) == 10 # Infeasible region → nothing delete_lower_bound(x) mbm3 = DP._MBM( @@ -164,7 +164,7 @@ function test_raw_M() DisjunctConstraintRef[con2], mbm3) obj5 = DP.prepare_max_M_objective(model, constraint_object(con2), sub4) - @test DP._raw_M(sub4, obj5, mbm3) == nothing + @test DP.raw_M(sub4, obj5, mbm3) == nothing # infeasible (x >= 100 but x <= 1) set_upper_bound(x, 1) @@ -175,7 +175,7 @@ function test_raw_M() mbm4) obj6 = DP.prepare_max_M_objective(model, constraint_object(con), sub5) - @test DP._raw_M(sub5, obj6, mbm4) == nothing + @test DP.raw_M(sub5, obj6, mbm4) == nothing # Unbounded subproblem → default_M fallback. # No lower bound on x means max(5 - x) s.t. x <= 3 @@ -191,7 +191,7 @@ function test_raw_M() DisjunctConstraintRef[ub_con1], mbm_ub) obj_ub = DP.prepare_max_M_objective(model_ub, constraint_object(ub_con2), sub_ub) - @test DP._raw_M(sub_ub, obj_ub, mbm_ub) == mbm_ub.default_M + @test DP.raw_M(sub_ub, obj_ub, mbm_ub) == mbm_ub.default_M end function test_maximize_M() diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 8ba1e5c2..e2dda56c 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -1,4 +1,4 @@ -using InfiniteOpt, HiGHS, Ipopt, Juniper +using InfiniteOpt, HiGHS, Ipopt, Juniper, Interpolations import DisjunctiveProgramming as DP # Helper to access internal function @@ -77,6 +77,24 @@ function test__is_parameter() @test IDP._is_parameter(y) == false end +# _is_parameter on unwrapped concrete dispatch types. Covers +# ext lines 28-32 (DependentParameterRef, IndependentParameterRef, +# FiniteParameterRef, ParameterFunctionRef, Any fallback). +function test__is_parameter_concrete_dispatches() + model = InfiniteGDPModel() + @infinite_parameter(model, t ∈ [0, 1]) + @infinite_parameter(model, s[1:2] ∈ [0, 1], independent = true) + @finite_parameter(model, p == 1.0) + @variable(model, x, Infinite(t)) + @parameter_function(model, pf == t -> 2*t) + dvr = InfiniteOpt.dispatch_variable_ref + @test IDP._is_parameter(dvr(t)) == true # Dependent + @test IDP._is_parameter(dvr(s[1])) == true # Independent + @test IDP._is_parameter(dvr(p)) == true # Finite + @test IDP._is_parameter(dvr(pf)) == true # ParamFunc + @test IDP._is_parameter(dvr(x)) == false # Any +end + function test_requires_disaggregation() model = InfiniteGDPModel() @infinite_parameter(model, t ∈ [0, 1]) @@ -169,6 +187,43 @@ function test_disaggregate_expression_infiniteopt() @test haskey(result_not_disagg.terms, y) end +function test_disaggregate_quad_expression_infiniteopt() + model = InfiniteGDPModel() + @infinite_parameter(model, t ∈ [0, 1]) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, 0 <= y <= 5, Infinite(t)) + @variable(model, z, InfiniteLogical(t)) + + bvrefs = DP._indicator_to_binary(model) + bvref = bvrefs[z] + + vrefs = Set([x, y]) + DP._variable_bounds(model)[x] = DP.set_variable_bound_info(x, Hull()) + DP._variable_bounds(model)[y] = DP.set_variable_bound_info(y, Hull()) + method = DP._Hull(Hull(1e-3), vrefs) + DP._disaggregate_variables(model, z, vrefs, method) + + # var × var → nonlinear (perspective divides by y) + quad_vv = @expression(model, x * y) + result_vv = DP.disaggregate_expression(model, quad_vv, bvref, method) + @test result_vv isa JuMP.GenericNonlinearExpr + + # param × var → quadratic (param * disaggregated) + quad_pv = @expression(model, t * x) + result_pv = DP.disaggregate_expression(model, quad_pv, bvref, method) + @test result_pv isa JuMP.GenericQuadExpr + + # var × param → quadratic (disaggregated * param) + quad_vp = @expression(model, x * t) + result_vp = DP.disaggregate_expression(model, quad_vp, bvref, method) + @test result_vp isa JuMP.GenericQuadExpr + + # param × param → cubic (t * t * bvref) + quad_pp = @expression(model, t * t) + result_pp = DP.disaggregate_expression(model, quad_pp, bvref, method) + @test result_pp isa JuMP.GenericNonlinearExpr +end + function test_variable_properties_infiniteopt() model = InfiniteGDPModel() @infinite_parameter(model, t ∈ [0, 1]) @@ -336,10 +391,110 @@ function test_logical_value() @test eltype(val) == Bool end -function test_unsupported_methods_error() +# _collect_parameters on model with no infinite parameters. +# Covers ext line 508. +function test__collect_parameters_no_params() + model = InfiniteGDPModel() + @test_throws ErrorException IDP._collect_parameters(model) +end + +# MBM with finite + integer variables in InfiniteModel. Covers +# copy_model_with_constraints (finite var, set_integer), +# and _build_flat_map line 252 (finite var path). +function test_mbm_finite_and_integer_var() model = InfiniteGDPModel(HiGHS.Optimizer) - @test_throws ErrorException DP.reformulate_model(model, MBM(HiGHS.Optimizer)) - @test_throws ErrorException DP.reformulate_model(model, CuttingPlanes(HiGHS.Optimizer)) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, 0 <= w <= 5, Int) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x + w >= 5, Disjunct(Y[1])) + @constraint(model, x + w <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Min, ∫(x, t) + w) + @test optimize!(model, + gdp_method = MBM(HiGHS.Optimizer)) isa Nothing + @test termination_status(model) in + [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] +end + +function test_mbm_infinite_simple() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + + @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + + @constraint(model, x >= 5, Disjunct(Y[1])) + @constraint(model, x <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + + @objective(model, Min, ∫(x, t)) + + @test optimize!(model, gdp_method = MBM(HiGHS.Optimizer)) isa Nothing + @test termination_status(model) in + [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] + # x=0 with disjunct 2 active (x <= 3) gives min + @test objective_value(model) ≈ 0.0 atol = 0.1 +end + +function test_mbm_infinite_param_dependent() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + + @infinite_parameter(model, t ∈ [0, 1], num_supports = 20) + @variable(model, -10 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + + # Parameter-dependent constraints: + # Disjunct 1: x(t) <= 2*t + # Disjunct 2: x(t) >= 1 - t + @parameter_function(model, f1 == t -> 2*t) + @parameter_function(model, f2 == t -> 1 - t) + @constraint(model, x <= f1, Disjunct(Y[1])) + @constraint(model, x >= f2, Disjunct(Y[2])) + @disjunction(model, Y) + + @objective(model, Min, ∫(x, t)) + + @test optimize!(model, gdp_method = MBM(HiGHS.Optimizer)) isa Nothing + @test termination_status(model) in + [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] +end + +function test_mbm_vs_bigm_infinite() + # Compare MBM and BigM: should give same + # feasible set and optimal value. + for method_pair in [ + (BigM(100), MBM(HiGHS.Optimizer)) + ] + model1 = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model1) + @infinite_parameter(model1, t ∈ [0, 1], num_supports = 10) + @variable(model1, 0 <= x1 <= 10, Infinite(t)) + @variable(model1, Y1[1:2], InfiniteLogical(t)) + @constraint(model1, x1 >= 5, Disjunct(Y1[1])) + @constraint(model1, x1 <= 3, Disjunct(Y1[2])) + @disjunction(model1, Y1) + @objective(model1, Min, ∫(x1, t)) + optimize!(model1, gdp_method = method_pair[1]) + obj1 = objective_value(model1) + + model2 = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model2) + @infinite_parameter(model2, t2 ∈ [0, 1], num_supports = 10) + @variable(model2, 0 <= x2 <= 10, Infinite(t2)) + @variable(model2, Y2[1:2], InfiniteLogical(t2)) + @constraint(model2, x2 >= 5, Disjunct(Y2[1])) + @constraint(model2, x2 <= 3, Disjunct(Y2[2])) + @disjunction(model2, Y2) + @objective(model2, Min, ∫(x2, t2)) + optimize!(model2, gdp_method = method_pair[2]) + obj2 = objective_value(model2) + + @test obj1 ≈ obj2 atol = 0.5 + end end function test_methods() @@ -393,6 +548,154 @@ function test_methods() @test value(z) ≈ expected_z atol=tol end +function test_mbm_with_derivatives() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + + @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) + @variable(model, -5 <= x <= 5, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + + @constraint(model, ∂(x, t) >= 1, Disjunct(Y[1])) + @constraint(model, ∂(x, t) <= -1, Disjunct(Y[2])) + @disjunction(model, Y) + + set_upper_bound(∂(x, t), 10) + set_lower_bound(∂(x, t), -10) + + @objective(model, Min, ∫(x^2, t)) + + juniper = JuMP.optimizer_with_attributes( + Juniper.Optimizer, + "nl_solver" => JuMP.optimizer_with_attributes( + Ipopt.Optimizer, "print_level" => 0), + "log_levels" => [] + ) + set_optimizer(model, juniper) + @test optimize!(model, gdp_method = MBM(juniper)) isa Nothing + @test termination_status(model) in + [MOI.OPTIMAL, MOI.LOCALLY_SOLVED, + MOI.ALMOST_LOCALLY_SOLVED] +end + +function test_CuttingPlanes_infinite_simple() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + + @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + + @constraint(model, x >= 5, Disjunct(Y[1])) + @constraint(model, x <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + + @objective(model, Min, ∫(x, t)) + + # Should not throw + @test optimize!(model, + gdp_method = CuttingPlanes( + HiGHS.Optimizer; max_iter = 5) + ) isa Nothing + @test termination_status(model) in + [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] +end + +function test_CuttingPlanes_infinite_two_disj() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + + @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) + @variable(model, 0 <= x[1:2] <= 10, Infinite(t)) + @variable(model, W1[1:2], InfiniteLogical(t)) + @variable(model, W2[1:2], InfiniteLogical(t)) + + @constraint(model, x[1] >= 2, Disjunct(W1[1])) + @constraint(model, x[1] <= 1, Disjunct(W1[2])) + @disjunction(model, W1) + + @constraint(model, x[2] >= 3, Disjunct(W2[1])) + @constraint(model, x[2] <= 2, Disjunct(W2[2])) + @disjunction(model, W2) + + @objective(model, Min, ∫(x[1] + x[2], t)) + + # Compare cutting planes vs BigM + optimize!(model, + gdp_method = CuttingPlanes( + HiGHS.Optimizer; max_iter = 10) + ) + cp_obj = objective_value(model) + + model2 = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model2) + @infinite_parameter(model2, t2 ∈ [0, 1], num_supports = 10) + @variable(model2, 0 <= x2[1:2] <= 10, Infinite(t2)) + @variable(model2, V1[1:2], InfiniteLogical(t2)) + @variable(model2, V2[1:2], InfiniteLogical(t2)) + @constraint(model2, x2[1] >= 2, Disjunct(V1[1])) + @constraint(model2, x2[1] <= 1, Disjunct(V1[2])) + @disjunction(model2, V1) + @constraint(model2, x2[2] >= 3, Disjunct(V2[1])) + @constraint(model2, x2[2] <= 2, Disjunct(V2[2])) + @disjunction(model2, V2) + @objective(model2, Min, ∫(x2[1] + x2[2], t2)) + optimize!(model2, gdp_method = BigM()) + bigm_obj = objective_value(model2) + + @test cp_obj ≈ bigm_obj atol = 1.0 +end + + + +function test_CuttingPlanes_with_cuts() + # Maximization with single-constraint disjuncts where Hull + # is strictly tighter than BigM. BigM allows x+y up to + # variable bounds (20), Hull limits to max(5,8)=8. This + # forces cuts. Finite var w exercises isempty(vprefs) + # branch in add_original_model_cut (line 779). + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, 0 <= y <= 10, Infinite(t)) + @variable(model, 0 <= w <= 10) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x + y <= 5, Disjunct(Y[1])) + @constraint(model, x + y <= 8, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, ∫(x + y, t) + w) + cutting_planes = CuttingPlanes(HiGHS.Optimizer; + max_iter = 30, seperation_tolerance = 1e-6) + @test optimize!(model, gdp_method = cutting_planes) isa Nothing + @test termination_status(model) in + [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] +end + +function test_CuttingPlanes_multiparameter() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @infinite_parameter(model, s ∈ [0, 2], num_supports = 4) + @variable(model, 0 <= x <= 10, Infinite(t, s)) + @variable(model, Y[1:2], InfiniteLogical(t, s)) + + @constraint(model, x >= 5, Disjunct(Y[1])) + @constraint(model, x <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + + @objective(model, Min, ∫(∫(x, t), s)) + + # Should not throw + @test optimize!(model, + gdp_method = CuttingPlanes( + HiGHS.Optimizer; max_iter = 5) + ) isa Nothing + @test termination_status(model) in + [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] +end + @testset "InfiniteDisjunctiveProgramming" begin @testset "Model" begin @@ -406,6 +709,7 @@ end @testset "Variables" begin test_infinite_logical() test__is_parameter() + test__is_parameter_concrete_dispatches() test_requires_disaggregation() test_variable_properties_infiniteopt() test_variable_properties_from_expr() @@ -429,7 +733,19 @@ end @testset "Methods" begin test_get_constant() test_disaggregate_expression_infiniteopt() - test_unsupported_methods_error() + test_disaggregate_quad_expression_infiniteopt() + end + + @testset "Internal Helpers" begin + test__collect_parameters_no_params() + end + + @testset "MBM" begin + test_mbm_finite_and_integer_var() + test_mbm_infinite_simple() + test_mbm_infinite_param_dependent() + test_mbm_vs_bigm_infinite() + test_mbm_with_derivatives() end @testset "Integration" begin @@ -437,4 +753,12 @@ end test_methods() end + @testset "Cutting Planes" begin + test_CuttingPlanes_infinite_simple() + test_CuttingPlanes_infinite_two_disj() + test_CuttingPlanes_with_cuts() + test_CuttingPlanes_multiparameter() + end + + end \ No newline at end of file From ab3370bb2be7310ba20a020a61f8880f8bd92d6d Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 20 Apr 2026 21:55:45 -0400 Subject: [PATCH 10/72] . --- Project.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 3346659a..ae0dad94 100644 --- a/Project.toml +++ b/Project.toml @@ -27,10 +27,11 @@ julia = "1.10" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" +InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" Juniper = "2ddba703-00a4-53a7-87a5-e8b9971dde84" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Aqua", "HiGHS", "Test", "Juniper", "Ipopt", "Interpolations"] +test = ["Aqua", "HiGHS", "InfiniteOpt", "Test", "Juniper", "Ipopt", "Interpolations"] From 65e743f4e25aba7a27f6c6e266a6ae7562947c9e Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Tue, 21 Apr 2026 10:23:19 -0400 Subject: [PATCH 11/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 9 +++++---- src/extension_api.jl | 10 +++++----- src/utilities.jl | 13 ------------- test/extensions/InfiniteDisjunctiveProgramming.jl | 3 +-- 4 files changed, 11 insertions(+), 24 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 8f5cd1e0..157ebddc 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -297,7 +297,7 @@ function DP.copy_model_with_constraints( ref_map[p] = new_p end - # 2. Copy decision variables with bounds (skip parameters) + # 2. Copy decision variables with bounds for v in JuMP.all_variables(model) _is_parameter(v) && continue prefs = InfiniteOpt.parameter_refs(v) @@ -351,8 +351,7 @@ function DP.copy_model_with_constraints( end # 7. Remap fwd_map: original model var -> flat JuMP VarRef - fwd_map = Dict{InfiniteOpt.GeneralVariableRef, - Vector{JuMP.VariableRef}}() + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() for (orig, mapped) in ref_map _is_parameter(orig) && continue haskey(tr_fwd, mapped) || continue @@ -561,7 +560,9 @@ function DP.add_cut( ) prefs, sups = _collect_parameters(model) inf_terms = Any[] - cut_scalar = DP._zero_aff(model) + cut_scalar = zero(JuMP.GenericAffExpr{ + JuMP.value_type(typeof(model)), + InfiniteOpt.GeneralVariableRef}) for var in decision_vars haskey(rBM_sol, var) || continue haskey(sep_sol, var) || continue diff --git a/src/extension_api.jl b/src/extension_api.jl index 6d046450..ad3566f6 100644 --- a/src/extension_api.jl +++ b/src/extension_api.jl @@ -1,8 +1,8 @@ """ InfiniteGDPModel(args...; kwargs...) -Creates an `InfiniteOpt.InfiniteModel` that is compatible with the -capabiltiies provided by DisjunctiveProgramming.jl. This requires +Creates an `InfiniteOpt.InfiniteModel` that is compatible with the +capabiltiies provided by DisjunctiveProgramming.jl. This requires that InfiniteOpt be imported first. **Example** @@ -18,9 +18,9 @@ function InfiniteGDPModel end """ InfiniteLogical(prefs...) -Allows users to create infinite logical variables. This is a tag -for the `@variable` macro that is a combination of `InfiniteOpt.Infinite` -and `DisjunctiveProgramming.Logical`. This requires that InfiniteOpt be +Allows users to create infinite logical variables. This is a tag +for the `@variable` macro that is a combination of `InfiniteOpt.Infinite` +and `DisjunctiveProgramming.Logical`. This requires that InfiniteOpt be first imported. **Example** diff --git a/src/utilities.jl b/src/utilities.jl index 69d3f4ca..b0203d70 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -129,19 +129,6 @@ function get_constant(expr::JuMP.AbstractVariableRef) return zero(JuMP.value_type(typeof(JuMP.owner_model(expr)))) end -################################################################################ -# ZERO EXPRESSION CONSTRUCTORS -################################################################################ -# Create a type-correct zero affine expression for the model. -_zero_aff(model::JuMP.AbstractModel) = zero( - JuMP.GenericAffExpr{JuMP.value_type(typeof(model)), - JuMP.variable_ref_type(model)}) - -# Create a type-correct zero quadratic expression for the model. -_zero_quad(model::JuMP.AbstractModel) = zero( - JuMP.GenericQuadExpr{JuMP.value_type(typeof(model)), - JuMP.variable_ref_type(model)}) - ################################################################################ # MODEL COPYING ################################################################################ diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index e2dda56c..22f870a9 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -760,5 +760,4 @@ end test_CuttingPlanes_multiparameter() end - -end \ No newline at end of file +end From 882390e4abad398f35532f5ecdaef2839446ea37 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Tue, 21 Apr 2026 12:20:27 -0400 Subject: [PATCH 12/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 108 ++++++++------------------ 1 file changed, 34 insertions(+), 74 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 157ebddc..7ad421c7 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -255,7 +255,7 @@ function _build_flat_map( flat_map = Dict{InfiniteOpt.GeneralVariableRef, Any}() for (v, ws) in sub.fwd_map if length(ws) == 1 - flat_map[v] = ws[1] + flat_map[v] = only(ws) else vp = InfiniteOpt.parameter_refs(v) shape = Tuple(length(supports[p]) for p in vp) @@ -427,25 +427,31 @@ function DP.raw_M( end end model = JuMP.owner_model(first(keys(sub.fwd_map))) - return _aggregate_M_values(model, M_vals) + # Condense flat per-support values: scalar if uniform, else pf. + all(==(M_vals[1]), M_vals) && return M_vals[1] + prefs, supports = _collect_parameters(model) + grids = Tuple(supports[p] for p in prefs) + shape = Tuple(length(supports[p]) for p in prefs) + fn = Interpolations.linear_interpolation( + grids, reshape(M_vals, shape), + extrapolation_bc = Interpolations.Line()) + return _make_parameter_function(model, fn, prefs...) end ################################################################################ # TRANSCRIPTION HELPERS ################################################################################ -# Create a parameter function programmatically. Uses -# build_parameter_function + add_parameter_function (the lower-level -# API behind @parameter_function) since the macro doesn't support -# programmatic use. The closure wrapper handles non-Function callables -# like Interpolations.Extrapolation. Accepts any number of prefs via -# varargs: _make_parameter_function(m, f, t) for 1D, (m, f, t, x) for 2D. +# Replacement for @parameter_function in the case of using an interpolation. +# Example (1D interpolation): +# fn = Interpolations.linear_interpolation(grids, vals) +# pf = _make_parameter_function(model, fn, t) # returns a pf ref function _make_parameter_function( model::InfiniteOpt.InfiniteModel, fn, prefs::InfiniteOpt.GeneralVariableRef... ) f = fn isa Function ? fn : ((args...) -> fn(args...)) - pref_arg = length(prefs) == 1 ? prefs[1] : prefs + pref_arg = length(prefs) == 1 ? only(prefs) : prefs pfunc = InfiniteOpt.build_parameter_function(error, f, pref_arg) return InfiniteOpt.add_parameter_function(model, pfunc) end @@ -462,42 +468,6 @@ function _collect_parameters(model::InfiniteOpt.InfiniteModel) return prefs, supports end -# Condense flat per-support M values into a scalar or parameter function. -function _aggregate_M_values( - model::InfiniteOpt.InfiniteModel, - vals::AbstractVector{<:Real} - ) - if all(==(vals[1]), vals) - return vals[1] - end - prefs, supports = _collect_parameters(model) - return values_to_parameter_function(model, vals, prefs, supports) -end - -""" - values_to_parameter_function( - model, vals, prefs, supports - ) - -Interpolate flat per-support values into an InfiniteOpt parameter -function registered on `model`. Builds a grid from `supports`, -reshapes `vals`, fits a linear interpolation, and returns the -registered parameter function ref. -""" -function values_to_parameter_function( - model::InfiniteOpt.InfiniteModel, - vals::AbstractVector{<:Real}, - prefs::Union{Vector{InfiniteOpt.GeneralVariableRef}, - Tuple{Vararg{InfiniteOpt.GeneralVariableRef}}}, - supports::Dict{InfiniteOpt.GeneralVariableRef, Vector{Float64}} - ) - grids = Tuple(supports[p] for p in prefs) - shape = Tuple(length(supports[p]) for p in prefs) - nd = reshape(vals, shape) - fn = Interpolations.linear_interpolation(grids, nd, - extrapolation_bc = Interpolations.Line()) - return _make_parameter_function(model, fn, prefs...) -end ################################################################################ # CUTTING PLANES FOR INFINITEMODEL @@ -549,46 +519,36 @@ function DP.extract_solution(model::InfiniteOpt.InfiniteModel) return sol end -# Add an infinite-form cut to the InfiniteModel. Infinite variables get -# interpolated parameter function coefficients wrapped in integrals; -# finite variables contribute scalar terms. +# Add a flat-sum cut directly to the transformation backend, matching +# the SEP's unweighted Euclidean norm (Trespalacios & Grossmann 2016 +# Eq. 11 applied in the joint transcribed variable space). Then mark +# the backend as ready so the next optimize! reuses the cut-enhanced +# flat model without re-transcribing (which would wipe the cut). function DP.add_cut( model::InfiniteOpt.InfiniteModel, decision_vars::Vector{InfiniteOpt.GeneralVariableRef}, rBM_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}}, sep_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}} ) - prefs, sups = _collect_parameters(model) - inf_terms = Any[] - cut_scalar = zero(JuMP.GenericAffExpr{ - JuMP.value_type(typeof(model)), - InfiniteOpt.GeneralVariableRef}) + flat = InfiniteOpt.transformation_model(model) + cut_expr = zero(JuMP.GenericAffExpr{ + JuMP.value_type(typeof(flat)), + JuMP.variable_ref_type(flat)}) for var in decision_vars haskey(rBM_sol, var) || continue haskey(sep_sol, var) || continue - vprefs = InfiniteOpt.parameter_refs(var) - if isempty(vprefs) - xi = 2 * (sep_sol[var][1] - rBM_sol[var][1]) - cut_scalar += xi * (var - sep_sol[var][1]) - else - xi_vals = 2 .* (sep_sol[var] .- rBM_sol[var]) - sp_vals = sep_sol[var] - xi_pf = values_to_parameter_function( - model, xi_vals, vprefs, sups) - sp_pf = values_to_parameter_function( - model, sp_vals, vprefs, sups) - push!(inf_terms, xi_pf * var - xi_pf * sp_pf) - end - end - if !isempty(inf_terms) - inf_expr = JuMP.@expression(model, sum(inf_terms)) - for p in prefs - inf_expr = InfiniteOpt.integral(inf_expr, p) + rbm_vals = rBM_sol[var] + sep_vals = sep_sol[var] + tv = InfiniteOpt.transformation_variable(var) + flat_vars = tv isa AbstractArray ? vec(tv) : [tv] + for k in eachindex(flat_vars) + xi = 2 * (sep_vals[k] - rbm_vals[k]) + JuMP.add_to_expression!(cut_expr, xi, flat_vars[k]) + JuMP.add_to_expression!(cut_expr, -xi * sep_vals[k]) end - cut_scalar += inf_expr end - cref = JuMP.@constraint(model, cut_scalar >= 0) - push!(DP._reformulation_constraints(model), cref) + JuMP.@constraint(flat, cut_expr >= 0) + InfiniteOpt.set_transformation_backend_ready(model, true) return end From 778b598a4c4cc49dd85e385821bce9e79ca69636 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Tue, 21 Apr 2026 16:31:41 -0400 Subject: [PATCH 13/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 244 ++++++------------ src/mbm.jl | 10 +- .../InfiniteDisjunctiveProgramming.jl | 160 ++++++++---- 3 files changed, 194 insertions(+), 220 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 7ad421c7..72a38518 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -170,111 +170,34 @@ function DP.disaggregate_expression( return JuMP.@expression(model, sum(terms)) end -# Quadratic expression: handle parameter x parameter, parameter x variable, -# and variable x variable terms. -function DP.disaggregate_expression( - model::M, quad::JuMP.GenericQuadExpr, - bvref::Union{JuMP.AbstractVariableRef, JuMP.GenericAffExpr}, - method::DP._Hull - ) where {M <: InfiniteOpt.InfiniteModel} - # Affine part (uses InfiniteOpt override above) - new_expr = DP.disaggregate_expression(model, quad.aff, bvref, method) - ϵ = method.value - for (pair, coeff) in quad.terms - a_param = pair.a isa InfiniteOpt.GeneralVariableRef && - _is_parameter(pair.a) - b_param = pair.b isa InfiniteOpt.GeneralVariableRef && - _is_parameter(pair.b) - if a_param && b_param - # param × param: constant, scale by y - new_expr += coeff * pair.a * pair.b * bvref - elseif a_param - # param × var: perspective cancels y - db = method.disjunct_variables[pair.b, bvref] - new_expr += coeff * pair.a * db - elseif b_param - # var × param: perspective cancels y - da = method.disjunct_variables[pair.a, bvref] - new_expr += coeff * da * pair.b - else - # var × var: standard perspective - da = method.disjunct_variables[pair.a, bvref] - db = method.disjunct_variables[pair.b, bvref] - new_expr += coeff * da * db / ((1 - ϵ) * bvref + ϵ) - end - end - return new_expr -end - ################################################################################ # MBM FOR INFINITEMODEL ################################################################################ # Reuses the finite MBM infrastructure by overriding: -# copy_model_with_constraints (build mini InfiniteModel + -# transcribe to flat JuMP model), prepare_max_M_objective -# (expand infinite constraint into K flat objectives via -# _build_flat_map), and raw_M (vector dispatch aggregates -# K per-support M values into a parameter function). +# copy_model_with_constraints (build mini InfiniteModel, transcribe to +# flat JuMP, stash mini + main->mini ref_map in sub.model.ext), +# prepare_max_M_objective (translate main-model slack expr to mini-level +# then call InfiniteOpt.transformation_expression to get K flat +# objectives), and raw_M (vector dispatch aggregates K per-support M +# values into a parameter function). # Collect all parameter function refs from all disjunct constraints in # the model. -function _all_param_functions( - model::InfiniteOpt.InfiniteModel - ) - pf_set = Set{InfiniteOpt.GeneralVariableRef}() +function _all_param_functions(model::InfiniteOpt.InfiniteModel) + param_funcs = Set{InfiniteOpt.GeneralVariableRef}() for (_, crefs) in DP._indicator_to_constraints(model) for cref in crefs cref isa DP.DisjunctConstraintRef || continue con = JuMP.constraint_object(cref) for v in InfiniteOpt.all_expression_variables(con.func) - dv = InfiniteOpt.dispatch_variable_ref(v) - if dv isa InfiniteOpt.ParameterFunctionRef - push!(pf_set, v) + dispatch_var = InfiniteOpt.dispatch_variable_ref(v) + if dispatch_var isa InfiniteOpt.ParameterFunctionRef + push!(param_funcs, v) end end end end - return pf_set -end - -# Build a flat map for support point k. Maps decision variables to their -# flat JuMP.VariableRef at support k (handling multi-parameter indexing) -# and evaluates parameter functions to their numerical values. pf_set is -# precomputed by the caller to avoid rescanning all disjunct constraints -# on every support point. -function _build_flat_map( - sub::DP.GDPSubmodel, k::Int, - prefs::Vector{InfiniteOpt.GeneralVariableRef}, - supports::Dict{InfiniteOpt.GeneralVariableRef, Vector{Float64}}, - full_shape::Tuple, - pf_set::Set{InfiniteOpt.GeneralVariableRef} - ) - ci = CartesianIndices(full_shape)[k] - - # Decision variables: map each to its variable-local index - flat_map = Dict{InfiniteOpt.GeneralVariableRef, Any}() - for (v, ws) in sub.fwd_map - if length(ws) == 1 - flat_map[v] = only(ws) - else - vp = InfiniteOpt.parameter_refs(v) - shape = Tuple(length(supports[p]) for p in vp) - idx = Tuple(ci[findfirst(==(p), prefs)] for p in vp) - flat_map[v] = ws[LinearIndices(shape)[idx...]] - end - end - - # Parameter functions: evaluate at support point k - sup_vals = Dict( - prefs[i] => supports[prefs[i]][ci[i]] - for i in 1:length(prefs)) - for pf in pf_set - fn = InfiniteOpt.raw_function(pf) - pf_prefs = InfiniteOpt.parameter_refs(pf) - pf_vals = Tuple(sup_vals[p] for p in pf_prefs) - flat_map[pf] = fn(pf_vals...) - end - return flat_map + return param_funcs end # Build mini InfiniteModel with only the given disjunct constraints, @@ -291,10 +214,10 @@ function DP.copy_model_with_constraints( # 1. Copy infinite parameters with their supports for p in InfiniteOpt.all_parameters(model) domain = InfiniteOpt.infinite_domain(p) - sups = Float64.(InfiniteOpt.supports(p)) - param = InfiniteOpt.build_parameter(error, domain; supports = sups) - new_p = InfiniteOpt.add_parameter(mini, param, JuMP.name(p)) - ref_map[p] = new_p + supports = Float64.(InfiniteOpt.supports(p)) + param = InfiniteOpt.build_parameter(error, domain; supports = supports) + new_param = InfiniteOpt.add_parameter(mini, param, JuMP.name(p)) + ref_map[p] = new_param end # 2. Copy decision variables with bounds @@ -321,13 +244,13 @@ function DP.copy_model_with_constraints( # 4. Copy parameter functions from ALL disjuncts (needed for # constraint transcription) - pf_set = _all_param_functions(model) - for pf in pf_set - fn = InfiniteOpt.raw_function(pf) - prefs = InfiniteOpt.parameter_refs(pf) + param_funcs = _all_param_functions(model) + for pfunc in param_funcs + func = InfiniteOpt.raw_function(pfunc) + prefs = InfiniteOpt.parameter_refs(pfunc) mapped_prefs = Tuple(ref_map[p] for p in prefs) - new_pf = _make_parameter_function(mini, fn, mapped_prefs...) - ref_map[pf] = new_pf + new_pfunc = _make_parameter_function(mini, func, mapped_prefs...) + ref_map[pfunc] = new_pfunc end # 5. Add disjunct constraints using existing ref_map @@ -342,72 +265,51 @@ function DP.copy_model_with_constraints( # 6. Transcribe mini InfiniteModel to flat JuMP model InfiniteOpt.build_transformation_backend!(mini) flat = InfiniteOpt.transformation_model(mini) - tr_fwd = Dict{InfiniteOpt.GeneralVariableRef, - Vector{JuMP.VariableRef}}() - for v in DP.collect_all_vars(mini) - tv = InfiniteOpt.transformation_variable(v) - vprefs = InfiniteOpt.parameter_refs(v) - tr_fwd[v] = isempty(vprefs) ? [tv] : vec(tv) - end - - # 7. Remap fwd_map: original model var -> flat JuMP VarRef - fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() - for (orig, mapped) in ref_map - _is_parameter(orig) && continue - haskey(tr_fwd, mapped) || continue - fwd_map[orig] = tr_fwd[mapped] - end - - decision_vars = collect(keys(fwd_map)) JuMP.set_optimizer(flat, method.optimizer) JuMP.set_silent(flat) - return DP.GDPSubmodel(flat, decision_vars, fwd_map) + # Stash main + ref_map so prepare_max_M_objective can translate + # main-model expressions and let InfiniteOpt transcribe them via + # mini's backend. Also stash main so raw_M can return a parameter + # function on main (where it will be used in BigM constraints). + flat.ext[:inf_mbm_main] = model + flat.ext[:inf_mbm_ref_map] = ref_map + # fwd_map / decision_vars are CP-shaped fields on GDPSubmodel that + # the MBM path through our overrides does not consult; pass empty + # containers of the right types. + return DP.GDPSubmodel(flat, InfiniteOpt.GeneralVariableRef[], + Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}()) end -# Prepare objectives for all support points. Expands an infinite -# constraint into K flat objectives via _build_flat_map with -# multi-parameter indexing and parameter function evaluation. +# Translate the constraint slack to the mini InfiniteModel via ref_map, +# then use InfiniteOpt.transformation_expression to get one JuMP scalar +# (or plain Real, for pure-parameter slacks) per support point. function DP.prepare_max_M_objective( - model::InfiniteOpt.InfiniteModel, + ::InfiniteOpt.InfiniteModel, obj::JuMP.ScalarConstraint{T, S}, sub::DP.GDPSubmodel ) where {T, S <: _MOI.LessThan} - prefs, supports = _collect_parameters(model) - full_shape = Tuple(length(supports[p]) for p in prefs) - K = prod(full_shape) - pf_set = _all_param_functions(model) - objectives = Vector{JuMP.AbstractJuMPScalar}(undef, K) - for k in 1:K - flat_map = _build_flat_map(sub, k, prefs, supports, full_shape, pf_set) - objectives[k] = -obj.set.upper + - DP._replace_variables_in_constraint(obj.func, flat_map) - end - return objectives + ref_map = sub.model.ext[:inf_mbm_ref_map] + mini_expr = DP._replace_variables_in_constraint(obj.func, ref_map) + return InfiniteOpt.transformation_expression(mini_expr - obj.set.upper) end function DP.prepare_max_M_objective( - model::InfiniteOpt.InfiniteModel, + ::InfiniteOpt.InfiniteModel, obj::JuMP.ScalarConstraint{T, S}, sub::DP.GDPSubmodel ) where {T, S <: _MOI.GreaterThan} - prefs, supports = _collect_parameters(model) - full_shape = Tuple(length(supports[p]) for p in prefs) - K = prod(full_shape) - pf_set = _all_param_functions(model) - objectives = Vector{JuMP.AbstractJuMPScalar}(undef, K) - for k in 1:K - flat_map = _build_flat_map(sub, k, prefs, supports, full_shape, pf_set) - objectives[k] = obj.set.lower - - DP._replace_variables_in_constraint(obj.func, flat_map) - end - return objectives + ref_map = sub.model.ext[:inf_mbm_ref_map] + mini_expr = DP._replace_variables_in_constraint(obj.func, ref_map) + return InfiniteOpt.transformation_expression(obj.set.lower - mini_expr) end # Solve the submodel for a vector of objectives (one per support point). -# Returns aggregated M value (scalar or parameter function) or nothing. +# Elements may be Real when the slack is pure-parameter at that support; +# JuMP's @objective accepts Real and the solver treats it as a constant +# objective, which gives the correct M value for that slice. function DP.raw_M( sub::DP.GDPSubmodel, - objectives::Vector{<:JuMP.AbstractJuMPScalar}, + objectives::Vector{<:Union{Real, JuMP.AbstractJuMPScalar}}, method::DP._MBM ) M_vals = typeof(method.default_M)[] @@ -426,16 +328,15 @@ function DP.raw_M( push!(M_vals, method.default_M) end end - model = JuMP.owner_model(first(keys(sub.fwd_map))) - # Condense flat per-support values: scalar if uniform, else pf. + model = sub.model.ext[:inf_mbm_main] + # Condense per-support values: scalar if uniform, else pfunc. all(==(M_vals[1]), M_vals) && return M_vals[1] prefs, supports = _collect_parameters(model) grids = Tuple(supports[p] for p in prefs) shape = Tuple(length(supports[p]) for p in prefs) - fn = Interpolations.linear_interpolation( - grids, reshape(M_vals, shape), + func = Interpolations.linear_interpolation(grids, reshape(M_vals, shape), extrapolation_bc = Interpolations.Line()) - return _make_parameter_function(model, fn, prefs...) + return _make_parameter_function(model, func, prefs...) end ################################################################################ @@ -444,16 +345,17 @@ end # Replacement for @parameter_function in the case of using an interpolation. # Example (1D interpolation): -# fn = Interpolations.linear_interpolation(grids, vals) -# pf = _make_parameter_function(model, fn, t) # returns a pf ref +# func = Interpolations.linear_interpolation(grids, vals) +# pfunc = _make_parameter_function(model, func, t) # returns a pfunc ref function _make_parameter_function( - model::InfiniteOpt.InfiniteModel, fn, + model::InfiniteOpt.InfiniteModel, func, prefs::InfiniteOpt.GeneralVariableRef... ) - f = fn isa Function ? fn : ((args...) -> fn(args...)) + wrapped_func = func isa Function ? func : ((args...) -> func(args...)) pref_arg = length(prefs) == 1 ? only(prefs) : prefs - pfunc = InfiniteOpt.build_parameter_function(error, f, pref_arg) - return InfiniteOpt.add_parameter_function(model, pfunc) + builder = InfiniteOpt.build_parameter_function( + error, wrapped_func, pref_arg) + return InfiniteOpt.add_parameter_function(model, builder) end # Collect all infinite parameters and their supports from the model. @@ -484,19 +386,19 @@ function DP.copy_and_reformulate( DP.reformulate_model(model, reform_method) InfiniteOpt.build_transformation_backend!(model) flat = InfiniteOpt.transformation_model(model) - tr_fwd = Dict{InfiniteOpt.GeneralVariableRef, + transcription_fwd = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() for v in DP.collect_all_vars(model) - tv = InfiniteOpt.transformation_variable(v) - vprefs = InfiniteOpt.parameter_refs(v) - tr_fwd[v] = isempty(vprefs) ? [tv] : vec(tv) + transcription_var = InfiniteOpt.transformation_variable(v) + var_prefs = InfiniteOpt.parameter_refs(v) + transcription_fwd[v] = isempty(var_prefs) ? + [transcription_var] : vec(transcription_var) end sub_copy, copy_map = JuMP.copy_model(flat) - fwd_map = Dict{InfiniteOpt.GeneralVariableRef, - Vector{JuMP.VariableRef}}() + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() for v in decision_vars - haskey(tr_fwd, v) || continue - fwd_map[v] = [copy_map[tv] for tv in tr_fwd[v]] + haskey(transcription_fwd, v) || continue + fwd_map[v] = [copy_map[flat_var] for flat_var in transcription_fwd[v]] end sub = DP.GDPSubmodel(sub_copy, decision_vars, fwd_map) JuMP.set_optimizer(sub.model, method.optimizer) @@ -512,9 +414,10 @@ function DP.extract_solution(model::InfiniteOpt.InfiniteModel) T = JuMP.value_type(typeof(model)) sol = Dict{V, Vector{T}}() for v in dvars - tv = InfiniteOpt.transformation_variable(v) - vprefs = InfiniteOpt.parameter_refs(v) - sol[v] = isempty(vprefs) ? [JuMP.value(tv)] : JuMP.value.(vec(tv)) + transcription_var = InfiniteOpt.transformation_variable(v) + var_prefs = InfiniteOpt.parameter_refs(v) + sol[v] = isempty(var_prefs) ? [JuMP.value(transcription_var)] : + JuMP.value.(vec(transcription_var)) end return sol end @@ -539,8 +442,9 @@ function DP.add_cut( haskey(sep_sol, var) || continue rbm_vals = rBM_sol[var] sep_vals = sep_sol[var] - tv = InfiniteOpt.transformation_variable(var) - flat_vars = tv isa AbstractArray ? vec(tv) : [tv] + transcription_var = InfiniteOpt.transformation_variable(var) + flat_vars = transcription_var isa AbstractArray ? + vec(transcription_var) : [transcription_var] for k in eachindex(flat_vars) xi = 2 * (sep_vals[k] - rbm_vals[k]) JuMP.add_to_expression!(cut_expr, xi, flat_vars[k]) diff --git a/src/mbm.jl b/src/mbm.jl index 8d8fc1cf..4fd844c3 100644 --- a/src/mbm.jl +++ b/src/mbm.jl @@ -264,8 +264,14 @@ function prepare_max_M_objective( return expr end -# Solve the submodel for a single objective expression. -# Returns a scalar M value, or nothing if infeasible. +""" + raw_M(sub::GDPSubmodel, objective, method::_MBM) + +Maximize `objective` over `sub` to obtain one raw M value for MBM. +Returns `max(obj_value, 0)` on optimal, `nothing` on infeasible +(signals the constraint is redundant in the combined region), or +`method.default_M` otherwise (unbounded, numerical failure, etc). +""" function raw_M( sub::GDPSubmodel, objective::JuMP.AbstractJuMPScalar, diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 22f870a9..7f8495ea 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -77,9 +77,9 @@ function test__is_parameter() @test IDP._is_parameter(y) == false end -# _is_parameter on unwrapped concrete dispatch types. Covers -# ext lines 28-32 (DependentParameterRef, IndependentParameterRef, -# FiniteParameterRef, ParameterFunctionRef, Any fallback). +# _is_parameter on unwrapped concrete dispatch types +# (DependentParameterRef, IndependentParameterRef, FiniteParameterRef, +# ParameterFunctionRef, Any fallback). function test__is_parameter_concrete_dispatches() model = InfiniteGDPModel() @infinite_parameter(model, t ∈ [0, 1]) @@ -187,43 +187,6 @@ function test_disaggregate_expression_infiniteopt() @test haskey(result_not_disagg.terms, y) end -function test_disaggregate_quad_expression_infiniteopt() - model = InfiniteGDPModel() - @infinite_parameter(model, t ∈ [0, 1]) - @variable(model, 0 <= x <= 10, Infinite(t)) - @variable(model, 0 <= y <= 5, Infinite(t)) - @variable(model, z, InfiniteLogical(t)) - - bvrefs = DP._indicator_to_binary(model) - bvref = bvrefs[z] - - vrefs = Set([x, y]) - DP._variable_bounds(model)[x] = DP.set_variable_bound_info(x, Hull()) - DP._variable_bounds(model)[y] = DP.set_variable_bound_info(y, Hull()) - method = DP._Hull(Hull(1e-3), vrefs) - DP._disaggregate_variables(model, z, vrefs, method) - - # var × var → nonlinear (perspective divides by y) - quad_vv = @expression(model, x * y) - result_vv = DP.disaggregate_expression(model, quad_vv, bvref, method) - @test result_vv isa JuMP.GenericNonlinearExpr - - # param × var → quadratic (param * disaggregated) - quad_pv = @expression(model, t * x) - result_pv = DP.disaggregate_expression(model, quad_pv, bvref, method) - @test result_pv isa JuMP.GenericQuadExpr - - # var × param → quadratic (disaggregated * param) - quad_vp = @expression(model, x * t) - result_vp = DP.disaggregate_expression(model, quad_vp, bvref, method) - @test result_vp isa JuMP.GenericQuadExpr - - # param × param → cubic (t * t * bvref) - quad_pp = @expression(model, t * t) - result_pp = DP.disaggregate_expression(model, quad_pp, bvref, method) - @test result_pp isa JuMP.GenericNonlinearExpr -end - function test_variable_properties_infiniteopt() model = InfiniteGDPModel() @infinite_parameter(model, t ∈ [0, 1]) @@ -392,15 +355,113 @@ function test_logical_value() end # _collect_parameters on model with no infinite parameters. -# Covers ext line 508. function test__collect_parameters_no_params() model = InfiniteGDPModel() @test_throws ErrorException IDP._collect_parameters(model) end -# MBM with finite + integer variables in InfiniteModel. Covers -# copy_model_with_constraints (finite var, set_integer), -# and _build_flat_map line 252 (finite var path). +# raw_M against an InfiniteModel where M is constant across supports. +# Setup: x(t) ∈ [0, 10], disj1: x ≥ 5, disj2: x ≤ 3. +# For disj1 slack r(x) = 5 - x maximized over disj2's region x ∈ [0, 3]: +# max(5 - x) = 5 at x = 0. Same at every support ⇒ scalar M = 5. +function test_raw_M_infinite_scalar() + model = InfiniteGDPModel() + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, con, x >= 5, Disjunct(Y[1])) + @constraint(model, con2, x <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + mbm = DP._MBM(MBM(HiGHS.Optimizer), model) + sub = DP.copy_model_with_constraints( + model, DP.DisjunctConstraintRef[con2], mbm) + obj = DP.prepare_max_M_objective( + model, JuMP.constraint_object(con), sub) + @test length(obj) == 5 # K support points + @test DP.raw_M(sub, obj, mbm) == 5.0 +end + +# raw_M with a support-varying M. Setup: x(t) ∈ [0, 10], disj1: x ≤ 2t, +# disj2: x ≥ 0.5. Slack r(x) = x - 2t maximized over x ∈ [0.5, 10]: +# max(x - 2t) = 10 - 2t. Varies with t ⇒ raw_M returns a pfunc; the +# underlying function should evaluate to 10 - 2t at each support. +function test_raw_M_infinite_param_function() + model = InfiniteGDPModel() + supports = [0.0, 0.25, 0.5, 0.75, 1.0] + @infinite_parameter(model, t ∈ [0, 1], supports = supports) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @parameter_function(model, f == t -> 2*t) + @constraint(model, con, x <= f, Disjunct(Y[1])) + @constraint(model, con2, x >= 0.5, Disjunct(Y[2])) + @disjunction(model, Y) + mbm = DP._MBM(MBM(HiGHS.Optimizer), model) + sub = DP.copy_model_with_constraints( + model, DP.DisjunctConstraintRef[con2], mbm) + obj = DP.prepare_max_M_objective( + model, JuMP.constraint_object(con), sub) + M = DP.raw_M(sub, obj, mbm) + @test M isa InfiniteOpt.GeneralVariableRef + raw_fn = InfiniteOpt.raw_function(M) + for t_val in supports + @test raw_fn(t_val) ≈ 10.0 - 2*t_val atol=1e-6 + end +end + +# extract_solution returns per-support values from the transformation +# backend. Setup: force disj 2 active (x ≤ 3), BigM-reformulate, solve +# min ∫x ⇒ x = 0 at every support. +function test_extract_solution_infinite() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + K = 4 + @infinite_parameter(model, t ∈ [0, 1], num_supports = K) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x >= 5, Disjunct(Y[1])) + @constraint(model, x <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + JuMP.fix(Y[2], true) # force disj 2 active + @objective(model, Min, ∫(x, t)) + DP.reformulate_model(model, BigM(10.0)) + set_optimizer(model, HiGHS.Optimizer) + set_silent(model) + optimize!(model, ignore_optimize_hook = true) + sol = DP.extract_solution(model) + @test haskey(sol, x) + @test length(sol[x]) == K + @test all(v -> isapprox(v, 0.0; atol=1e-6), sol[x]) +end + +# add_cut adds one flat-sum cut to the transformation backend and marks +# the backend ready so the next optimize! does NOT re-transcribe. +function test_add_cut_infinite() + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + K = 3 + @infinite_parameter(model, t ∈ [0, 1], num_supports = K) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x >= 5, Disjunct(Y[1])) + @constraint(model, x <= 3, Disjunct(Y[2])) + @disjunction(model, Y) + DP.reformulate_model(model, BigM(10.0)) + InfiniteOpt.build_transformation_backend!(model) + flat = InfiniteOpt.transformation_model(model) + n_before = JuMP.num_constraints(flat; + count_variable_in_set_constraints = false) + rBM_sol = Dict(x => [1.0, 2.0, 3.0]) + sep_sol = Dict(x => [0.5, 1.5, 2.5]) + DP.add_cut(model, [x], rBM_sol, sep_sol) + n_after = JuMP.num_constraints(flat; + count_variable_in_set_constraints = false) + @test n_after == n_before + 1 + # set_transformation_backend_ready(true) — next optimize! should + # reuse without re-transcribing (otherwise our cut would be lost) + @test InfiniteOpt.transformation_backend_ready(model) +end + +# MBM with finite + integer variables in an InfiniteModel. function test_mbm_finite_and_integer_var() model = InfiniteGDPModel(HiGHS.Optimizer) set_silent(model) @@ -651,9 +712,9 @@ end function test_CuttingPlanes_with_cuts() # Maximization with single-constraint disjuncts where Hull # is strictly tighter than BigM. BigM allows x+y up to - # variable bounds (20), Hull limits to max(5,8)=8. This - # forces cuts. Finite var w exercises isempty(vprefs) - # branch in add_original_model_cut (line 779). + # variable bounds (20), Hull limits to max(5,8)=8 — this + # forces cuts to tighten the relaxation. Finite var w exercises + # the isempty(var_prefs) branch in add_cut. model = InfiniteGDPModel(HiGHS.Optimizer) set_silent(model) @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) @@ -733,7 +794,6 @@ end @testset "Methods" begin test_get_constant() test_disaggregate_expression_infiniteopt() - test_disaggregate_quad_expression_infiniteopt() end @testset "Internal Helpers" begin @@ -741,6 +801,8 @@ end end @testset "MBM" begin + test_raw_M_infinite_scalar() + test_raw_M_infinite_param_function() test_mbm_finite_and_integer_var() test_mbm_infinite_simple() test_mbm_infinite_param_dependent() @@ -754,6 +816,8 @@ end end @testset "Cutting Planes" begin + test_extract_solution_infinite() + test_add_cut_infinite() test_CuttingPlanes_infinite_simple() test_CuttingPlanes_infinite_two_disj() test_CuttingPlanes_with_cuts() From 2c0071a36805afc52f36de975de0e3881247b71d Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Tue, 21 Apr 2026 18:35:47 -0400 Subject: [PATCH 14/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 72a38518..7982b192 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -303,10 +303,15 @@ function DP.prepare_max_M_objective( return InfiniteOpt.transformation_expression(obj.set.lower - mini_expr) end +# Real dispatch: pure-parameter slacks collapse to a constant at that +# support. Mirrors the max(value, 0) semantics of the scalar base. +DP.raw_M(::DP.GDPSubmodel, obj::Real, method::DP._MBM) = + max(obj, zero(method.default_M)) + # Solve the submodel for a vector of objectives (one per support point). -# Elements may be Real when the slack is pure-parameter at that support; -# JuMP's @objective accepts Real and the solver treats it as a constant -# objective, which gives the correct M value for that slice. +# Clears start values before each solve (Gurobi refuses NaN warmstarts +# that can linger from a prior unbounded solve) and delegates each +# element to the scalar base `raw_M` above. function DP.raw_M( sub::DP.GDPSubmodel, objectives::Vector{<:Union{Real, JuMP.AbstractJuMPScalar}}, @@ -315,18 +320,9 @@ function DP.raw_M( M_vals = typeof(method.default_M)[] for obj_expr in objectives JuMP.set_start_value.(JuMP.all_variables(sub.model), nothing) - JuMP.@objective(sub.model, Max, obj_expr) - JuMP.optimize!(sub.model) - if JuMP.is_solved_and_feasible(sub.model) - push!(M_vals, max( - JuMP.objective_value(sub.model), - zero(method.default_M))) - elseif JuMP.termination_status(sub.model) == - JuMP.MOI.INFEASIBLE - return nothing - else - push!(M_vals, method.default_M) - end + m = DP.raw_M(sub, obj_expr, method) + m === nothing && return nothing + push!(M_vals, m) end model = sub.model.ext[:inf_mbm_main] # Condense per-support values: scalar if uniform, else pfunc. From a77b98833af2fa3476998da3d6fb56c8e3558732 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Tue, 21 Apr 2026 20:59:25 -0400 Subject: [PATCH 15/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 19 ++++++----- .../InfiniteDisjunctiveProgramming.jl | 32 ++++++++++++------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 7982b192..631875bb 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -282,7 +282,9 @@ end # Translate the constraint slack to the mini InfiniteModel via ref_map, # then use InfiniteOpt.transformation_expression to get one JuMP scalar -# (or plain Real, for pure-parameter slacks) per support point. +# per support point. Narrows the declared Vector{Union{Real, …}} return +# to Vector{AbstractJuMPScalar}; errors loudly if a support gives a +# pure-constant slack (would require a degenerate disjunct constraint). function DP.prepare_max_M_objective( ::InfiniteOpt.InfiniteModel, obj::JuMP.ScalarConstraint{T, S}, @@ -290,7 +292,8 @@ function DP.prepare_max_M_objective( ) where {T, S <: _MOI.LessThan} ref_map = sub.model.ext[:inf_mbm_ref_map] mini_expr = DP._replace_variables_in_constraint(obj.func, ref_map) - return InfiniteOpt.transformation_expression(mini_expr - obj.set.upper) + return Vector{JuMP.AbstractJuMPScalar}( + InfiniteOpt.transformation_expression(mini_expr - obj.set.upper)) end function DP.prepare_max_M_objective( @@ -300,21 +303,17 @@ function DP.prepare_max_M_objective( ) where {T, S <: _MOI.GreaterThan} ref_map = sub.model.ext[:inf_mbm_ref_map] mini_expr = DP._replace_variables_in_constraint(obj.func, ref_map) - return InfiniteOpt.transformation_expression(obj.set.lower - mini_expr) + return Vector{JuMP.AbstractJuMPScalar}( + InfiniteOpt.transformation_expression(obj.set.lower - mini_expr)) end -# Real dispatch: pure-parameter slacks collapse to a constant at that -# support. Mirrors the max(value, 0) semantics of the scalar base. -DP.raw_M(::DP.GDPSubmodel, obj::Real, method::DP._MBM) = - max(obj, zero(method.default_M)) - # Solve the submodel for a vector of objectives (one per support point). # Clears start values before each solve (Gurobi refuses NaN warmstarts # that can linger from a prior unbounded solve) and delegates each -# element to the scalar base `raw_M` above. +# element to the scalar base `raw_M`. function DP.raw_M( sub::DP.GDPSubmodel, - objectives::Vector{<:Union{Real, JuMP.AbstractJuMPScalar}}, + objectives::Vector{<:JuMP.AbstractJuMPScalar}, method::DP._MBM ) M_vals = typeof(method.default_M)[] diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 7f8495ea..e7fed04d 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -55,7 +55,7 @@ function test_infinite_logical() @test binary_variable(y) isa InfiniteOpt.GeneralVariableRef end -function test__is_parameter() +function test_is_parameter() model = InfiniteGDPModel() @infinite_parameter(model, t ∈ [0, 1]) @infinite_parameter(model, s[1:2] ∈ [0, 1], independent = true) @@ -80,19 +80,29 @@ end # _is_parameter on unwrapped concrete dispatch types # (DependentParameterRef, IndependentParameterRef, FiniteParameterRef, # ParameterFunctionRef, Any fallback). -function test__is_parameter_concrete_dispatches() +function test_is_parameter_concrete_dispatches() model = InfiniteGDPModel() + # Scalar + `independent = true` array both give IndependentParameterRef; + # a default array parameter gives DependentParameterRef. @infinite_parameter(model, t ∈ [0, 1]) @infinite_parameter(model, s[1:2] ∈ [0, 1], independent = true) + @infinite_parameter(model, q[1:2] ∈ [0, 1]) @finite_parameter(model, p == 1.0) @variable(model, x, Infinite(t)) @parameter_function(model, pf == t -> 2*t) dvr = InfiniteOpt.dispatch_variable_ref - @test IDP._is_parameter(dvr(t)) == true # Dependent - @test IDP._is_parameter(dvr(s[1])) == true # Independent - @test IDP._is_parameter(dvr(p)) == true # Finite - @test IDP._is_parameter(dvr(pf)) == true # ParamFunc - @test IDP._is_parameter(dvr(x)) == false # Any + # Verify each ref hits the intended dispatch. + @test dvr(t) isa InfiniteOpt.IndependentParameterRef + @test dvr(s[1]) isa InfiniteOpt.IndependentParameterRef + @test dvr(q[1]) isa InfiniteOpt.DependentParameterRef + @test dvr(p) isa InfiniteOpt.FiniteParameterRef + @test dvr(pf) isa InfiniteOpt.ParameterFunctionRef + @test IDP._is_parameter(dvr(t)) == true + @test IDP._is_parameter(dvr(s[1])) == true + @test IDP._is_parameter(dvr(q[1])) == true + @test IDP._is_parameter(dvr(p)) == true + @test IDP._is_parameter(dvr(pf)) == true + @test IDP._is_parameter(dvr(x)) == false # Any fallback end function test_requires_disaggregation() @@ -355,7 +365,7 @@ function test_logical_value() end # _collect_parameters on model with no infinite parameters. -function test__collect_parameters_no_params() +function test_collect_parameters_no_params() model = InfiniteGDPModel() @test_throws ErrorException IDP._collect_parameters(model) end @@ -769,8 +779,8 @@ end @testset "Variables" begin test_infinite_logical() - test__is_parameter() - test__is_parameter_concrete_dispatches() + test_is_parameter() + test_is_parameter_concrete_dispatches() test_requires_disaggregation() test_variable_properties_infiniteopt() test_variable_properties_from_expr() @@ -797,7 +807,7 @@ end end @testset "Internal Helpers" begin - test__collect_parameters_no_params() + test_collect_parameters_no_params() end @testset "MBM" begin From 1ec83f4f8a789f9a5afd22aa399c4834278afe75 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Tue, 21 Apr 2026 22:46:02 -0400 Subject: [PATCH 16/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 124 +++++------------- .../InfiniteDisjunctiveProgramming.jl | 20 +-- 2 files changed, 39 insertions(+), 105 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 631875bb..4a7f795a 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -173,35 +173,9 @@ end ################################################################################ # MBM FOR INFINITEMODEL ################################################################################ -# Reuses the finite MBM infrastructure by overriding: -# copy_model_with_constraints (build mini InfiniteModel, transcribe to -# flat JuMP, stash mini + main->mini ref_map in sub.model.ext), -# prepare_max_M_objective (translate main-model slack expr to mini-level -# then call InfiniteOpt.transformation_expression to get K flat -# objectives), and raw_M (vector dispatch aggregates K per-support M -# values into a parameter function). - -# Collect all parameter function refs from all disjunct constraints in -# the model. -function _all_param_functions(model::InfiniteOpt.InfiniteModel) - param_funcs = Set{InfiniteOpt.GeneralVariableRef}() - for (_, crefs) in DP._indicator_to_constraints(model) - for cref in crefs - cref isa DP.DisjunctConstraintRef || continue - con = JuMP.constraint_object(cref) - for v in InfiniteOpt.all_expression_variables(con.func) - dispatch_var = InfiniteOpt.dispatch_variable_ref(v) - if dispatch_var isa InfiniteOpt.ParameterFunctionRef - push!(param_funcs, v) - end - end - end - end - return param_funcs -end -# Build mini InfiniteModel with only the given disjunct constraints, -# transcribe to flat JuMP model, return GDPSubmodel with forward map. +# Build a mini InfiniteModel holding only the given disjunct constraints, +# transcribe it, and return as a GDPSubmodel. function DP.copy_model_with_constraints( model::InfiniteOpt.InfiniteModel, constraints::Vector{<:DP.DisjunctConstraintRef}, @@ -242,10 +216,8 @@ function DP.copy_model_with_constraints( ref_map[d] = new_d end - # 4. Copy parameter functions from ALL disjuncts (needed for - # constraint transcription) - param_funcs = _all_param_functions(model) - for pfunc in param_funcs + # 4. Copy parameter functions (needed by ref_map substitution) + for pfunc in InfiniteOpt.all_parameter_functions(model) func = InfiniteOpt.raw_function(pfunc) prefs = InfiniteOpt.parameter_refs(pfunc) mapped_prefs = Tuple(ref_map[p] for p in prefs) @@ -262,29 +234,20 @@ function DP.copy_model_with_constraints( JuMP.@constraint(mini, new_func * T in con.set) end - # 6. Transcribe mini InfiniteModel to flat JuMP model + # 6. Transcribe mini InfiniteModel InfiniteOpt.build_transformation_backend!(mini) - flat = InfiniteOpt.transformation_model(mini) - JuMP.set_optimizer(flat, method.optimizer) - JuMP.set_silent(flat) - # Stash main + ref_map so prepare_max_M_objective can translate - # main-model expressions and let InfiniteOpt transcribe them via - # mini's backend. Also stash main so raw_M can return a parameter - # function on main (where it will be used in BigM constraints). - flat.ext[:inf_mbm_main] = model - flat.ext[:inf_mbm_ref_map] = ref_map - # fwd_map / decision_vars are CP-shaped fields on GDPSubmodel that - # the MBM path through our overrides does not consult; pass empty - # containers of the right types. - return DP.GDPSubmodel(flat, InfiniteOpt.GeneralVariableRef[], + transcribed = InfiniteOpt.transformation_model(mini) + JuMP.set_optimizer(transcribed, method.optimizer) + JuMP.set_silent(transcribed) + # Stash for prepare_max_M_objective / raw_M. + transcribed.ext[:inf_mbm_main] = model + transcribed.ext[:inf_mbm_ref_map] = ref_map + # GDPSubmodel's fwd_map / decision_vars are CP-only; unused here. + return DP.GDPSubmodel(transcribed, InfiniteOpt.GeneralVariableRef[], Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}()) end -# Translate the constraint slack to the mini InfiniteModel via ref_map, -# then use InfiniteOpt.transformation_expression to get one JuMP scalar -# per support point. Narrows the declared Vector{Union{Real, …}} return -# to Vector{AbstractJuMPScalar}; errors loudly if a support gives a -# pure-constant slack (would require a degenerate disjunct constraint). +# Return one pointwise slack per support. function DP.prepare_max_M_objective( ::InfiniteOpt.InfiniteModel, obj::JuMP.ScalarConstraint{T, S}, @@ -307,10 +270,8 @@ function DP.prepare_max_M_objective( InfiniteOpt.transformation_expression(obj.set.lower - mini_expr)) end -# Solve the submodel for a vector of objectives (one per support point). -# Clears start values before each solve (Gurobi refuses NaN warmstarts -# that can linger from a prior unbounded solve) and delegates each -# element to the scalar base `raw_M`. +# Per-support solve, delegating to scalar base raw_M. Aggregated to a +# scalar if uniform, else to a parameter function. function DP.raw_M( sub::DP.GDPSubmodel, objectives::Vector{<:JuMP.AbstractJuMPScalar}, @@ -326,9 +287,9 @@ function DP.raw_M( model = sub.model.ext[:inf_mbm_main] # Condense per-support values: scalar if uniform, else pfunc. all(==(M_vals[1]), M_vals) && return M_vals[1] - prefs, supports = _collect_parameters(model) - grids = Tuple(supports[p] for p in prefs) - shape = Tuple(length(supports[p]) for p in prefs) + prefs = InfiniteOpt.all_parameters(model) + grids = Tuple(Float64.(InfiniteOpt.supports(p)) for p in prefs) + shape = Tuple(length.(grids)) func = Interpolations.linear_interpolation(grids, reshape(M_vals, shape), extrapolation_bc = Interpolations.Line()) return _make_parameter_function(model, func, prefs...) @@ -353,25 +314,12 @@ function _make_parameter_function( return InfiniteOpt.add_parameter_function(model, builder) end -# Collect all infinite parameters and their supports from the model. -function _collect_parameters(model::InfiniteOpt.InfiniteModel) - params = collect(InfiniteOpt.all_parameters(model)) - if isempty(params) - error("Model has no infinite parameters.") - end - prefs = InfiniteOpt.GeneralVariableRef[p for p in params] - supports = Dict{InfiniteOpt.GeneralVariableRef, Vector{Float64}}( - p => Float64.(InfiniteOpt.supports(p)) for p in prefs) - return prefs, supports -end - - ################################################################################ # CUTTING PLANES FOR INFINITEMODEL ################################################################################ -# Build CP subproblem: reformulate the InfiniteModel in-place, transcribe -# to a flat JuMP copy, and wrap in GDPSubmodel with forward variable map. +# Build CP subproblem: reformulate the InfiniteModel in-place, transcribe, +# copy, and wrap in GDPSubmodel with forward variable map. function DP.copy_and_reformulate( model::InfiniteOpt.InfiniteModel, decision_vars::Vector{InfiniteOpt.GeneralVariableRef}, @@ -380,7 +328,7 @@ function DP.copy_and_reformulate( ) DP.reformulate_model(model, reform_method) InfiniteOpt.build_transformation_backend!(model) - flat = InfiniteOpt.transformation_model(model) + transcribed = InfiniteOpt.transformation_model(model) transcription_fwd = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() for v in DP.collect_all_vars(model) @@ -389,11 +337,11 @@ function DP.copy_and_reformulate( transcription_fwd[v] = isempty(var_prefs) ? [transcription_var] : vec(transcription_var) end - sub_copy, copy_map = JuMP.copy_model(flat) + sub_copy, copy_map = JuMP.copy_model(transcribed) fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() for v in decision_vars haskey(transcription_fwd, v) || continue - fwd_map[v] = [copy_map[flat_var] for flat_var in transcription_fwd[v]] + fwd_map[v] = [copy_map[transcribed_var] for transcribed_var in transcription_fwd[v]] end sub = DP.GDPSubmodel(sub_copy, decision_vars, fwd_map) JuMP.set_optimizer(sub.model, method.optimizer) @@ -401,8 +349,7 @@ function DP.copy_and_reformulate( return sub end -# Extract per-support-point solutions from the InfiniteOpt transformation -# backend after optimize!(model, ignore_optimize_hook=true). +# Read per-support values from the transformation backend. function DP.extract_solution(model::InfiniteOpt.InfiniteModel) dvars = DP.collect_cutting_planes_vars(model) V = eltype(dvars) @@ -417,36 +364,33 @@ function DP.extract_solution(model::InfiniteOpt.InfiniteModel) return sol end -# Add a flat-sum cut directly to the transformation backend, matching -# the SEP's unweighted Euclidean norm (Trespalacios & Grossmann 2016 -# Eq. 11 applied in the joint transcribed variable space). Then mark -# the backend as ready so the next optimize! reuses the cut-enhanced -# flat model without re-transcribing (which would wipe the cut). +# Add a pointwise-sum cut directly to the transformation backend and mark +# it ready so the next optimize! doesn't re-transcribe and wipe the cut. function DP.add_cut( model::InfiniteOpt.InfiniteModel, decision_vars::Vector{InfiniteOpt.GeneralVariableRef}, rBM_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}}, sep_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}} ) - flat = InfiniteOpt.transformation_model(model) + transcribed = InfiniteOpt.transformation_model(model) cut_expr = zero(JuMP.GenericAffExpr{ - JuMP.value_type(typeof(flat)), - JuMP.variable_ref_type(flat)}) + JuMP.value_type(typeof(transcribed)), + JuMP.variable_ref_type(transcribed)}) for var in decision_vars haskey(rBM_sol, var) || continue haskey(sep_sol, var) || continue rbm_vals = rBM_sol[var] sep_vals = sep_sol[var] transcription_var = InfiniteOpt.transformation_variable(var) - flat_vars = transcription_var isa AbstractArray ? + transcribed_vars = transcription_var isa AbstractArray ? vec(transcription_var) : [transcription_var] - for k in eachindex(flat_vars) + for k in eachindex(transcribed_vars) xi = 2 * (sep_vals[k] - rbm_vals[k]) - JuMP.add_to_expression!(cut_expr, xi, flat_vars[k]) + JuMP.add_to_expression!(cut_expr, xi, transcribed_vars[k]) JuMP.add_to_expression!(cut_expr, -xi * sep_vals[k]) end end - JuMP.@constraint(flat, cut_expr >= 0) + JuMP.@constraint(transcribed, cut_expr >= 0) InfiniteOpt.set_transformation_backend_ready(model, true) return end diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index e7fed04d..42ac0914 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -364,12 +364,6 @@ function test_logical_value() @test eltype(val) == Bool end -# _collect_parameters on model with no infinite parameters. -function test_collect_parameters_no_params() - model = InfiniteGDPModel() - @test_throws ErrorException IDP._collect_parameters(model) -end - # raw_M against an InfiniteModel where M is constant across supports. # Setup: x(t) ∈ [0, 10], disj1: x ≥ 5, disj2: x ≤ 3. # For disj1 slack r(x) = 5 - x maximized over disj2's region x ∈ [0, 3]: @@ -443,8 +437,8 @@ function test_extract_solution_infinite() @test all(v -> isapprox(v, 0.0; atol=1e-6), sol[x]) end -# add_cut adds one flat-sum cut to the transformation backend and marks -# the backend ready so the next optimize! does NOT re-transcribe. +# add_cut adds one pointwise-sum cut to the transformation backend and +# marks the backend ready so the next optimize! does NOT re-transcribe. function test_add_cut_infinite() model = InfiniteGDPModel(HiGHS.Optimizer) set_silent(model) @@ -457,13 +451,13 @@ function test_add_cut_infinite() @disjunction(model, Y) DP.reformulate_model(model, BigM(10.0)) InfiniteOpt.build_transformation_backend!(model) - flat = InfiniteOpt.transformation_model(model) - n_before = JuMP.num_constraints(flat; + transcribed = InfiniteOpt.transformation_model(model) + n_before = JuMP.num_constraints(transcribed; count_variable_in_set_constraints = false) rBM_sol = Dict(x => [1.0, 2.0, 3.0]) sep_sol = Dict(x => [0.5, 1.5, 2.5]) DP.add_cut(model, [x], rBM_sol, sep_sol) - n_after = JuMP.num_constraints(flat; + n_after = JuMP.num_constraints(transcribed; count_variable_in_set_constraints = false) @test n_after == n_before + 1 # set_transformation_backend_ready(true) — next optimize! should @@ -806,10 +800,6 @@ end test_disaggregate_expression_infiniteopt() end - @testset "Internal Helpers" begin - test_collect_parameters_no_params() - end - @testset "MBM" begin test_raw_M_infinite_scalar() test_raw_M_infinite_param_function() From f863f15622e669945cbdcb648cfeab2227eabff6 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Wed, 22 Apr 2026 09:08:08 -0400 Subject: [PATCH 17/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 1 - 1 file changed, 1 deletion(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 4a7f795a..1804f763 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -247,7 +247,6 @@ function DP.copy_model_with_constraints( Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}()) end -# Return one pointwise slack per support. function DP.prepare_max_M_objective( ::InfiniteOpt.InfiniteModel, obj::JuMP.ScalarConstraint{T, S}, From 350fda3ea0761eeee4392ac71b6d7271507892e8 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Fri, 24 Apr 2026 19:28:14 -0400 Subject: [PATCH 18/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 69 +++++++++++---------------- 1 file changed, 28 insertions(+), 41 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 1804f763..c09be8a4 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -221,8 +221,12 @@ function DP.copy_model_with_constraints( func = InfiniteOpt.raw_function(pfunc) prefs = InfiniteOpt.parameter_refs(pfunc) mapped_prefs = Tuple(ref_map[p] for p in prefs) - new_pfunc = _make_parameter_function(mini, func, mapped_prefs...) - ref_map[pfunc] = new_pfunc + pref_arg = length(mapped_prefs) == 1 ? + only(mapped_prefs) : mapped_prefs + param_func = InfiniteOpt.build_parameter_function( + error, func, pref_arg) + ref_map[pfunc] = InfiniteOpt.add_parameter_function( + mini, param_func) end # 5. Add disjunct constraints using existing ref_map @@ -253,9 +257,10 @@ function DP.prepare_max_M_objective( sub::DP.GDPSubmodel ) where {T, S <: _MOI.LessThan} ref_map = sub.model.ext[:inf_mbm_ref_map] - mini_expr = DP._replace_variables_in_constraint(obj.func, ref_map) - return Vector{JuMP.AbstractJuMPScalar}( - InfiniteOpt.transformation_expression(mini_expr - obj.set.upper)) + mini_expr = DP._replace_variables_in_constraint( + obj.func, ref_map) - obj.set.upper + sub.model.ext[:inf_mbm_obj_expr] = obj.func + return InfiniteOpt.transformation_expression(mini_expr) end function DP.prepare_max_M_objective( @@ -264,53 +269,35 @@ function DP.prepare_max_M_objective( sub::DP.GDPSubmodel ) where {T, S <: _MOI.GreaterThan} ref_map = sub.model.ext[:inf_mbm_ref_map] - mini_expr = DP._replace_variables_in_constraint(obj.func, ref_map) - return Vector{JuMP.AbstractJuMPScalar}( - InfiniteOpt.transformation_expression(obj.set.lower - mini_expr)) + mini_expr = obj.set.lower - DP._replace_variables_in_constraint( + obj.func, ref_map) + sub.model.ext[:inf_mbm_obj_expr] = obj.func + return InfiniteOpt.transformation_expression(mini_expr) end # Per-support solve, delegating to scalar base raw_M. Aggregated to a # scalar if uniform, else to a parameter function. function DP.raw_M( sub::DP.GDPSubmodel, - objectives::Vector{<:JuMP.AbstractJuMPScalar}, + objectives::AbstractArray{<:Union{JuMP.AbstractJuMPScalar, Real}}, method::DP._MBM ) - M_vals = typeof(method.default_M)[] - for obj_expr in objectives + M_vals = similar(objectives, typeof(method.default_M)) + for I in eachindex(objectives) JuMP.set_start_value.(JuMP.all_variables(sub.model), nothing) - m = DP.raw_M(sub, obj_expr, method) + m = DP.raw_M(sub, objectives[I], method) m === nothing && return nothing - push!(M_vals, m) + M_vals[I] = m end - model = sub.model.ext[:inf_mbm_main] - # Condense per-support values: scalar if uniform, else pfunc. - all(==(M_vals[1]), M_vals) && return M_vals[1] - prefs = InfiniteOpt.all_parameters(model) - grids = Tuple(Float64.(InfiniteOpt.supports(p)) for p in prefs) - shape = Tuple(length.(grids)) - func = Interpolations.linear_interpolation(grids, reshape(M_vals, shape), - extrapolation_bc = Interpolations.Line()) - return _make_parameter_function(model, func, prefs...) -end - -################################################################################ -# TRANSCRIPTION HELPERS -################################################################################ - -# Replacement for @parameter_function in the case of using an interpolation. -# Example (1D interpolation): -# func = Interpolations.linear_interpolation(grids, vals) -# pfunc = _make_parameter_function(model, func, t) # returns a pfunc ref -function _make_parameter_function( - model::InfiniteOpt.InfiniteModel, func, - prefs::InfiniteOpt.GeneralVariableRef... - ) - wrapped_func = func isa Function ? func : ((args...) -> func(args...)) - pref_arg = length(prefs) == 1 ? only(prefs) : prefs - builder = InfiniteOpt.build_parameter_function( - error, wrapped_func, pref_arg) - return InfiniteOpt.add_parameter_function(model, builder) + all(==(first(M_vals)), M_vals) && return first(M_vals) + main = sub.model.ext[:inf_mbm_main] + expr = sub.model.ext[:inf_mbm_obj_expr] + prefs = InfiniteOpt.parameter_refs(expr) + grids = Tuple(InfiniteOpt.supports(p) for p in prefs) + interp = Interpolations.linear_interpolation(grids, M_vals) + param_func = InfiniteOpt.build_parameter_function( + error, (args...) -> interp(args...), prefs) + return InfiniteOpt.add_parameter_function(main, param_func) end ################################################################################ From 523529452a3be39cf0022c39f404fc38fc5eaf1a Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 26 Apr 2026 21:25:49 -0400 Subject: [PATCH 19/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 6 +++--- src/mbm.jl | 24 ++++++++++++------------ src/utilities.jl | 10 +++++----- test/constraints/mbm.jl | 24 ++++++++++++------------ 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index c09be8a4..5a409203 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -233,7 +233,7 @@ function DP.copy_model_with_constraints( for cref in constraints cref isa DP.DisjunctConstraintRef || continue con = JuMP.constraint_object(cref) - new_func = DP._replace_variables_in_constraint(con.func, ref_map) + new_func = DP.replace_variables_in_constraint(con.func, ref_map) T = one(JuMP.value_type(typeof(mini))) JuMP.@constraint(mini, new_func * T in con.set) end @@ -257,7 +257,7 @@ function DP.prepare_max_M_objective( sub::DP.GDPSubmodel ) where {T, S <: _MOI.LessThan} ref_map = sub.model.ext[:inf_mbm_ref_map] - mini_expr = DP._replace_variables_in_constraint( + mini_expr = DP.replace_variables_in_constraint( obj.func, ref_map) - obj.set.upper sub.model.ext[:inf_mbm_obj_expr] = obj.func return InfiniteOpt.transformation_expression(mini_expr) @@ -269,7 +269,7 @@ function DP.prepare_max_M_objective( sub::DP.GDPSubmodel ) where {T, S <: _MOI.GreaterThan} ref_map = sub.model.ext[:inf_mbm_ref_map] - mini_expr = obj.set.lower - DP._replace_variables_in_constraint( + mini_expr = obj.set.lower - DP.replace_variables_in_constraint( obj.func, ref_map) sub.model.ext[:inf_mbm_obj_expr] = obj.func return InfiniteOpt.transformation_expression(mini_expr) diff --git a/src/mbm.jl b/src/mbm.jl index 4fd844c3..76ca939f 100644 --- a/src/mbm.jl +++ b/src/mbm.jl @@ -250,7 +250,7 @@ function prepare_max_M_objective( sub::GDPSubmodel ) where {T, S <: _MOI.LessThan} flat_map = Dict(v => ws[1] for (v, ws) in sub.fwd_map) - expr = -obj.set.upper + _replace_variables_in_constraint(obj.func, flat_map) + expr = -obj.set.upper + replace_variables_in_constraint(obj.func, flat_map) return expr end @@ -260,7 +260,7 @@ function prepare_max_M_objective( sub::GDPSubmodel ) where {T, S <: _MOI.GreaterThan} flat_map = Dict(v => ws[1] for (v, ws) in sub.fwd_map) - expr = obj.set.lower - _replace_variables_in_constraint(obj.func, flat_map) + expr = obj.set.lower - replace_variables_in_constraint(obj.func, flat_map) return expr end @@ -462,7 +462,7 @@ function copy_model_with_constraints( for cref in constraints con = JuMP.constraint_object(cref) flat_map = Dict(v => only(ws) for (v, ws) in fwd_map) - expr = _replace_variables_in_constraint(con.func, flat_map) + expr = replace_variables_in_constraint(con.func, flat_map) T = one(JuMP.value_type(typeof(sub_model))) JuMP.@constraint(sub_model, expr * T in con.set) end @@ -480,7 +480,7 @@ end # Replace variable refs in an expression using a map. Uses AbstractDict # because the InfiniteModel MBM path maps decision vars to VariableRefs # and parameter functions to Numbers in the same dict (via _build_flat_map). -function _replace_variables_in_constraint( +function replace_variables_in_constraint( fun::JuMP.AbstractVariableRef, var_map::AbstractDict ) @@ -501,7 +501,7 @@ function _var_ref_type( return V end -function _replace_variables_in_constraint( +function replace_variables_in_constraint( fun::T, var_map::AbstractDict ) where {T <: JuMP.GenericAffExpr} C = JuMP.value_type(T) @@ -514,7 +514,7 @@ function _replace_variables_in_constraint( return new_aff end -function _replace_variables_in_constraint( +function replace_variables_in_constraint( fun::T, var_map::AbstractDict ) where {T <: JuMP.GenericQuadExpr} C = JuMP.value_type(T) @@ -524,23 +524,23 @@ function _replace_variables_in_constraint( JuMP.add_to_expression!(new_quad, coef * var_map[vars.a] * var_map[vars.b]) end - new_aff = _replace_variables_in_constraint(fun.aff, var_map) + new_aff = replace_variables_in_constraint(fun.aff, var_map) JuMP.add_to_expression!(new_quad, new_aff) return new_quad end -function _replace_variables_in_constraint(fun::Number, var_map::AbstractDict) +function replace_variables_in_constraint(fun::Number, var_map::AbstractDict) return fun end -function _replace_variables_in_constraint(fun::T, +function replace_variables_in_constraint(fun::T, var_map::AbstractDict) where {T <: JuMP.GenericNonlinearExpr} - new_args = Any[_replace_variables_in_constraint( + new_args = Any[replace_variables_in_constraint( arg, var_map) for arg in fun.args] return T(fun.head, new_args) end -function _replace_variables_in_constraint(fun::Vector, var_map::AbstractDict) - return [_replace_variables_in_constraint(expr, +function replace_variables_in_constraint(fun::Vector, var_map::AbstractDict) + return [replace_variables_in_constraint(expr, var_map) for expr in fun] end diff --git a/src/utilities.jl b/src/utilities.jl index b0203d70..bca1b748 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -30,7 +30,7 @@ function copy_and_reformulate( orig_to_copy = Dict{V, V}( v => ref_map[v] for v in decision_vars) JuMP.@objective(copy, sense, - _replace_variables_in_constraint(obj, orig_to_copy) + replace_variables_in_constraint(obj, orig_to_copy) ) fwd_map = Dict{V, Vector{V}}(v => [ref_map[v]] for v in decision_vars) sub = GDPSubmodel(copy, decision_vars, fwd_map) @@ -221,7 +221,7 @@ function copy_gdp_data( old_con_ref = LogicalConstraintRef(model, idx) new_con_ref = LogicalConstraintRef(new_model, idx) c = lc_data.constraint - expr = _replace_variables_in_constraint(c.func, lv_map) + expr = replace_variables_in_constraint(c.func, lv_map) new_con = JuMP.build_constraint(error, expr, c.set) JuMP.add_constraint(new_model, new_con, lc_data.name) lc_map[old_con_ref] = new_con_ref @@ -233,7 +233,7 @@ function copy_gdp_data( old_dc_ref = DisjunctConstraintRef(model, idx) old_indicator = old_gdp.constraint_to_indicator[old_dc_ref] new_indicator = lv_map[old_indicator] - new_expr = _replace_variables_in_constraint(old_constraint.func, + new_expr = replace_variables_in_constraint(old_constraint.func, var_map ) # Update to new_gdp.disjunct_constraints @@ -247,7 +247,7 @@ function copy_gdp_data( # Copying disjunctions for (idx, disj_data) in old_gdp.disjunctions old_disj = disj_data.constraint - new_indicators = [_replace_variables_in_constraint(indicator, lv_map) + new_indicators = [replace_variables_in_constraint(indicator, lv_map) for indicator in old_disj.indicators ] new_disj = Disjunction(new_indicators, old_disj.nested) @@ -383,7 +383,7 @@ function _remap_indicator_to_binary( bref::JuMP.GenericAffExpr, var_map::Dict{V, V} ) where {V <: JuMP.AbstractVariableRef} - return _replace_variables_in_constraint(bref, var_map) + return replace_variables_in_constraint(bref, var_map) end function _remap_constraint_to_indicator( diff --git a/test/constraints/mbm.jl b/test/constraints/mbm.jl index 80f9a27f..a6854bdc 100644 --- a/test/constraints/mbm.jl +++ b/test/constraints/mbm.jl @@ -27,7 +27,7 @@ function test__var_ref_type_numeric_map() @test DP._var_ref_type(typeof(aff), var_map) == VariableRef end -# _replace_variables_in_constraint with QuadExpr where var_map +# replace_variables_in_constraint with QuadExpr where var_map # maps some vars to Numbers. Covers lines 569, 571, 574. function test__replace_variables_quad_numeric_map() model = Model() @@ -38,21 +38,21 @@ function test__replace_variables_quad_numeric_map() # both Number (line 569) map1 = Dict{VariableRef, Any}(x[1] => 2.0, x[2] => 3.0) - result1 = DP._replace_variables_in_constraint(quad1, map1) + result1 = DP.replace_variables_in_constraint(quad1, map1) @test result1.aff.constant ≈ 6.0 # ra Number, rb VariableRef (line 571) map2 = Dict{VariableRef, Any}(x[1] => 2.0, x[2] => y) - result2 = DP._replace_variables_in_constraint(quad1, map2) + result2 = DP.replace_variables_in_constraint(quad1, map2) @test result2.aff.terms[y] ≈ 2.0 # rb Number, ra VariableRef (line 574) map3 = Dict{VariableRef, Any}(x[1] => y, x[2] => 3.0) - result3 = DP._replace_variables_in_constraint(quad1, map3) + result3 = DP.replace_variables_in_constraint(quad1, map3) @test result3.aff.terms[y] ≈ 3.0 end -function test_replace_variables_in_constraint() +function testreplace_variables_in_constraint() model = Model() sub_model = Model() @variable(model, x[1:3]) @@ -64,14 +64,14 @@ function test_replace_variables_in_constraint() #Test GenericVariableRef new_vars = Dict{AbstractVariableRef, AbstractVariableRef}() [new_vars[x[i]] = @variable(sub_model) for i in 1:3] - varref = DP._replace_variables_in_constraint(x[1], new_vars) - expr1 = DP._replace_variables_in_constraint( + varref = DP.replace_variables_in_constraint(x[1], new_vars) + expr1 = DP.replace_variables_in_constraint( constraint_object(con1).func, new_vars) - expr2 = DP._replace_variables_in_constraint( + expr2 = DP.replace_variables_in_constraint( constraint_object(con2).func, new_vars) - expr3 = DP._replace_variables_in_constraint( + expr3 = DP.replace_variables_in_constraint( constraint_object(con3).func, new_vars) - expr4 = DP._replace_variables_in_constraint( + expr4 = DP.replace_variables_in_constraint( constraint_object(con4).func, new_vars) @test expr1 == JuMP.@expression(sub_model, new_vars[x[1]] + 1 - 1) @test expr2 == JuMP.@expression(sub_model, new_vars[x[2]]*new_vars[x[1]]) @@ -80,7 +80,7 @@ function test_replace_variables_in_constraint() expected = JuMP.@expression(sub_model, sin(new_vars[x[3]]) - 0.0) @test JuMP.isequal_canonical(expr3, expected) @test expr4 == [new_vars[x[i]] for i in 1:3] - @test_throws MethodError DP._replace_variables_in_constraint( + @test_throws MethodError DP.replace_variables_in_constraint( "String", new_vars) end @@ -794,7 +794,7 @@ end test_mbm() test__var_ref_type_numeric_map() test__replace_variables_quad_numeric_map() - test_replace_variables_in_constraint() + testreplace_variables_in_constraint() test_prepare_max_M_objective() test_raw_M() test_maximize_M() From 32ccde59f594212f31654b1f9c2376a79e1e0ef5 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 26 Apr 2026 21:33:34 -0400 Subject: [PATCH 20/72] . --- test/constraints/mbm.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/constraints/mbm.jl b/test/constraints/mbm.jl index a6854bdc..0c96dbbb 100644 --- a/test/constraints/mbm.jl +++ b/test/constraints/mbm.jl @@ -52,7 +52,7 @@ function test__replace_variables_quad_numeric_map() @test result3.aff.terms[y] ≈ 3.0 end -function testreplace_variables_in_constraint() +function test_replace_variables_in_constraint() model = Model() sub_model = Model() @variable(model, x[1:3]) @@ -794,7 +794,7 @@ end test_mbm() test__var_ref_type_numeric_map() test__replace_variables_quad_numeric_map() - testreplace_variables_in_constraint() + test_replace_variables_in_constraint() test_prepare_max_M_objective() test_raw_M() test_maximize_M() From bb03b3bd248ca508650d4a7a7266c70f9a3420cc Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 26 Apr 2026 21:59:53 -0400 Subject: [PATCH 21/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 5a409203..efd80d40 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -282,7 +282,7 @@ function DP.raw_M( objectives::AbstractArray{<:Union{JuMP.AbstractJuMPScalar, Real}}, method::DP._MBM ) - M_vals = similar(objectives, typeof(method.default_M)) + M_vals = Array{typeof(method.default_M)}(undef, size(objectives)) for I in eachindex(objectives) JuMP.set_start_value.(JuMP.all_variables(sub.model), nothing) m = DP.raw_M(sub, objectives[I], method) From f2387f5437097a99825c1c9c708d1ba5af33876b Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 26 Apr 2026 23:29:14 -0400 Subject: [PATCH 22/72] . --- src/cuttingplanes.jl | 31 +++++++++++++++---------------- src/utilities.jl | 13 +++++++++++++ test/constraints/cuttingplanes.jl | 4 ++-- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/cuttingplanes.jl b/src/cuttingplanes.jl index dbc818e5..603dbb4c 100644 --- a/src/cuttingplanes.jl +++ b/src/cuttingplanes.jl @@ -8,25 +8,17 @@ function collect_cutting_planes_vars(model::JuMP.AbstractModel) return collect_all_vars(model) end -# Extract solution from a solved model (in-place). Extensions -# override for models where values live on a backend. +# Read primal values from a solved model. Returns a scalar-valued +# `Dict{var, value}`, skipping fixed vars. CP callers wrap to +# per-support `Vector` shape via `_cp_per_support`. The InfiniteOpt +# extension overrides this dispatch to give per-support `Vector` +# values directly. function extract_solution(model::JuMP.AbstractModel) dvars = collect_cutting_planes_vars(model) V = eltype(dvars) T = JuMP.value_type(typeof(model)) - return Dict{V, Vector{T}}( - v => [JuMP.value(v)] for v in dvars) -end - -# Extract solution from a GDPSubmodel (SEP path). -function extract_solution(sub::GDPSubmodel) - V = eltype(sub.decision_vars) - T = JuMP.value_type(typeof(sub.model)) - sol = Dict{V, Vector{T}}() - for var in sub.decision_vars - sol[var] = JuMP.value.(sub.fwd_map[var]) - end - return sol + return Dict{V, T}( + v => JuMP.value(v) for v in dvars if !JuMP.is_fixed(v)) end # Set quadratic separation objective: min Σ (x_k - rBM_k)². @@ -113,7 +105,7 @@ function reformulate_model( # Cutting plane loop: rBM <-> SEP until convergence for iter in 1:method.max_iter JuMP.optimize!(model, ignore_optimize_hook = true) - rBM_sol = extract_solution(model) + rBM_sol = _cp_per_support(extract_solution(model)) separation_obj, separation_sol = _solve_separation(separation, rBM_sol) if separation_obj <= method.seperation_tolerance break @@ -127,6 +119,13 @@ function reformulate_model( return end +# Wrap scalar `extract_solution(model)` values into 1-element +# `Vector`s — uniform per-support shape that the CP loop expects. +# `Vector` values (from the InfiniteOpt extension's per-support read) +# pass through unchanged. +_cp_per_support(point::AbstractDict) = + Dict(v => val isa AbstractVector ? val : [val] for (v, val) in point) + ################################################################################ # ERROR MESSAGES ################################################################################ diff --git a/src/utilities.jl b/src/utilities.jl index bca1b748..da090eac 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -8,6 +8,19 @@ function _copy_model( return M() end +""" + extract_solution(sub::GDPSubmodel) + +Read the primal solution of `sub.model` after a solve, keyed by the +parent-model decision variables via `sub.fwd_map`. Shape follows +`fwd_map` values: `Vector`-valued fwd_maps (CP/MBM) yield per-support +`Vector`s; scalar fwd_maps (LOA feas) yield scalars. +""" +function extract_solution(sub::GDPSubmodel) + return Dict( + var => JuMP.value.(sub.fwd_map[var]) for var in sub.decision_vars) +end + """ copy_and_reformulate(model, decision_vars, reform_method, method) diff --git a/test/constraints/cuttingplanes.jl b/test/constraints/cuttingplanes.jl index 9f3a7931..e15976dd 100644 --- a/test/constraints/cuttingplanes.jl +++ b/test/constraints/cuttingplanes.jl @@ -146,7 +146,7 @@ function test_cp_cut_generation() JuMP.set_silent(model) relaxed = DP.relax_logical_vars(model) optimize!(model, ignore_optimize_hook = true) - rBM_sol = DP.extract_solution(model) + rBM_sol = DP._cp_per_support(DP.extract_solution(model)) # Solve SEP DP._set_separation_objective(separation, rBM_sol) @@ -168,7 +168,7 @@ function test_cp_cut_generation() # Re-solve with cut → should tighten optimize!(model, ignore_optimize_hook = true) rBM_sol2 = DP.extract_solution(model) - @test rBM_sol2[x][1] ≈ 4.0 atol = 0.1 + @test rBM_sol2[x] ≈ 4.0 atol = 0.1 DP.unrelax_logical_vars(relaxed) end From fbdafa0c9e888b2e37f8006ada24eecccf1860c2 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 26 Apr 2026 23:44:18 -0400 Subject: [PATCH 23/72] . --- src/cuttingplanes.jl | 23 ++++++++--------------- test/constraints/cuttingplanes.jl | 4 ++-- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/cuttingplanes.jl b/src/cuttingplanes.jl index 603dbb4c..63692258 100644 --- a/src/cuttingplanes.jl +++ b/src/cuttingplanes.jl @@ -8,17 +8,17 @@ function collect_cutting_planes_vars(model::JuMP.AbstractModel) return collect_all_vars(model) end -# Read primal values from a solved model. Returns a scalar-valued -# `Dict{var, value}`, skipping fixed vars. CP callers wrap to -# per-support `Vector` shape via `_cp_per_support`. The InfiniteOpt -# extension overrides this dispatch to give per-support `Vector` -# values directly. +# Read primal values from a solved model. Returns +# `Dict{var, Vector{value}}` — per-support shape uniformly: finite +# models trivially have one "support" (length-1 Vector), the +# InfiniteOpt extension overrides this dispatch to populate +# multi-support Vectors. Skips fixed vars. function extract_solution(model::JuMP.AbstractModel) dvars = collect_cutting_planes_vars(model) V = eltype(dvars) T = JuMP.value_type(typeof(model)) - return Dict{V, T}( - v => JuMP.value(v) for v in dvars if !JuMP.is_fixed(v)) + return Dict{V, Vector{T}}( + v => [JuMP.value(v)] for v in dvars if !JuMP.is_fixed(v)) end # Set quadratic separation objective: min Σ (x_k - rBM_k)². @@ -105,7 +105,7 @@ function reformulate_model( # Cutting plane loop: rBM <-> SEP until convergence for iter in 1:method.max_iter JuMP.optimize!(model, ignore_optimize_hook = true) - rBM_sol = _cp_per_support(extract_solution(model)) + rBM_sol = extract_solution(model) separation_obj, separation_sol = _solve_separation(separation, rBM_sol) if separation_obj <= method.seperation_tolerance break @@ -119,13 +119,6 @@ function reformulate_model( return end -# Wrap scalar `extract_solution(model)` values into 1-element -# `Vector`s — uniform per-support shape that the CP loop expects. -# `Vector` values (from the InfiniteOpt extension's per-support read) -# pass through unchanged. -_cp_per_support(point::AbstractDict) = - Dict(v => val isa AbstractVector ? val : [val] for (v, val) in point) - ################################################################################ # ERROR MESSAGES ################################################################################ diff --git a/test/constraints/cuttingplanes.jl b/test/constraints/cuttingplanes.jl index e15976dd..9f3a7931 100644 --- a/test/constraints/cuttingplanes.jl +++ b/test/constraints/cuttingplanes.jl @@ -146,7 +146,7 @@ function test_cp_cut_generation() JuMP.set_silent(model) relaxed = DP.relax_logical_vars(model) optimize!(model, ignore_optimize_hook = true) - rBM_sol = DP._cp_per_support(DP.extract_solution(model)) + rBM_sol = DP.extract_solution(model) # Solve SEP DP._set_separation_objective(separation, rBM_sol) @@ -168,7 +168,7 @@ function test_cp_cut_generation() # Re-solve with cut → should tighten optimize!(model, ignore_optimize_hook = true) rBM_sol2 = DP.extract_solution(model) - @test rBM_sol2[x] ≈ 4.0 atol = 0.1 + @test rBM_sol2[x][1] ≈ 4.0 atol = 0.1 DP.unrelax_logical_vars(relaxed) end From 153c1e796f013d006347845397cb74dc77a32e4e Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 27 Apr 2026 10:04:22 -0400 Subject: [PATCH 24/72] . --- src/cuttingplanes.jl | 20 ++++++++++++++------ src/utilities.jl | 13 ------------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/cuttingplanes.jl b/src/cuttingplanes.jl index 63692258..dbc818e5 100644 --- a/src/cuttingplanes.jl +++ b/src/cuttingplanes.jl @@ -8,17 +8,25 @@ function collect_cutting_planes_vars(model::JuMP.AbstractModel) return collect_all_vars(model) end -# Read primal values from a solved model. Returns -# `Dict{var, Vector{value}}` — per-support shape uniformly: finite -# models trivially have one "support" (length-1 Vector), the -# InfiniteOpt extension overrides this dispatch to populate -# multi-support Vectors. Skips fixed vars. +# Extract solution from a solved model (in-place). Extensions +# override for models where values live on a backend. function extract_solution(model::JuMP.AbstractModel) dvars = collect_cutting_planes_vars(model) V = eltype(dvars) T = JuMP.value_type(typeof(model)) return Dict{V, Vector{T}}( - v => [JuMP.value(v)] for v in dvars if !JuMP.is_fixed(v)) + v => [JuMP.value(v)] for v in dvars) +end + +# Extract solution from a GDPSubmodel (SEP path). +function extract_solution(sub::GDPSubmodel) + V = eltype(sub.decision_vars) + T = JuMP.value_type(typeof(sub.model)) + sol = Dict{V, Vector{T}}() + for var in sub.decision_vars + sol[var] = JuMP.value.(sub.fwd_map[var]) + end + return sol end # Set quadratic separation objective: min Σ (x_k - rBM_k)². diff --git a/src/utilities.jl b/src/utilities.jl index da090eac..bca1b748 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -8,19 +8,6 @@ function _copy_model( return M() end -""" - extract_solution(sub::GDPSubmodel) - -Read the primal solution of `sub.model` after a solve, keyed by the -parent-model decision variables via `sub.fwd_map`. Shape follows -`fwd_map` values: `Vector`-valued fwd_maps (CP/MBM) yield per-support -`Vector`s; scalar fwd_maps (LOA feas) yield scalars. -""" -function extract_solution(sub::GDPSubmodel) - return Dict( - var => JuMP.value.(sub.fwd_map[var]) for var in sub.decision_vars) -end - """ copy_and_reformulate(model, decision_vars, reform_method, method) From d053c0700b513663d0a4618a3b4dfb8cff2f628e Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 27 Apr 2026 10:49:18 -0400 Subject: [PATCH 25/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 1 - 1 file changed, 1 deletion(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index efd80d40..66d745f0 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -196,7 +196,6 @@ function DP.copy_model_with_constraints( # 2. Copy decision variables with bounds for v in JuMP.all_variables(model) - _is_parameter(v) && continue prefs = InfiniteOpt.parameter_refs(v) var_type = isempty(prefs) ? nothing : InfiniteOpt.Infinite(Tuple(ref_map[p] for p in prefs)...) From b0184fc4ef43ec13fb4d3b9be08aa9d7a62f4cae Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Fri, 1 May 2026 00:01:42 -0400 Subject: [PATCH 26/72] Update to get_variable_info, copy_model_with_constraints, prepare_max_M_objective and raw_M in InfGDP --- ext/InfiniteDisjunctiveProgramming.jl | 57 ++++++++++--------- src/mbm.jl | 2 +- src/variables.jl | 4 +- test/constraints/mbm.jl | 15 +++++ .../InfiniteDisjunctiveProgramming.jl | 2 +- 5 files changed, 51 insertions(+), 29 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 66d745f0..693845bd 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -237,17 +237,20 @@ function DP.copy_model_with_constraints( JuMP.@constraint(mini, new_func * T in con.set) end - # 6. Transcribe mini InfiniteModel + # 6. Build the transformation backend so transcribed exists and + # configure its solver. We hold mini in sub.model and recover + # transcribed lazily via transformation_model(mini). InfiniteOpt.build_transformation_backend!(mini) transcribed = InfiniteOpt.transformation_model(mini) JuMP.set_optimizer(transcribed, method.optimizer) JuMP.set_silent(transcribed) - # Stash for prepare_max_M_objective / raw_M. - transcribed.ext[:inf_mbm_main] = model - transcribed.ext[:inf_mbm_ref_map] = ref_map - # GDPSubmodel's fwd_map / decision_vars are CP-only; unused here. - return DP.GDPSubmodel(transcribed, InfiniteOpt.GeneralVariableRef[], - Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}()) + # 7. Wrap ref_map as fwd_map (singleton vectors) so + # prepare_max_M_objective can use the standard flat_map idiom. + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, + Vector{InfiniteOpt.GeneralVariableRef}}( + v => [w] for (v, w) in ref_map) + return DP.GDPSubmodel( + mini, DP.collect_all_vars(model), fwd_map) end function DP.prepare_max_M_objective( @@ -255,11 +258,9 @@ function DP.prepare_max_M_objective( obj::JuMP.ScalarConstraint{T, S}, sub::DP.GDPSubmodel ) where {T, S <: _MOI.LessThan} - ref_map = sub.model.ext[:inf_mbm_ref_map] - mini_expr = DP.replace_variables_in_constraint( - obj.func, ref_map) - obj.set.upper - sub.model.ext[:inf_mbm_obj_expr] = obj.func - return InfiniteOpt.transformation_expression(mini_expr) + flat_map = Dict(v => ws[1] for (v, ws) in sub.fwd_map) + obj_func = DP.replace_variables_in_constraint(obj.func, flat_map) + return obj_func - obj.set.upper end function DP.prepare_max_M_objective( @@ -267,31 +268,35 @@ function DP.prepare_max_M_objective( obj::JuMP.ScalarConstraint{T, S}, sub::DP.GDPSubmodel ) where {T, S <: _MOI.GreaterThan} - ref_map = sub.model.ext[:inf_mbm_ref_map] - mini_expr = obj.set.lower - DP.replace_variables_in_constraint( - obj.func, ref_map) - sub.model.ext[:inf_mbm_obj_expr] = obj.func - return InfiniteOpt.transformation_expression(mini_expr) + flat_map = Dict(v => ws[1] for (v, ws) in sub.fwd_map) + obj_func = DP.replace_variables_in_constraint(obj.func, flat_map) + return obj.set.lower - obj_func end -# Per-support solve, delegating to scalar base raw_M. Aggregated to a -# scalar if uniform, else to a parameter function. +# Transcribe mini_expr, solve per support on the transcribed JuMP +# model, and aggregate to a scalar if uniform, else to a parameter +# function on main. function DP.raw_M( - sub::DP.GDPSubmodel, - objectives::AbstractArray{<:Union{JuMP.AbstractJuMPScalar, Real}}, + sub::DP.GDPSubmodel{<:InfiniteOpt.InfiniteModel}, + mini_expr::JuMP.AbstractJuMPScalar, method::DP._MBM ) + objectives = InfiniteOpt.transformation_expression(mini_expr) + transcribed = InfiniteOpt.transformation_model(sub.model) + inner_sub = DP.GDPSubmodel(transcribed,JuMP.VariableRef[], + Dict{JuMP.VariableRef, Vector{JuMP.VariableRef}}() + ) M_vals = Array{typeof(method.default_M)}(undef, size(objectives)) for I in eachindex(objectives) - JuMP.set_start_value.(JuMP.all_variables(sub.model), nothing) - m = DP.raw_M(sub, objectives[I], method) + m = DP.raw_M(inner_sub, objectives[I], method) m === nothing && return nothing M_vals[I] = m end all(==(first(M_vals)), M_vals) && return first(M_vals) - main = sub.model.ext[:inf_mbm_main] - expr = sub.model.ext[:inf_mbm_obj_expr] - prefs = InfiniteOpt.parameter_refs(expr) + mini_prefs = InfiniteOpt.parameter_refs(mini_expr) + reverse_map = Dict(ws[1] => v for (v, ws) in sub.fwd_map) + prefs = Tuple(reverse_map[p] for p in mini_prefs) + main = JuMP.owner_model(first(prefs)) grids = Tuple(InfiniteOpt.supports(p) for p in prefs) interp = Interpolations.linear_interpolation(grids, M_vals) param_func = InfiniteOpt.build_parameter_function( diff --git a/src/mbm.jl b/src/mbm.jl index 76ca939f..23c5096e 100644 --- a/src/mbm.jl +++ b/src/mbm.jl @@ -273,7 +273,7 @@ Returns `max(obj_value, 0)` on optimal, `nothing` on infeasible `method.default_M` otherwise (unbounded, numerical failure, etc). """ function raw_M( - sub::GDPSubmodel, + sub::GDPSubmodel{<:JuMP.AbstractModel}, objective::JuMP.AbstractJuMPScalar, method::_MBM ) diff --git a/src/variables.jl b/src/variables.jl index f96cfe02..e3369db2 100644 --- a/src/variables.jl +++ b/src/variables.jl @@ -547,7 +547,9 @@ function get_variable_info(vref::JuMP.AbstractVariableRef; has_lb::Bool = JuMP.has_lower_bound(vref), has_ub::Bool = JuMP.has_upper_bound(vref), has_fix::Bool = JuMP.is_fixed(vref), - has_start::Bool = JuMP.has_start_value(vref), + has_start::Bool = JuMP.has_start_value(vref) && + !(JuMP.start_value(vref) isa Number && + isnan(JuMP.start_value(vref))), has_binary::Bool = JuMP.is_binary(vref), has_integer::Bool = JuMP.is_integer(vref), lower_bound::Union{Number, Function} = has_lb ? JuMP.lower_bound(vref) : 0, diff --git a/test/constraints/mbm.jl b/test/constraints/mbm.jl index 0c96dbbb..11f164da 100644 --- a/test/constraints/mbm.jl +++ b/test/constraints/mbm.jl @@ -783,6 +783,20 @@ function test_get_variable_info() @test info_custom.has_ub == false end +# A NaN-valued start is treated as no start so the NaN doesn't +# propagate to copies or to solver inputs. +function test_get_variable_info_nan_start() + model = GDPModel() + @variable(model, x) + JuMP.set_start_value(x, NaN) + @test JuMP.has_start_value(x) == true + @test isnan(JuMP.start_value(x)) + + info = DP.get_variable_info(x) + @test info.has_start == false + @test info.start == 0 +end + @testset "MBM" begin test__copy_model() test_variable_properties() @@ -791,6 +805,7 @@ end test_variable_copy() test__copy_model_with_constraints() test_get_variable_info() + test_get_variable_info_nan_start() test_mbm() test__var_ref_type_numeric_map() test__replace_variables_quad_numeric_map() diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 42ac0914..c000ebc7 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -381,7 +381,7 @@ function test_raw_M_infinite_scalar() model, DP.DisjunctConstraintRef[con2], mbm) obj = DP.prepare_max_M_objective( model, JuMP.constraint_object(con), sub) - @test length(obj) == 5 # K support points + @test length(InfiniteOpt.parameter_refs(obj)) == 1 @test DP.raw_M(sub, obj, mbm) == 5.0 end From 2909c8416925a7b74eff727263ff83caa97a5fa5 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Fri, 1 May 2026 11:59:38 -0400 Subject: [PATCH 27/72] Internal constant interpolation --- Project.toml | 7 +--- ext/InfiniteDisjunctiveProgramming.jl | 30 ++++++++++++-- .../InfiniteDisjunctiveProgramming.jl | 39 +++++++++++++++++-- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/Project.toml b/Project.toml index ae0dad94..7b4a2728 100644 --- a/Project.toml +++ b/Project.toml @@ -9,15 +9,13 @@ Reexport = "189a3867-3050-52da-a836-e630ba90ab69" [weakdeps] InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" -Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" [extensions] -InfiniteDisjunctiveProgramming = ["InfiniteOpt", "Interpolations"] +InfiniteDisjunctiveProgramming = "InfiniteOpt" [compat] Aqua = "0.8" InfiniteOpt = "0.6" -Interpolations = "0.16.2" Ipopt = "1.9.0" JuMP = "1.18" Juniper = "0.9.3" @@ -28,10 +26,9 @@ julia = "1.10" Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" -Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" Juniper = "2ddba703-00a4-53a7-87a5-e8b9971dde84" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Aqua", "HiGHS", "InfiniteOpt", "Test", "Juniper", "Ipopt", "Interpolations"] +test = ["Aqua", "HiGHS", "InfiniteOpt", "Test", "Juniper", "Ipopt"] diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 693845bd..05b7219e 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -1,7 +1,7 @@ module InfiniteDisjunctiveProgramming import JuMP.MOI as _MOI -import InfiniteOpt, JuMP, Interpolations +import InfiniteOpt, JuMP import DisjunctiveProgramming as DP ################################################################################ @@ -273,6 +273,31 @@ function DP.prepare_max_M_objective( return obj.set.lower - obj_func end +# Constant interpolation +function _interpolate( + grids::NTuple{N, AbstractVector{<:Real}}, + values::AbstractArray{<:Real, N} + ) where {N} + # mimic the call form of Interpolations.jl's interpolation + return (args...) -> _interpolate_at(grids, values, args) +end + +function _interpolate_at( + grids::NTuple{N, AbstractVector{<:Real}}, + values::AbstractArray{<:Real, N}, + args::NTuple{N, <:Real} + ) where {N} + # lower-corner cell index per dimension + idx_lo = ntuple(d -> + clamp(searchsortedlast(grids[d], args[d]),1, length(grids[d]) - 1), N + ) + # max over the 2^N corners; bit d of k picks lower or upper + return maximum( + values[ntuple(d -> idx_lo[d] +((k >> (d - 1)) & 1), N)...] + for k in 0:(2^N - 1) + ) +end + # Transcribe mini_expr, solve per support on the transcribed JuMP # model, and aggregate to a scalar if uniform, else to a parameter # function on main. @@ -298,9 +323,8 @@ function DP.raw_M( prefs = Tuple(reverse_map[p] for p in mini_prefs) main = JuMP.owner_model(first(prefs)) grids = Tuple(InfiniteOpt.supports(p) for p in prefs) - interp = Interpolations.linear_interpolation(grids, M_vals) param_func = InfiniteOpt.build_parameter_function( - error, (args...) -> interp(args...), prefs) + error, _interpolate(grids, M_vals), prefs) return InfiniteOpt.add_parameter_function(main, param_func) end diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index c000ebc7..47bb884b 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -1,4 +1,4 @@ -using InfiniteOpt, HiGHS, Ipopt, Juniper, Interpolations +using InfiniteOpt, HiGHS, Ipopt, Juniper import DisjunctiveProgramming as DP # Helper to access internal function @@ -387,8 +387,8 @@ end # raw_M with a support-varying M. Setup: x(t) ∈ [0, 10], disj1: x ≤ 2t, # disj2: x ≥ 0.5. Slack r(x) = x - 2t maximized over x ∈ [0.5, 10]: -# max(x - 2t) = 10 - 2t. Varies with t ⇒ raw_M returns a pfunc; the -# underlying function should evaluate to 10 - 2t at each support. +# max(x - 2t) = 10 - 2t. Varies with t ⇒ raw_M returns a pfunc whose +# raw values at supports are max-of-cell upper bounds for 10 - 2t. function test_raw_M_infinite_param_function() model = InfiniteGDPModel() supports = [0.0, 0.25, 0.5, 0.75, 1.0] @@ -407,11 +407,41 @@ function test_raw_M_infinite_param_function() M = DP.raw_M(sub, obj, mbm) @test M isa InfiniteOpt.GeneralVariableRef raw_fn = InfiniteOpt.raw_function(M) + # max-of-corners is conservative: raw_fn(t) ≥ 10 - 2t at supports. for t_val in supports - @test raw_fn(t_val) ≈ 10.0 - 2*t_val atol=1e-6 + @test raw_fn(t_val) >= 10.0 - 2*t_val - 1e-6 end end +# Piecewise-constant max-of-corners: returns the maximum value over +# the 2^n corners of the cell containing the query. +function test_interpolate() + grid1 = [0.0, 1.0, 2.0, 3.0] + vals1 = [10.0, 20.0, 40.0, 50.0] + f = IDP._interpolate((grid1,), vals1) + # At grid points: max over the cell to the right (or last cell). + @test f(0.0) == 20.0 # max(vals[1], vals[2]) + @test f(1.0) == 40.0 # max(vals[2], vals[3]) + @test f(3.0) == 50.0 # last cell: max(vals[3], vals[4]) + # Between grid points: max of the surrounding two values. + @test f(0.5) == 20.0 + @test f(1.5) == 40.0 + @test f(2.25) == 50.0 + # Out-of-range clamps to the boundary cell. + @test f(-1.0) == 20.0 + @test f(4.0) == 50.0 + + # 2D: max over the 4 surrounding corners. + gx = [0.0, 1.0, 2.0] + gy = [0.0, 10.0] + vals2 = [x * y for x in gx, y in gy] # 3x2 matrix + g = IDP._interpolate((gx, gy), vals2) + @test g(0.0, 0.0) == 10.0 # corners (0,0)=0, (1,0)=0, (0,10)=0, (1,10)=10 + @test g(2.0, 10.0) == 20.0 # last cell, max corner is (2,10)=20 + @test g(0.5, 5.0) == 10.0 # corners 0,0,0,10 -> 10 + @test g(1.5, 5.0) == 20.0 # corners 0,0,10,20 -> 20 +end + # extract_solution returns per-support values from the transformation # backend. Setup: force disj 2 active (x ≤ 3), BigM-reformulate, solve # min ∫x ⇒ x = 0 at every support. @@ -801,6 +831,7 @@ end end @testset "MBM" begin + test_interpolate() test_raw_M_infinite_scalar() test_raw_M_infinite_param_function() test_mbm_finite_and_integer_var() From bb769992125f1122afef5eefb1c6f18f2cd657a9 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Wed, 13 May 2026 22:55:28 -0400 Subject: [PATCH 28/72] Change to copy_model_with_constraints to use copy_model(::InfiniteModel) --- ext/InfiniteDisjunctiveProgramming.jl | 84 ++++++++------------------- 1 file changed, 25 insertions(+), 59 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 05b7219e..6399c9c1 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -174,83 +174,49 @@ end # MBM FOR INFINITEMODEL ################################################################################ -# Build a mini InfiniteModel holding only the given disjunct constraints, -# transcribe it, and return as a GDPSubmodel. +# Copy the InfiniteModel, strip everything but VariableInfo bounds, +# add back the selected disjunct constraints, transcribe, and return +# only the other disjunct's constraints plus variable bounds. function DP.copy_model_with_constraints( model::InfiniteOpt.InfiniteModel, constraints::Vector{<:DP.DisjunctConstraintRef}, method::DP._MBM ) - mini = InfiniteOpt.InfiniteModel() - ref_map = Dict{InfiniteOpt.GeneralVariableRef, - InfiniteOpt.GeneralVariableRef}() + mini, ref_map = JuMP.copy_model(model) - # 1. Copy infinite parameters with their supports - for p in InfiniteOpt.all_parameters(model) - domain = InfiniteOpt.infinite_domain(p) - supports = Float64.(InfiniteOpt.supports(p)) - param = InfiniteOpt.build_parameter(error, domain; supports = supports) - new_param = InfiniteOpt.add_parameter(mini, param, JuMP.name(p)) - ref_map[p] = new_param - end - - # 2. Copy decision variables with bounds - for v in JuMP.all_variables(model) - prefs = InfiniteOpt.parameter_refs(v) - var_type = isempty(prefs) ? nothing : - InfiniteOpt.Infinite(Tuple(ref_map[p] for p in prefs)...) - props = DP.VariableProperties( - DP.get_variable_info(v), "", nothing, var_type) - ref_map[v] = DP.create_variable(mini, props) - end - - # 3. Copy derivatives with their bounds - for d in InfiniteOpt.all_derivatives(model) - vref = InfiniteOpt.derivative_argument(d) - pref = InfiniteOpt.operator_parameter(d) - new_d = InfiniteOpt.deriv(ref_map[vref], ref_map[pref]) - info = DP.get_variable_info(d) - info.has_lb && JuMP.set_lower_bound(new_d, info.lower_bound) - info.has_ub && JuMP.set_upper_bound(new_d, info.upper_bound) - ref_map[d] = new_d - end - - # 4. Copy parameter functions (needed by ref_map substitution) - for pfunc in InfiniteOpt.all_parameter_functions(model) - func = InfiniteOpt.raw_function(pfunc) - prefs = InfiniteOpt.parameter_refs(pfunc) - mapped_prefs = Tuple(ref_map[p] for p in prefs) - pref_arg = length(mapped_prefs) == 1 ? - only(mapped_prefs) : mapped_prefs - param_func = InfiniteOpt.build_parameter_function( - error, func, pref_arg) - ref_map[pfunc] = InfiniteOpt.add_parameter_function( - mini, param_func) + # Drop global constraints. + for cref in JuMP.all_constraints(mini) + JuMP.delete(mini, cref) end - # 5. Add disjunct constraints using existing ref_map for cref in constraints - cref isa DP.DisjunctConstraintRef || continue con = JuMP.constraint_object(cref) - new_func = DP.replace_variables_in_constraint(con.func, ref_map) T = one(JuMP.value_type(typeof(mini))) - JuMP.@constraint(mini, new_func * T in con.set) + JuMP.@constraint(mini, ref_map[con.func] * T in con.set) end - # 6. Build the transformation backend so transcribed exists and - # configure its solver. We hold mini in sub.model and recover - # transcribed lazily via transformation_model(mini). InfiniteOpt.build_transformation_backend!(mini) transcribed = InfiniteOpt.transformation_model(mini) JuMP.set_optimizer(transcribed, method.optimizer) JuMP.set_silent(transcribed) - # 7. Wrap ref_map as fwd_map (singleton vectors) so - # prepare_max_M_objective can use the standard flat_map idiom. + + # fwd_map needs every ref reachable from disjunct constraints — + # decision vars + parameters + parameter functions so the + # objective substitution in `prepare_max_M_objective` can look up + # any term it sees. + decision_vars = DP.collect_all_vars(model) fwd_map = Dict{InfiniteOpt.GeneralVariableRef, - Vector{InfiniteOpt.GeneralVariableRef}}( - v => [w] for (v, w) in ref_map) - return DP.GDPSubmodel( - mini, DP.collect_all_vars(model), fwd_map) + Vector{InfiniteOpt.GeneralVariableRef}}() + for v in decision_vars + fwd_map[v] = [ref_map[v]] + end + for p in InfiniteOpt.all_parameters(model) + fwd_map[p] = [ref_map[p]] + end + for pf in InfiniteOpt.all_parameter_functions(model) + fwd_map[pf] = [ref_map[pf]] + end + return DP.GDPSubmodel(mini, decision_vars, fwd_map) end function DP.prepare_max_M_objective( From fb5f381282e518323c5a95834301cab619a02dd8 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Wed, 13 May 2026 23:07:27 -0400 Subject: [PATCH 29/72] Project.toml revert --- Project.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Project.toml b/Project.toml index 7b4a2728..3ca54b77 100644 --- a/Project.toml +++ b/Project.toml @@ -15,20 +15,20 @@ InfiniteDisjunctiveProgramming = "InfiniteOpt" [compat] Aqua = "0.8" -InfiniteOpt = "0.6" -Ipopt = "1.9.0" JuMP = "1.18" -Juniper = "0.9.3" Reexport = "1" julia = "1.10" +Juniper = "0.9.3" +Ipopt = "1.9.0" +InfiniteOpt = "0.6" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" -InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" Juniper = "2ddba703-00a4-53a7-87a5-e8b9971dde84" -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" [targets] -test = ["Aqua", "HiGHS", "InfiniteOpt", "Test", "Juniper", "Ipopt"] +test = ["Aqua", "HiGHS", "Test", "Juniper", "Ipopt", "InfiniteOpt"] From e420f982aa88ed61348f4dc66a667211b0d85b55 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Wed, 13 May 2026 23:23:27 -0400 Subject: [PATCH 30/72] Using master version of InfiniteOpt --- .github/workflows/CI.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index acae65fc..8d852299 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -25,6 +25,8 @@ jobs: with: version: ${{ matrix.version }} arch: ${{ matrix.arch }} + - name: Use InfiniteOpt master + run: julia --color=yes --project=. -e 'using Pkg; Pkg.develop(url="https://github.com/infiniteopt/InfiniteOpt.jl")' - uses: julia-actions/julia-runtest@latest - uses: julia-actions/julia-processcoverage@v1 - uses: codecov/codecov-action@v4 From c0ca8fe8b8b6e8535751785eb5cb94ad49e91afd Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Wed, 13 May 2026 23:27:44 -0400 Subject: [PATCH 31/72] Reverting CI change --- .github/workflows/CI.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 8d852299..acae65fc 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -25,8 +25,6 @@ jobs: with: version: ${{ matrix.version }} arch: ${{ matrix.arch }} - - name: Use InfiniteOpt master - run: julia --color=yes --project=. -e 'using Pkg; Pkg.develop(url="https://github.com/infiniteopt/InfiniteOpt.jl")' - uses: julia-actions/julia-runtest@latest - uses: julia-actions/julia-processcoverage@v1 - uses: codecov/codecov-action@v4 From 404c7d678b0398df6d18a4ca91896e94f3cc8486 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Wed, 22 Apr 2026 13:17:06 -0400 Subject: [PATCH 32/72] loa_two_models squashed --- ext/InfiniteDisjunctiveProgramming.jl | 206 ++++++++ src/DisjunctiveProgramming.jl | 1 + src/loa.jl | 656 ++++++++++++++++++++++++++ src/utilities.jl | 82 ++++ test/constraints/loa.jl | 225 +++++++++ test/runtests.jl | 1 + test/solve.jl | 27 +- 7 files changed, 1193 insertions(+), 5 deletions(-) create mode 100644 src/loa.jl create mode 100644 test/constraints/loa.jl diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 6399c9c1..20fca5a5 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -374,5 +374,211 @@ function DP.add_cut( InfiniteOpt.set_transformation_backend_ready(model, true) return end +################################################################################ +# LOA FOR INFINITEMODEL +################################################################################ +# Dispatch overrides for InfiniteModel. The base LOA algorithm in src/loa.jl +# is written for finite (scalar) models; these overrides handle transcription +# to a flat master and per-support binary/variable indexing. + +# Helper: map an InfiniteOpt var to its flat master var(s) via transcription + +# copy_map. Returns a vector for infinite vars, scalar for finite. +function _tv_map(v, copy_map) + tv = InfiniteOpt.transformation_variable(v) + return tv isa AbstractArray ? [copy_map[fv] for fv in vec(tv)] : copy_map[tv] +end + +# Convert InfiniteModel x_values to flat-var x_values (for objective OA cut). +function _flat_xk(model::InfiniteOpt.InfiniteModel, x_values) + fxk = Dict{JuMP.VariableRef, Float64}() + for (v, val) in x_values + tv = InfiniteOpt.transformation_variable(v) + if tv isa AbstractArray + vals = val isa AbstractVector ? val : fill(Float64(val), length(tv)) + for (i, fv) in enumerate(vec(tv)) + fxk[fv] = vals[i] + end + else + fxk[tv] = val isa Number ? Float64(val) : Float64(first(val)) + end + end + return fxk +end + +#per-support dispatch methods: the base DP.jl LOA code calls scalar-form +#helpers; these array methods let the same code path handle vector-valued +#bin_map / var_map / x_values / active entries without any extra branches +#in the base file. + +DP.fix_fv(bvs::AbstractArray, val::Bool) = + (for bv in bvs; DP.fix_fv(bv, val); end; return) +DP.fix_fv(bvs::AbstractArray, val::AbstractArray) = + (for (bv, v) in zip(bvs, val); DP.fix_fv(bv, v); end; return) +DP.unfix_fv(bvs::AbstractArray) = + (for bv in bvs; DP.unfix_fv(bv); end; return) + +DP.any_active(v::AbstractVector{Bool}) = any(v) + +#combo extraction: round per-support binary values to a Vector{Bool} +DP.combo_val(bvs::AbstractArray) = Bool.(round.(JuMP.value.(bvs))) + +#no-good cut: fold one scalar term per (binary, active) pair. The scalar- +#active method handles the set-covering phase where combos are Bool-valued. +DP.add_ng_terms(cut, bvs::AbstractArray, active::Bool) = + DP.add_ng_terms(cut, bvs, fill(active, length(bvs))) +function DP.add_ng_terms(cut, bvs::AbstractArray, actives::AbstractArray) + for (bv, a) in zip(bvs, actives) + DP.add_ng_terms(cut, bv, a) + end + return +end + +#OA cut sites: one site per active support, with per-support restrictions +#of `x_values` and `var_map` +DP.cut_sites(bvs::AbstractArray, active::Bool, x_values, var_map, d) = + DP.cut_sites(bvs, fill(active, length(bvs)), x_values, var_map, d) +function DP.cut_sites( + bvs::AbstractArray, actives::AbstractArray, + x_values, var_map, d + ) + sites = Any[] + for k in 1:length(bvs) + actives[k] || continue + smap_k = Dict{Any, Any}( + v => (mv isa AbstractVector ? mv[k] : mv) + for (v, mv) in var_map) + x_k = Dict{Any, Any}( + v => (xv isa AbstractVector ? xv[k] : xv) + for (v, xv) in x_values) + d_k = d isa AbstractVector ? d[k] : d + push!(sites, (bvs[k], x_k, smap_k, d_k)) + end + return sites +end + +#detect number of supports from a bin_map; used below in `build_loa_master` +function _detect_K(bin_map) + for (_, bvs) in bin_map + bvs isa AbstractVector && return length(bvs) + end + return 1 +end + +#transcribe the BigM'd InfiniteModel to flat, copy it, create alpha_oa. +#Number of supports K is stashed in `master.model.ext[:_loa_K]` for the +#per-support OA cut and combo overrides below. +function DP.build_loa_master(model::InfiniteOpt.InfiniteModel, method::DP.LOA) + InfiniteOpt.build_transformation_backend!(model) + flat = InfiniteOpt.transformation_model(model) + orig_obj = JuMP.objective_function(flat) + master, copy_map = JuMP.copy_model(flat) + JuMP.set_optimizer(master, method.mip_optimizer) + JuMP.set_silent(master) + bin_map = Dict{DP.LogicalVariableRef, Any}() + for (ind, bv) in DP._indicator_to_binary(model) + bin_map[ind] = _tv_map(bv, copy_map) + end + var_map = Dict{InfiniteOpt.GeneralVariableRef, Any}() + for v in DP.collect_all_vars(model) + var_map[v] = _tv_map(v, copy_map) + end + #also store a flat→master copy_map for objective OA cuts + flat_copy_map = Dict{JuMP.VariableRef, JuMP.VariableRef}() + for v in JuMP.all_variables(flat) + flat_copy_map[v] = copy_map[v] + end + obj_sense = JuMP.objective_sense(master) + alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") + JuMP.@objective(master, obj_sense, alpha_oa) + m = DP._LOAMaster(master, bin_map, var_map, obj_sense, orig_obj, + alpha_oa, flat_copy_map) + master.ext[:_loa_K] = _detect_K(bin_map) + master.ext[:_loa_flat_copy_map] = flat_copy_map + return m +end + +#fix per-support via point equality constraints +function DP.fix_combo_binaries(model::InfiniteOpt.InfiniteModel, combo) + crefs = InfiniteOpt.InfOptConstraintRef[] + for (ind, val) in combo + bv = DP._indicator_to_binary(model)[ind] + if val isa Bool + JuMP.fix(bv, val ? 1.0 : 0.0; force = true) + else + sups = InfiniteOpt.supports(first(InfiniteOpt.parameter_refs(bv))) + for (k, s) in enumerate(vec(sups)) + push!(crefs, + JuMP.@constraint(model, bv(s) == (val[k] ? 1.0 : 0.0))) + end + end + end + model.ext[:_loa_fix_crefs] = crefs +end + +function DP.unfix_combo_binaries(model::InfiniteOpt.InfiniteModel, combo) + if haskey(model.ext, :_loa_fix_crefs) + for c in model.ext[:_loa_fix_crefs] + JuMP.is_valid(model, c) && JuMP.delete(model, c) + end + delete!(model.ext, :_loa_fix_crefs) + end + for (ind, val) in combo + val isa Bool || continue + bv = DP._indicator_to_binary(model)[ind] + JuMP.is_fixed(bv) && JuMP.unfix(bv) + end +end + +#Transcribe the BigM'd InfiniteModel, then hand off to the base +#`copy_model_with_constraints` on the flat model. fwd_map is rekeyed to +#InfiniteModel vars for use by `_extract_*_x_values`; obj_ref_map stays +#keyed by flat vars (the flat-level objective OA cut lives there). +function DP.copy_model_with_constraints( + model::InfiniteOpt.InfiniteModel, method::DP.LOA + ) + InfiniteOpt.build_transformation_backend!(model) + flat = InfiniteOpt.transformation_model(model) + base = DP.copy_model_with_constraints(flat, method) + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Any}() + for v in DP.collect_all_vars(model) + fwd_map[v] = _tv_map(v, base.fwd_map) + end + return DP._LOAFeasSubmodel(base.model, fwd_map, base.fwd_map) +end + +#read per-support JuMP values from either a vector of flat vars or a +#single scalar. Dispatch handles both transformation_variable (may be +#N-dim) and fwd_map (already flat) shapes. +_read_values(v::AbstractArray) = Float64[JuMP.value(fv) for fv in vec(v)] +_read_values(v) = Float64(JuMP.value(v)) + +#extract per-support x-values from the InfiniteModel NLP; objective +#x-values are keyed by the flat transcription vars for the objective cut +function DP.extract_primary_x_values(model::InfiniteOpt.InfiniteModel) + x_vals = Dict{JuMP.AbstractVariableRef, Any}() + for v in DP.collect_all_vars(model) + JuMP.is_fixed(v) && continue + x_vals[v] = _read_values(InfiniteOpt.transformation_variable(v)) + end + return x_vals, _flat_xk(model, x_vals) +end + +#extract per-support x-values from the flat feas submodel. x_vals keys are +#InfiniteModel vars (via fwd_map); obj_x_values keys are flat vars. +function DP.extract_feas_x_values( + model::InfiniteOpt.InfiniteModel, feas::DP._LOAFeasSubmodel + ) + x_vals = Dict{JuMP.AbstractVariableRef, Any}() + for v in DP.collect_all_vars(model) + JuMP.is_fixed(v) && continue + haskey(feas.fwd_map, v) || continue + x_vals[v] = _read_values(feas.fwd_map[v]) + end + obj_xv = Dict{JuMP.VariableRef, Float64}() + for (flat_v, feas_v) in feas.obj_ref_map + obj_xv[flat_v] = Float64(JuMP.value(feas_v)) + end + return x_vals, obj_xv +end end diff --git a/src/DisjunctiveProgramming.jl b/src/DisjunctiveProgramming.jl index 85f03455..6f1537e7 100644 --- a/src/DisjunctiveProgramming.jl +++ b/src/DisjunctiveProgramming.jl @@ -28,6 +28,7 @@ include("print.jl") include("extension_api.jl") include("utilities.jl") include("psplit.jl") +include("loa.jl") # Define additional stuff that should not be exported const _EXCLUDE_SYMBOLS = [Symbol(@__MODULE__), :eval, :include] diff --git a/src/loa.jl b/src/loa.jl new file mode 100644 index 00000000..3f750fb3 --- /dev/null +++ b/src/loa.jl @@ -0,0 +1,656 @@ +################################################################################ +# LOGIC-BASED OUTER APPROXIMATION (LOA) +################################################################################ +# Türkay & Grossmann (1996), Comp. & Chem. Eng. 20(8), 959-978 +# With augmented-penalty OA master from: +# Viswanathan & Grossmann (1990), Comp. & Chem. Eng. 14(7), 769-782 +################################################################################ + +""" + LOA{O, P} <: AbstractReformulationMethod + +Logic-based Outer Approximation solver for GDP models. Uses two models: the +original (BigM-reformulated, binaries fixed per iteration as an NLP) and a +master MILP copy that accumulates OA and no-good cuts. +""" +struct LOA{O, P} <: AbstractReformulationMethod + nlp_optimizer::O + mip_optimizer::P + max_iter::Int + atol::Float64 + rtol::Float64 + M_value::Float64 + max_slack::Float64 + OA_penalty_factor::Float64 + function LOA( + nlp_optimizer::O; + mip_optimizer::P = nlp_optimizer, + max_iter::Int = 10, atol::Float64 = 1e-6, + rtol::Float64 = 1e-4, M_value::Float64 = 1e9, + max_slack::Float64 = 1000.0, OA_penalty_factor::Float64 = 1000.0 + ) where {O, P} + new{O, P}(nlp_optimizer, mip_optimizer, max_iter, atol, rtol, + M_value, max_slack, OA_penalty_factor) + end +end + +################################################################################ +# DATA STRUCTURES +################################################################################ +# Master MILP state. orig_obj is the original objective (for OA cuts); +# alpha_oa replaces it (Türkay & Grossmann 1996). obj_ref_map maps the +# variables in orig_obj to master variables. +mutable struct _LOAMaster{M <: JuMP.AbstractModel, B, V} + model::M + bin_map::B + var_map::V + obj_sense::_MOI.OptimizationSense + orig_obj::Any + alpha_oa::Any + obj_ref_map::Any +end + +# Feasibility-restoration submodel (Viswanathan & Grossmann 1990, NLPF). +# Standalone model with a shared scalar slack `u` embedded into every JuMP +# constraint and `min u` as the objective. When the primary NLP is +# infeasible, binaries are fixed here and the submodel is solved to +# produce a least-infeasible point for OA cut generation. Keeping it +# separate leaves the original NLP model clean (no `u`, no slackened +# constraints). +# +# fwd_map: original-model var -> feas var, used for binary fixing and +# value extraction. +# obj_ref_map: original-objective var -> feas var, used to linearize the +# original objective at the feas point. +struct _LOAFeasSubmodel{M <: JuMP.AbstractModel} + model::M + fwd_map::Any + obj_ref_map::Any +end + +################################################################################ +# MAIN ALGORITHM +################################################################################ +function reformulate_model(model::JuMP.AbstractModel, method::LOA) + _clear_reformulations(model) + combos = _set_covering_combos(model) + reformulate_model(model, BigM(method.M_value)) + + #build the feasibility-restoration submodel as a separate copy, then + #embed the shared scalar slack `u` in every constraint (V&G 1990, + #NLPF). The original model stays clean: no slacks, no integrality + #relaxation, no objective change. + feas = copy_model_with_constraints(model, method) + _embed_feas_slack(feas) + + master = build_loa_master(model, method) + reform_map = _build_reform_map(model) + JuMP.relax_integrality(model) + JuMP.set_optimizer(model, method.nlp_optimizer) + JuMP.set_silent(model) + + sense_token = Val(JuMP.objective_sense(model)) + best_obj = _worst_obj(sense_token) + is_better(o) = _is_better(sense_token, o, best_obj) + best_result = nothing + for combo in combos + result = _solve_nlp(model, combo, method, reform_map, feas) + _add_no_good_cut(model, master, combo) + _add_oa_cuts(model, master, result, method) + if result.feasible && is_better(result.objective) + best_obj = result.objective + best_result = result + end + end + + master_bound = _worst_obj(_flip_sense(sense_token)) + for iter in 1:method.max_iter + JuMP.optimize!(master.model) + JuMP.is_solved_and_feasible(master.model) || break + master_bound = JuMP.objective_value(master.model) + _loa_converged(best_obj, master_bound, sense_token, method) && break + combo = _extract_combo(model, master) + result = _solve_nlp(model, combo, method, reform_map, feas) + _add_no_good_cut(model, master, combo) + _add_oa_cuts(model, master, result, method) + if result.feasible && is_better(result.objective) + best_obj = result.objective + best_result = result + end + end + + _finalize_model(model, best_result) + return +end + +#fix best combo permanently and set start values +function _finalize_model(model, best_result) + best_result === nothing && return + fix_combo_binaries(model, best_result.combo) + for (var, v) in best_result.x_values + JuMP.is_valid(model, var) || continue + v isa Number || continue + JuMP.set_start_value(var, v) + end +end + +################################################################################ +# EXTENSION POINTS +################################################################################ +#build master MILP from the BigM-reformulated original +function build_loa_master(model::JuMP.AbstractModel, method::LOA) + orig_obj = JuMP.objective_function(model) + master, copy_map = JuMP.copy_model(model) + JuMP.set_optimizer(master, method.mip_optimizer) + JuMP.set_silent(master) + bin_map = Dict{LogicalVariableRef, Any}() + for (ind, bv) in _indicator_to_binary(model) + bin_map[ind] = copy_map[bv] + end + #replace objective with alpha_oa; OA cuts are added each iteration + obj_sense = JuMP.objective_sense(master) + alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") + JuMP.@objective(master, obj_sense, alpha_oa) + return _LOAMaster( + master, bin_map, copy_map, obj_sense, orig_obj, alpha_oa, copy_map) +end + +""" + copy_model_with_constraints(model, method::LOA) + +Build an LOA feas submodel: a fresh deep copy of `model` with every +non-bound constraint copied over, wired up with `method.nlp_optimizer`. +The returned submodel is a raw copy; slack embedding and the `min u` +objective are applied separately via [`_embed_feas_slack`]. Shares the +dispatch entry point with MBM's [`copy_model_with_constraints`](@ref), +which instead takes an explicit subset of disjunct constraints. +""" +function copy_model_with_constraints( + model::JuMP.AbstractModel, method::LOA + ) + var_type = JuMP.variable_ref_type(model) + sub_model = _copy_model(model) + fwd_map = Dict{var_type, var_type}() + for var in collect_all_vars(model) + fwd_map[var] = variable_copy(sub_model, var) + end + VT = JuMP.variable_ref_type(typeof(model)) + for (F, S) in JuMP.list_of_constraint_types(model) + F === VT && continue + for cref in JuMP.all_constraints(model, F, S) + con = JuMP.constraint_object(cref) + expr = _replace_variables_in_constraint(con.func, fwd_map) + JuMP.@constraint(sub_model, expr in con.set) + end + end + JuMP.set_optimizer(sub_model, method.nlp_optimizer) + JuMP.set_silent(sub_model) + return _LOAFeasSubmodel(sub_model, fwd_map, fwd_map) +end + +#embed shared scalar slack `u` into every constraint of the feas +#submodel, relax integrality, and set `min u` as the objective. Converts +#the raw copy produced by `copy_model_with_constraints` into a V&G 1990 +#NLPF feasibility-restoration problem. +function _embed_feas_slack(feas::_LOAFeasSubmodel) + u = JuMP.@variable(feas.model, base_name = "_loa_u", lower_bound = 0.0) + _slacken_model_constraints(feas.model, u) + JuMP.relax_integrality(feas.model) + JuMP.@objective(feas.model, Min, u) + return +end + +#replace every JuMP constraint in `model` (excluding variable bounds) with +#its slackened counterpart via `_slacken`. Shared by finite and InfiniteOpt +#feas submodel builders. +function _slacken_model_constraints(model, u) + VT = JuMP.variable_ref_type(typeof(model)) + to_slacken = Any[] + for (F, S) in JuMP.list_of_constraint_types(model) + F === VT && continue + for cref in JuMP.all_constraints(model, F, S) + push!(to_slacken, cref) + end + end + for cref in to_slacken + JuMP.is_valid(model, cref) || continue + con = JuMP.constraint_object(cref) + for (sf, ss) in _slacken(con.func, con.set, u) + JuMP.@constraint(model, sf in ss) + end + JuMP.delete(model, cref) + end + return +end + +#fix/unfix indicator binaries on the original model +function fix_combo_binaries(model, combo) + for (ind, active) in combo + bv = _indicator_to_binary(model)[ind] + JuMP.fix(bv, active ? 1.0 : 0.0; force = true) + end +end +function unfix_combo_binaries(model, combo) + for (ind, _) in combo + bv = _indicator_to_binary(model)[ind] + JuMP.is_fixed(bv) && JuMP.unfix(bv) + end +end + +################################################################################ +# SET COVERING INITIALIZATION +################################################################################ +function _set_covering_combos(model::JuMP.AbstractModel) + M = typeof(model) + LVR = LogicalVariableRef{M} + per_disj = Vector{Tuple{DisjunctionIndex, LVR}}[] + for (idx, disj_data) in _disjunctions(model) + disj_data.constraint.nested && continue + push!(per_disj, [(idx, ind) for ind in disj_data.constraint.indicators]) + end + isempty(per_disj) && return Dict{LVR, Bool}[] + all_combos = [Tuple{DisjunctionIndex, LVR}[c...] + for c in Iterators.product(per_disj...)] + uncovered = Set{LVR}() + for group in per_disj + for (_, ind) in group + push!(uncovered, ind) + end + end + selected = Dict{LVR, Bool}[] + while !isempty(uncovered) + best_combo = nothing + best_count = 0 + for combo in all_combos + cnt = sum(ind in uncovered for (_, ind) in combo) + if cnt > best_count + best_count = cnt + best_combo = combo + end + end + best_combo === nothing && break + combo_dict = Dict{LVR, Bool}() + for (disj_idx, active_ind) in best_combo + disj_data = _disjunctions(model)[disj_idx] + for ind in disj_data.constraint.indicators + combo_dict[ind] = (ind == active_ind) + end + end + push!(selected, combo_dict) + for (_, active_ind) in best_combo + delete!(uncovered, active_ind) + end + end + return selected +end + +################################################################################ +# NLP SUBPROBLEM +################################################################################ +# Termination tokens. `_solve_nlp` and `_run_feas_restoration` dispatch on +# these to build the result tuple without inspecting the solver status at +# multiple call sites. +struct _Feasible end +struct _Infeasible end +_nlp_status(model) = JuMP.is_solved_and_feasible(model) ? + _Feasible() : _Infeasible() + +#solve primary NLP for a fixed combo; on infeasibility, dispatch to the +#feasibility-restoration submodel (min u) to get a least-infeasible point +#for OA cut generation (Viswanathan & Grossmann 1990) +function _solve_nlp( + model::M, combo, method::LOA, reform_map, feas::_LOAFeasSubmodel + ) where {M <: JuMP.AbstractModel} + fix_combo_binaries(model, combo) + JuMP.optimize!(model, ignore_optimize_hook = true) + result = _nlp_primary_result( + _nlp_status(model), model, combo, reform_map, feas) + unfix_combo_binaries(model, combo) + return result +end + +#feasible primary NLP: pack up x-values, duals, objective +function _nlp_primary_result( + ::_Feasible, model::M, combo, reform_map, feas + ) where {M <: JuMP.AbstractModel} + x_vals, obj_xv = extract_primary_x_values(model) + duals = _collect_nlp_duals(model, combo, reform_map) + return (combo = combo, x_values = x_vals, duals = duals, + objective = JuMP.objective_value(model), feasible = true, + obj_x_values = obj_xv) +end + +#infeasible primary NLP: hand off to the feas-restoration submodel +function _nlp_primary_result( + ::_Infeasible, model, combo, reform_map, feas + ) + return _run_feas_restoration(model, feas, combo) +end + +#fix binaries on the feas submodel, solve min u, and dispatch on outcome +function _run_feas_restoration( + model::M, feas::_LOAFeasSubmodel, combo + ) where {M <: JuMP.AbstractModel} + _fix_feas_combo_binaries(feas, model, combo) + JuMP.optimize!(feas.model) + result = _nlp_feas_result(_nlp_status(feas.model), model, feas, combo) + _unfix_feas_combo_binaries(feas, model, combo) + return result +end + +#feas submodel solved: return the least-infeasible point for OA cuts +function _nlp_feas_result( + ::_Feasible, model::M, feas, combo + ) where {M <: JuMP.AbstractModel} + x_vals, obj_xv = extract_feas_x_values(model, feas) + return (combo = combo, x_values = x_vals, + duals = Dict{DisjunctConstraintRef{M}, Any}(), + objective = Inf, feasible = false, obj_x_values = obj_xv) +end + +#feas submodel also infeasible: return empty result (no OA cut) +function _nlp_feas_result( + ::_Infeasible, model::M, feas, combo + ) where {M <: JuMP.AbstractModel} + empty = Dict{JuMP.AbstractVariableRef, Any}() + return (combo = combo, x_values = empty, + duals = Dict{DisjunctConstraintRef{M}, Any}(), + objective = Inf, feasible = false, obj_x_values = empty) +end + +#collect duals of reformulated disjunct constraints for active disjuncts +function _collect_nlp_duals( + model::M, combo, reform_map + ) where {M <: JuMP.AbstractModel} + duals = Dict{DisjunctConstraintRef{M}, Any}() + JuMP.has_duals(model) || return duals + for (ind, active) in combo + active || continue + haskey(_indicator_to_constraints(model), ind) || continue + for cref in _indicator_to_constraints(model)[ind] + cref isa DisjunctConstraintRef || continue + duals[cref] = _sum_duals(reform_map, cref) + end + end + return duals +end + +#extract x-values from the primary NLP. Returns (x_vals, obj_x_values) +#where obj_x_values is keyed by whatever variables the original objective +#uses (same as x_vals for finite; flat-transcription vars for infinite). +function extract_primary_x_values(model::JuMP.AbstractModel) + x_vals = Dict{JuMP.AbstractVariableRef, Any}() + for v in JuMP.all_variables(model) + JuMP.is_fixed(v) && continue + x_vals[v] = JuMP.value(v) + end + return x_vals, x_vals +end + +#extract x-values from the feasibility submodel, keyed by original-model +#variables via `feas.fwd_map`. Same return contract as the primary path. +function extract_feas_x_values( + model::JuMP.AbstractModel, feas::_LOAFeasSubmodel + ) + x_vals = Dict{JuMP.AbstractVariableRef, Any}() + for v in JuMP.all_variables(model) + JuMP.is_fixed(v) && continue + haskey(feas.fwd_map, v) || continue + x_vals[v] = JuMP.value(feas.fwd_map[v]) + end + return x_vals, x_vals +end + +#fix/unfix indicator binaries on the feas submodel via fwd_map. Dispatches +#through `fix_fv`/`unfix_fv` to handle scalar feas vars (finite) and +#per-support vectors (InfiniteOpt flat transcription). +function _fix_feas_combo_binaries(feas::_LOAFeasSubmodel, model, combo) + for (ind, val) in combo + orig_bv = _indicator_to_binary(model)[ind] + haskey(feas.fwd_map, orig_bv) || continue + fix_fv(feas.fwd_map[orig_bv], val) + end +end +function _unfix_feas_combo_binaries(feas::_LOAFeasSubmodel, model, combo) + for (ind, _) in combo + orig_bv = _indicator_to_binary(model)[ind] + haskey(feas.fwd_map, orig_bv) || continue + unfix_fv(feas.fwd_map[orig_bv]) + end +end + +#fix/unfix a feas-submodel binary variable at a scalar Bool value +fix_fv(bv, val::Bool) = JuMP.fix(bv, val ? 1.0 : 0.0; force = true) +function unfix_fv(bv) + JuMP.is_fixed(bv) && JuMP.unfix(bv) + return +end + +################################################################################ +# REFORM MAP (BigM constraint index) +################################################################################ +function _build_reform_map(model::M) where {M <: JuMP.AbstractModel} + ref_cons = _reformulation_constraints(model) + isempty(ref_cons) && return Dict{DisjunctConstraintRef{M}, Vector{Any}}() + CRT = eltype(ref_cons) + rmap = Dict{DisjunctConstraintRef{M}, Vector{CRT}}() + idx = 1 + for (_, disj_data) in _disjunctions(model) + disj_data.constraint.nested && continue + for ind in disj_data.constraint.indicators + haskey(_indicator_to_constraints(model), ind) || continue + for cref in _indicator_to_constraints(model)[ind] + cref isa DisjunctConstraintRef || continue + con = _disjunct_constraints(model)[JuMP.index(cref)].constraint + n = _num_reform_cons(con) + idx + n - 1 <= length(ref_cons) || break + rmap[cref] = collect(ref_cons[idx:idx + n - 1]) + idx += n + end + end + end + return rmap +end + +_num_reform_cons(::JuMP.ScalarConstraint{T, S}) where { + T <: Union{Number, JuMP.AbstractJuMPScalar}, + S <: Union{_MOI.EqualTo, _MOI.Interval}} = 2 +_num_reform_cons(::JuMP.ScalarConstraint) = 1 +_num_reform_cons(con::JuMP.VectorConstraint{T, S}) where { + T <: Union{Number, JuMP.AbstractJuMPScalar}, + S <: _MOI.Zeros} = 2 * _MOI.dimension(con.set) +_num_reform_cons(con::JuMP.VectorConstraint) = _MOI.dimension(con.set) + +function _sum_duals(reform_map, cref) + haskey(reform_map, cref) || return 0.0 + rcs = reform_map[cref] + isempty(rcs) && return 0.0 + total = JuMP.dual(rcs[1]) + for i in 2:length(rcs) + total = total .+ JuMP.dual(rcs[i]) + end + return total +end + +################################################################################ +# COMBO EXTRACTION + CUTS +################################################################################ +#extract combo from master solution. `combo_val` dispatches on scalar vs +#array bin_map values (extension adds the array method for per-support). +function _extract_combo( + model::M, master::_LOAMaster + ) where {M <: JuMP.AbstractModel} + combo = Dict{LogicalVariableRef{M}, Any}() + for (_, disj_data) in _disjunctions(model) + disj_data.constraint.nested && continue + for ind in disj_data.constraint.indicators + haskey(master.bin_map, ind) || continue + combo[ind] = combo_val(master.bin_map[ind]) + end + end + return combo +end +combo_val(bv) = round(JuMP.value(bv)) > 0.5 + +#no-good cut on the master excluding the current combo. `add_ng_terms` +#dispatches on scalar vs array bin_map / active values. +function _add_no_good_cut(model, master::_LOAMaster, combo) + cut_expr = JuMP.AffExpr(0.0) + for (ind, active) in combo + haskey(master.bin_map, ind) || continue + add_ng_terms(cut_expr, master.bin_map[ind], active) + end + JuMP.@constraint(master.model, cut_expr >= 1.0) +end +function add_ng_terms(cut, bv, active::Bool) + if active + JuMP.add_to_expression!(cut, -1.0, bv) + JuMP.add_to_expression!(cut, 1.0) + else + JuMP.add_to_expression!(cut, 1.0, bv) + end + return +end + +#predicate used by OA cut generation: is there any active indicator in +#this combo entry? Scalar Bool case; extension adds `AbstractVector{Bool}` +any_active(active::Bool) = active + +################################################################################ +# LINEARIZATION HELPERS +################################################################################ +function _linearize_at(var::JuMP.AbstractVariableRef, xk, ref_map) + return JuMP.AffExpr(0.0, ref_map[var] => 1.0) +end +function _linearize_at(func::JuMP.GenericAffExpr, xk, ref_map) + result = JuMP.AffExpr(func.constant) + for (var, coef) in func.terms + JuMP.add_to_expression!(result, coef, ref_map[var]) + end + return result +end +################################################################################ +# SLACK HELPERS +################################################################################ +_slacken(f, set::_MOI.LessThan, u) = [(f - u, set)] +_slacken(f, set::_MOI.GreaterThan, u) = [(f + u, set)] +function _slacken(f, set::_MOI.EqualTo, u) + b = _MOI.constant(set) + return [(f - u, _MOI.LessThan(b)), (f + u, _MOI.GreaterThan(b))] +end +_slacken(f, set, u) = [(f, set)] + +################################################################################ +# OA CUT GENERATION +################################################################################ +#sense-dependent coefficients dispatched on Val(obj_sense). See usage in +#`_add_objective_oa_cut` and `_add_disjunct_oa_cuts`. +_disjunct_cut_coeffs(::Val{_MOI.MIN_SENSE}) = (-1, 1) +_disjunct_cut_coeffs(::Val{_MOI.MAX_SENSE}) = (1, -1) +_worst_obj(::Val{_MOI.MIN_SENSE}) = Inf +_worst_obj(::Val{_MOI.MAX_SENSE}) = -Inf +_is_better(::Val{_MOI.MIN_SENSE}, new, best) = new < best +_is_better(::Val{_MOI.MAX_SENSE}, new, best) = new > best +_gap(::Val{_MOI.MIN_SENSE}, best, bound) = best - bound +_gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best +_flip_sense(::Val{_MOI.MIN_SENSE}) = Val(_MOI.MAX_SENSE) +_flip_sense(::Val{_MOI.MAX_SENSE}) = Val(_MOI.MIN_SENSE) + +function _add_oa_cuts(model, master::_LOAMaster, result, method::LOA) + isempty(result.x_values) && return + _add_objective_oa_cut(master, result) + _add_disjunct_oa_cuts(model, master, result, method) + return +end + +#linearize the original objective at `result.obj_x_values` and add it as +#a cut on `alpha_oa` with direction determined by `master.obj_sense` +function _add_objective_oa_cut(master::_LOAMaster, result) + lin = _linearize_at( + master.orig_obj, result.obj_x_values, master.obj_ref_map) + _add_obj_cut(Val(master.obj_sense), master, lin) + return +end +_add_obj_cut(::Val{_MOI.MIN_SENSE}, master, lin) = + JuMP.@constraint(master.model, lin <= master.alpha_oa) +_add_obj_cut(::Val{_MOI.MAX_SENSE}, master, lin) = + JuMP.@constraint(master.model, lin >= master.alpha_oa) + +#add V&G 1990 augmented-penalty OA cuts for each active disjunct's +#nonlinear constraints. `cut_sites` yields the per-cut tuples: 1 site +#for finite, K sites for the InfiniteOpt case (extension override). +function _add_disjunct_oa_cuts( + model, master::_LOAMaster, result, method::LOA + ) + sgn, pen = _disjunct_cut_coeffs(Val(master.obj_sense)) + for (ind, active) in result.combo + any_active(active) || continue + haskey(master.bin_map, ind) || continue + haskey(_indicator_to_constraints(model), ind) || continue + for orig_cref in _indicator_to_constraints(model)[ind] + orig_cref isa DisjunctConstraintRef || continue + con = _disjunct_constraints(model)[ + JuMP.index(orig_cref)].constraint + con.func isa JuMP.GenericAffExpr && continue + d = get(result.duals, orig_cref, nothing) + d === nothing && continue + for (bv, xk, smap, d_k) in cut_sites( + master.bin_map[ind], active, + result.x_values, master.var_map, d) + dv = _dv(d_k) + s = sign(sgn * dv) + s == 0 && continue + _add_one_disjunct_oa_cut( + master, con, method, bv, xk, smap, s, pen) + end + end + end +end + +#one OA cut site: (binary, x_values, var_map, dual). Scalar case yields +#a single-element tuple collection; extension's array dispatch yields +#K per-support sites with sliced x_values and var_map. +cut_sites(bv, active::Bool, x_values, var_map, d) = + ((bv, x_values, var_map, d),) + +#extract a scalar dual value from a dual "cell" that may be a vector +#(from a vector-valued reform constraint in the infinite case) +_dv(d::Number) = d +_dv(d) = sum(d) + +#add a single disjunct OA cut with augmented-penalty slack +function _add_one_disjunct_oa_cut( + master::_LOAMaster, con, method, bv, x_values, var_map, s, pen + ) + lin_expr = _linearize_at(con.func, x_values, var_map) + rhs = _set_rhs(con.set) + slack = JuMP.@variable(master.model, + lower_bound = 0.0, upper_bound = method.max_slack) + JuMP.set_objective_function(master.model, + JuMP.objective_function(master.model) + + pen * method.OA_penalty_factor * slack) + JuMP.@constraint(master.model, + s * (lin_expr - rhs) - slack <= + method.M_value * (1 - bv)) + return +end + +################################################################################ +# CONVERGENCE CHECK +################################################################################ +function _loa_converged(best_obj, master_bound, sense_token, method::LOA) + (isinf(best_obj) || isinf(master_bound)) && return false + gap = _gap(sense_token, best_obj, master_bound) + gap <= method.atol && return true + abs(best_obj) > 1e-10 && gap / abs(best_obj) <= method.rtol && return true + return false +end +_loa_converged(z_upper, z_lower, method::LOA) = + _loa_converged(z_upper, z_lower, Val(_MOI.MIN_SENSE), method) + +################################################################################ +# ERROR FALLBACK +################################################################################ +function reformulate_model(::M, ::LOA) where {M} + error("reformulate_model not implemented for model type `$(M)` with LOA.") +end diff --git a/src/utilities.jl b/src/utilities.jl index bca1b748..431c539a 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -401,3 +401,85 @@ function _remap_constraint_to_indicator( ) where {M <: JuMP.AbstractModel} return disj_map[con_ref] end +################################################################################ +# LINEARIZATION & EXPRESSION CONVERSION +################################################################################ +# First-order Taylor approximation and MOI expression building +# for outer approximation methods (LOA, future OA variants). +################################################################################ + +################################################################################ +# MOI NONLINEAR EXPRESSION CONVERSION +################################################################################ +# Convert JuMP expression trees to Julia Expr with +# MOI.VariableIndex leaves for MOI.Nonlinear evaluation. +function _to_nlp_expr(expr::JuMP.GenericNonlinearExpr, idx::Dict) + args = Any[_to_nlp_expr(a, idx) for a in expr.args] + return Expr(:call, expr.head, args...) +end +function _to_nlp_expr(expr::JuMP.GenericAffExpr, idx::Dict) + parts = Any[expr.constant] + for (var, coef) in expr.terms + push!(parts, Expr(:call, :*, coef, _MOI.VariableIndex(idx[var]))) + end + length(parts) == 1 && return parts[1] + return Expr(:call, :+, parts...) +end +function _to_nlp_expr(expr::JuMP.GenericQuadExpr, idx::Dict) + parts = Any[_to_nlp_expr(expr.aff, idx)] + for (pair, coef) in expr.terms + push!(parts, Expr(:call, :*, coef, + _MOI.VariableIndex(idx[pair.a]), + _MOI.VariableIndex(idx[pair.b]))) + end + length(parts) == 1 && return parts[1] + return Expr(:call, :+, parts...) +end +function _to_nlp_expr(var::JuMP.AbstractVariableRef, idx::Dict) + return _MOI.VariableIndex(idx[var]) +end +_to_nlp_expr(x::Number, ::Dict) = x + +# First-order Taylor linearization of a quadratic or nonlinear +# expression at point xk via MOI.Nonlinear reverse-mode AD. +function _linearize_at( + func::Union{JuMP.GenericQuadExpr, JuMP.GenericNonlinearExpr}, + xk::Dict, ref_map + ) + vars = JuMP.AbstractVariableRef[] + _interrogate_variables(v -> push!(vars, v), func) + unique!(vars) + isempty(vars) && return JuMP.AffExpr(JuMP.value(v -> 0.0, func)) + + n = length(vars) + T = JuMP.value_type(typeof(JuMP.owner_model(vars[1]))) + idx = Dict(vars[i] => i for i in 1:n) + nlp = _MOI.Nonlinear.Model() + _MOI.Nonlinear.set_objective(nlp, _to_nlp_expr(func, idx)) + ord = [_MOI.VariableIndex(i) for i in 1:n] + evaluator = _MOI.Nonlinear.Evaluator( + nlp, _MOI.Nonlinear.SparseReverseMode(), ord) + _MOI.initialize(evaluator, [:Grad]) + + xk_vec = [get(xk, v, zero(T)) for v in vars] + f_xk = _MOI.eval_objective(evaluator, xk_vec) + grad = zeros(T, n) + _MOI.eval_objective_gradient(evaluator, grad, xk_vec) + + constant = T(f_xk) + for i in 1:n + constant -= grad[i] * xk_vec[i] + end + V = typeof(ref_map[vars[1]]) + result = JuMP.GenericAffExpr{T, V}(constant) + for i in 1:n + iszero(grad[i]) && continue + JuMP.add_to_expression!(result, grad[i], ref_map[vars[i]]) + end + return result +end + +# Extract RHS from an MOI set. +_set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = + _MOI.constant(s) +_set_rhs(::Any) = 0.0 diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl new file mode 100644 index 00000000..3b92cd5b --- /dev/null +++ b/test/constraints/loa.jl @@ -0,0 +1,225 @@ +using HiGHS + +function test_loa_datatype() + method = LOA(HiGHS.Optimizer) + @test method.nlp_optimizer == HiGHS.Optimizer + @test method.mip_optimizer == HiGHS.Optimizer + @test method.max_iter == 10 + @test method.atol == 1e-6 + @test method.rtol == 1e-4 + @test method.M_value == 1e9 + @test method.max_slack == 1000.0 + @test method.OA_penalty_factor == 1000.0 + + method = LOA(HiGHS.Optimizer; max_iter = 50, atol = 1e-8, + rtol = 1e-6, M_value = 1e6, max_slack = 500.0, + OA_penalty_factor = 200.0) + @test method.max_iter == 50 + @test method.atol == 1e-8 + @test method.rtol == 1e-6 + @test method.M_value == 1e6 + @test method.max_slack == 500.0 + @test method.OA_penalty_factor == 200.0 +end + +function test_set_covering_combos() + model = GDPModel() + @variable(model, x) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x >= 5, Disjunct(Y[2])) + @disjunction(model, Y) + + combos = DP._set_covering_combos(model) + + # Should cover both Y[1] and Y[2] + all_active = Set() + for combo in combos + for (ind, active) in combo + active && push!(all_active, ind) + end + end + @test length(all_active) == 2 +end + +function test_no_good_cut() + model = GDPModel() + @variable(model, x) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x >= 5, Disjunct(Y[2])) + @disjunction(model, Y) + + DP.reformulate_model(model, BigM(1e9)) + master = DP._build_loa_master( + model, LOA(HiGHS.Optimizer)) + master_model = master.model + + combo = Dict(Y[1] => true, Y[2] => false) + + num_cons_before = length(JuMP.all_constraints( + master_model; + include_variable_in_set_constraints = false)) + DP._add_no_good_cut(model, master, combo) + num_cons_after = length(JuMP.all_constraints( + master_model; + include_variable_in_set_constraints = false)) + + @test num_cons_after == num_cons_before + 1 +end + +function test_loa_convergence_check() + method = LOA(HiGHS.Optimizer; atol = 1e-6, rtol = 1e-4) + + @test DP._loa_converged(1.0, 1.0, method) == true + @test DP._loa_converged(1.0, 0.9999, method) == true + @test DP._loa_converged(1.0, 0.5, method) == false + @test DP._loa_converged(1e-8, 0.0, method) == true +end + +function test_loa_reformulate_simple() + model = GDPModel(HiGHS.Optimizer) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + + method = LOA(HiGHS.Optimizer) + DP.reformulate_model(model, method) + + @test DP._ready_to_optimize(model) +end + +function test_loa_solve_simple() + # Simple GDP: max x s.t. (x <= 3) OR (x <= 7), 0 <= x <= 10 + # Optimal: x = 7 (select second disjunct) + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + + optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 7.0 atol=1e-4 +end + +function test_loa_solve_two_disjunctions() + # Two disjunctions: max x + z + # D1: (x <= 3) OR (x <= 7) + # D2: (z <= 2) OR (z <= 5) + # Optimal: x = 7, z = 5 + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, 0 <= z <= 10) + @variable(model, Y[1:2], Logical) + @variable(model, W[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @constraint(model, z <= 2, Disjunct(W[1])) + @constraint(model, z <= 5, Disjunct(W[2])) + @disjunction(model, W) + @objective(model, Max, x + z) + + optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 12.0 atol=1e-4 +end + +function test_loa_error_fallback() + method = LOA(HiGHS.Optimizer) + @test_throws ErrorException DP.reformulate_model(42, method) +end + +function test_linearize_nonlinear_exp() + # exp(x) + y at (1, 2): + # f = e + 2, ∇f = [e, 1] + # linear: e*(x-1) + 1*(y-2) + (e+2) = e*x + y + model = GDPModel() + @variable(model, x) + @variable(model, y) + func = @expression(model, exp(x) + y) + xk = Dict{JuMP.AbstractVariableRef, Float64}( + x => 1.0, y => 2.0) + id_map = Dict(x => x, y => y) + lin = DP._linearize_at(func, xk, id_map) + @test JuMP.constant(lin) ≈ 0.0 atol = 1e-8 + @test JuMP.coefficient(lin, x) ≈ exp(1.0) atol = 1e-8 + @test JuMP.coefficient(lin, y) ≈ 1.0 atol = 1e-8 +end + +function test_linearize_nonlinear_sin() + # sin(x) at x = π/6: + # f = 0.5, f' = cos(π/6) = √3/2 + # linear: 0.5 + (√3/2)(x - π/6) + model = GDPModel() + @variable(model, x) + func = @expression(model, sin(x)) + xk = Dict{JuMP.AbstractVariableRef, Float64}( + x => π / 6) + id_map = Dict(x => x) + lin = DP._linearize_at(func, xk, id_map) + expected_const = 0.5 - (√3 / 2) * (π / 6) + @test JuMP.constant(lin) ≈ expected_const atol = 1e-8 + @test JuMP.coefficient(lin, x) ≈ √3 / 2 atol = 1e-8 +end + +function test_linearize_nonlinear_multivar() + # exp(x) * sin(y) at (1, π/2): + # f = e*1 = e, ∂f/∂x = e*sin(π/2) = e, ∂f/∂y = e*cos(π/2) = 0 + # linear: e + e*(x-1) + 0*(y-π/2) = e*x + model = GDPModel() + @variable(model, x) + @variable(model, y) + func = @expression(model, exp(x) * sin(y)) + xk = Dict{JuMP.AbstractVariableRef, Float64}( + x => 1.0, y => π / 2) + id_map = Dict(x => x, y => y) + lin = DP._linearize_at(func, xk, id_map) + @test JuMP.constant(lin) ≈ 0.0 atol = 1e-8 + @test JuMP.coefficient(lin, x) ≈ exp(1.0) atol = 1e-8 + @test JuMP.coefficient(lin, y) ≈ 0.0 atol = 1e-8 +end + +function test_to_nlp_expr() + model = GDPModel() + @variable(model, x) + @variable(model, y) + idx = Dict(x => 1, y => 2) + + # NonlinearExpr + nl = @expression(model, exp(x)) + e = DP._to_nlp_expr(nl, idx) + @test e == Expr(:call, :exp, MOI.VariableIndex(1)) + + # AffExpr + aff = @expression(model, 2x + 3y + 1) + e = DP._to_nlp_expr(aff, idx) + @test e isa Expr + @test e.head == :call && e.args[1] == :+ + + # Number + @test DP._to_nlp_expr(42, idx) == 42 +end + +@testset "LOA" begin + test_loa_datatype() + test_set_covering_combos() + test_no_good_cut() + test_loa_convergence_check() + test_loa_reformulate_simple() + test_loa_solve_simple() + test_loa_solve_two_disjunctions() + test_loa_error_fallback() + test_linearize_nonlinear_exp() + test_linearize_nonlinear_sin() + test_linearize_nonlinear_multivar() + test_to_nlp_expr() +end diff --git a/test/runtests.jl b/test/runtests.jl index 06e8813d..ca34f816 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -18,6 +18,7 @@ include("constraints/mbm.jl") include("constraints/bigm.jl") include("constraints/psplit.jl") include("constraints/cuttingplanes.jl") +include("constraints/loa.jl") include("constraints/hull.jl") include("constraints/fallback.jl") include("constraints/disjunction.jl") diff --git a/test/solve.jl b/test/solve.jl index c3ca9441..c53cf04a 100644 --- a/test/solve.jl +++ b/test/solve.jl @@ -62,7 +62,15 @@ function test_linear_gdp_example(m, use_complements = false) @test value(Y[2]) @test !value(W[1]) @test !value(W[2]) - + + @test optimize!(m, gdp_method = LOA(HiGHS.Optimizer)) isa Nothing + @test termination_status(m) == MOI.OPTIMAL + @test objective_value(m) ≈ 11 atol=1e-3 + @test value.(x) ≈ [9,2] atol=1e-3 + @test !value(Y[1]) + @test value(Y[2]) + @test !value(W[1]) + @test !value(W[2]) m_copy, ref_map = JuMP.copy_model(m) lv_map = DP.copy_gdp_data(m, m_copy, ref_map) @@ -130,11 +138,20 @@ function test_quadratic_gdp_example(use_complements = false) #psplit does not wo @test optimize!(m, gdp_method = PSplit(2,m)) isa Nothing @test termination_status(m) in [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] - @test objective_value(m) ≈ 6.1237 atol=1e-3 - @test value.(x) ≈ [4.0825, 2.0412] atol=1e-3 - @test !value(Y[1]) + @test objective_value(m) ≈ 6.1237 atol=1e-3 + @test value.(x) ≈ [4.0825, 2.0412] atol=1e-3 + @test !value(Y[1]) @test value(Y[2]) - @test !value(W[1]) + @test !value(W[1]) + @test !value(W[2]) + + @test optimize!(m, gdp_method = LOA(optimizer)) isa Nothing + @test termination_status(m) in [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] + @test objective_value(m) ≈ 6.1237 atol=1e-3 + @test value.(x) ≈ [4.0825, 2.0412] atol=1e-3 + @test !value(Y[1]) + @test value(Y[2]) + @test !value(W[1]) @test !value(W[2]) end From 025140b3da633c745f664a194c5a74d9311762b6 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Fri, 24 Apr 2026 13:39:53 -0400 Subject: [PATCH 33/72] . Co-Authored-By: Claude Opus 4.7 (1M context) --- ext/InfiniteDisjunctiveProgramming.jl | 50 +-- src/cuttingplanes.jl | 11 - src/datatypes.jl | 23 +- src/loa.jl | 489 ++++++++++++++------------ src/utilities.jl | 13 + test/constraints/loa.jl | 2 +- 6 files changed, 310 insertions(+), 278 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 20fca5a5..a72ede75 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -435,9 +435,9 @@ end #OA cut sites: one site per active support, with per-support restrictions #of `x_values` and `var_map` -DP.cut_sites(bvs::AbstractArray, active::Bool, x_values, var_map, d) = - DP.cut_sites(bvs, fill(active, length(bvs)), x_values, var_map, d) -function DP.cut_sites( +DP.cut_info(bvs::AbstractArray, active::Bool, x_values, var_map, d) = + DP.cut_info(bvs, fill(active, length(bvs)), x_values, var_map, d) +function DP.cut_info( bvs::AbstractArray, actives::AbstractArray, x_values, var_map, d ) @@ -490,8 +490,9 @@ function DP.build_loa_master(model::InfiniteOpt.InfiniteModel, method::DP.LOA) obj_sense = JuMP.objective_sense(master) alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") JuMP.@objective(master, obj_sense, alpha_oa) - m = DP._LOAMaster(master, bin_map, var_map, obj_sense, orig_obj, - alpha_oa, flat_copy_map) + m = (model = master, bin_map = bin_map, var_map = var_map, + obj_sense = obj_sense, orig_obj = orig_obj, + alpha_oa = alpha_oa, obj_ref_map = flat_copy_map) master.ext[:_loa_K] = _detect_K(bin_map) master.ext[:_loa_flat_copy_map] = flat_copy_map return m @@ -530,20 +531,30 @@ function DP.unfix_combo_binaries(model::InfiniteOpt.InfiniteModel, combo) end #Transcribe the BigM'd InfiniteModel, then hand off to the base -#`copy_model_with_constraints` on the flat model. fwd_map is rekeyed to -#InfiniteModel vars for use by `_extract_*_x_values`; obj_ref_map stays -#keyed by flat vars (the flat-level objective OA cut lives there). +#`copy_model_with_constraints` on the flat model. `disjunct_crefs` +#are InfiniteModel-level refs; for the InfiniteOpt path we pass an +#empty Vector and the base includes every non-bound flat constraint +#(flat has no GDPData so no reformulation-constraint set to skip), +#giving us the whole transcribed+BigM'd model. Per-combo flat pruning +#is deferred. fwd_map is rekeyed to InfiniteModel vars for use by +#`read_*_solution`; obj_ref_map stays keyed by flat vars (the +#flat-level objective OA cut lives there). function DP.copy_model_with_constraints( - model::InfiniteOpt.InfiniteModel, method::DP.LOA + model::InfiniteOpt.InfiniteModel, + disjunct_crefs::Vector{<:DP.DisjunctConstraintRef}, + method::DP.LOA ) InfiniteOpt.build_transformation_backend!(model) flat = InfiniteOpt.transformation_model(model) - base = DP.copy_model_with_constraints(flat, method) + flat_crefs = DP.DisjunctConstraintRef{typeof(flat)}[] + base = DP.copy_model_with_constraints(flat, flat_crefs, method) fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Any}() - for v in DP.collect_all_vars(model) - fwd_map[v] = _tv_map(v, base.fwd_map) + decision_vars = DP.collect_all_vars(model) + for v in decision_vars + fwd_map[v] = _tv_map(v, base.sub.fwd_map) end - return DP._LOAFeasSubmodel(base.model, fwd_map, base.fwd_map) + return (sub = DP.GDPSubmodel(base.sub.model, decision_vars, fwd_map), + obj_ref_map = base.sub.fwd_map) end #read per-support JuMP values from either a vector of flat vars or a @@ -554,7 +565,7 @@ _read_values(v) = Float64(JuMP.value(v)) #extract per-support x-values from the InfiniteModel NLP; objective #x-values are keyed by the flat transcription vars for the objective cut -function DP.extract_primary_x_values(model::InfiniteOpt.InfiniteModel) +function DP.read_primary_solution(model::InfiniteOpt.InfiniteModel) x_vals = Dict{JuMP.AbstractVariableRef, Any}() for v in DP.collect_all_vars(model) JuMP.is_fixed(v) && continue @@ -565,15 +576,10 @@ end #extract per-support x-values from the flat feas submodel. x_vals keys are #InfiniteModel vars (via fwd_map); obj_x_values keys are flat vars. -function DP.extract_feas_x_values( - model::InfiniteOpt.InfiniteModel, feas::DP._LOAFeasSubmodel +function DP.read_feas_solution( + model::InfiniteOpt.InfiniteModel, feas ) - x_vals = Dict{JuMP.AbstractVariableRef, Any}() - for v in DP.collect_all_vars(model) - JuMP.is_fixed(v) && continue - haskey(feas.fwd_map, v) || continue - x_vals[v] = _read_values(feas.fwd_map[v]) - end + x_vals = DP.extract_solution(feas.sub) obj_xv = Dict{JuMP.VariableRef, Float64}() for (flat_v, feas_v) in feas.obj_ref_map obj_xv[flat_v] = Float64(JuMP.value(feas_v)) diff --git a/src/cuttingplanes.jl b/src/cuttingplanes.jl index dbc818e5..ddb3710d 100644 --- a/src/cuttingplanes.jl +++ b/src/cuttingplanes.jl @@ -18,17 +18,6 @@ function extract_solution(model::JuMP.AbstractModel) v => [JuMP.value(v)] for v in dvars) end -# Extract solution from a GDPSubmodel (SEP path). -function extract_solution(sub::GDPSubmodel) - V = eltype(sub.decision_vars) - T = JuMP.value_type(typeof(sub.model)) - sol = Dict{V, Vector{T}}() - for var in sub.decision_vars - sol[var] = JuMP.value.(sub.fwd_map[var]) - end - return sol -end - # Set quadratic separation objective: min Σ (x_k - rBM_k)². function _set_separation_objective( sub::GDPSubmodel, diff --git a/src/datatypes.jl b/src/datatypes.jl index bdcd4c2e..6abf23c0 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -473,25 +473,24 @@ end """ GDPSubmodel{M, V, W} -A unified submodel wrapper used by MBM and cutting plane -reformulations. It encapsulates a flat JuMP optimization -submodel built from a single disjunct's feasible region, -along with mappings back to the original model's variables. +A JuMP submodel paired with the parent model's decision variables +and a map from each parent variable to its submodel representation. ## Fields -- `model::M`: The JuMP submodel representing a disjunct's - feasible region (constraints and variable bounds). -- `decision_vars::Vector{V}`: Ordered decision variables in - the submodel, matching the original model's ordering. -- `fwd_map::Dict{V, Vector{W}}`: Forward map from original - model variables to their submodel counterparts. +- `model::M`: The JuMP submodel. +- `decision_vars::Vector{V}`: Parent-model decision variables in + the order used by the submodel. +- `fwd_map::Dict{V, W}`: Map from each parent-model variable to + its submodel counterpart. `W` can be a single variable + reference (1:1 copy) or a collection of references (e.g. one + per transcription support). """ struct GDPSubmodel{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, - W <: JuMP.AbstractVariableRef} + W} model::M decision_vars::Vector{V} - fwd_map::Dict{V, Vector{W}} + fwd_map::Dict{V, W} end """ diff --git a/src/loa.jl b/src/loa.jl index 3f750fb3..81cbd3e9 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -34,40 +34,6 @@ struct LOA{O, P} <: AbstractReformulationMethod end end -################################################################################ -# DATA STRUCTURES -################################################################################ -# Master MILP state. orig_obj is the original objective (for OA cuts); -# alpha_oa replaces it (Türkay & Grossmann 1996). obj_ref_map maps the -# variables in orig_obj to master variables. -mutable struct _LOAMaster{M <: JuMP.AbstractModel, B, V} - model::M - bin_map::B - var_map::V - obj_sense::_MOI.OptimizationSense - orig_obj::Any - alpha_oa::Any - obj_ref_map::Any -end - -# Feasibility-restoration submodel (Viswanathan & Grossmann 1990, NLPF). -# Standalone model with a shared scalar slack `u` embedded into every JuMP -# constraint and `min u` as the objective. When the primary NLP is -# infeasible, binaries are fixed here and the submodel is solved to -# produce a least-infeasible point for OA cut generation. Keeping it -# separate leaves the original NLP model clean (no `u`, no slackened -# constraints). -# -# fwd_map: original-model var -> feas var, used for binary fixing and -# value extraction. -# obj_ref_map: original-objective var -> feas var, used to linearize the -# original objective at the feas point. -struct _LOAFeasSubmodel{M <: JuMP.AbstractModel} - model::M - fwd_map::Any - obj_ref_map::Any -end - ################################################################################ # MAIN ALGORITHM ################################################################################ @@ -76,16 +42,8 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) combos = _set_covering_combos(model) reformulate_model(model, BigM(method.M_value)) - #build the feasibility-restoration submodel as a separate copy, then - #embed the shared scalar slack `u` in every constraint (V&G 1990, - #NLPF). The original model stays clean: no slacks, no integrality - #relaxation, no objective change. - feas = copy_model_with_constraints(model, method) - _embed_feas_slack(feas) - master = build_loa_master(model, method) reform_map = _build_reform_map(model) - JuMP.relax_integrality(model) JuMP.set_optimizer(model, method.nlp_optimizer) JuMP.set_silent(model) @@ -93,8 +51,12 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) best_obj = _worst_obj(sense_token) is_better(o) = _is_better(sense_token, o, best_obj) best_result = nothing + + #Initialization Procedure (Türkay & Grossmann 1996 §2.2): solve the + #set-covering NLPs to seed the master with at least one OA cut per + #disjunct before the main iteration. for combo in combos - result = _solve_nlp(model, combo, method, reform_map, feas) + result = _solve_nlp(model, combo, method, reform_map) _add_no_good_cut(model, master, combo) _add_oa_cuts(model, master, result, method) if result.feasible && is_better(result.objective) @@ -110,7 +72,7 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) master_bound = JuMP.objective_value(master.model) _loa_converged(best_obj, master_bound, sense_token, method) && break combo = _extract_combo(model, master) - result = _solve_nlp(model, combo, method, reform_map, feas) + result = _solve_nlp(model, combo, method, reform_map) _add_no_good_cut(model, master, combo) _add_oa_cuts(model, master, result, method) if result.feasible && is_better(result.objective) @@ -120,6 +82,8 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) end _finalize_model(model, best_result) + _set_solution_method(model, method) + _set_ready_to_optimize(model, true) return end @@ -137,7 +101,20 @@ end ################################################################################ # EXTENSION POINTS ################################################################################ -#build master MILP from the BigM-reformulated original +""" + build_loa_master(model::JuMP.AbstractModel, method::LOA) + +Build the LOA master MILP as a deep copy of the BigM-reformulated +`model`, install the `alpha_oa` objective auxiliary, and wire up +`method.mip_optimizer`. OA and no-good cuts are added to the returned +master each iteration of the main LOA loop. + +## Returns +- `NamedTuple` with `model` (master MILP), `bin_map` + (indicator→binary), `var_map` (original→master var), + `obj_sense`, `orig_obj`, `alpha_oa`, and `obj_ref_map` + (objective-side map for linearization). +""" function build_loa_master(model::JuMP.AbstractModel, method::LOA) orig_obj = JuMP.objective_function(model) master, copy_map = JuMP.copy_model(model) @@ -151,101 +128,132 @@ function build_loa_master(model::JuMP.AbstractModel, method::LOA) obj_sense = JuMP.objective_sense(master) alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") JuMP.@objective(master, obj_sense, alpha_oa) - return _LOAMaster( - master, bin_map, copy_map, obj_sense, orig_obj, alpha_oa, copy_map) + return (model = master, bin_map = bin_map, var_map = copy_map, + obj_sense = obj_sense, orig_obj = orig_obj, + alpha_oa = alpha_oa, obj_ref_map = copy_map) end """ - copy_model_with_constraints(model, method::LOA) - -Build an LOA feas submodel: a fresh deep copy of `model` with every -non-bound constraint copied over, wired up with `method.nlp_optimizer`. -The returned submodel is a raw copy; slack embedding and the `min u` -objective are applied separately via [`_embed_feas_slack`]. Shares the -dispatch entry point with MBM's [`copy_model_with_constraints`](@ref), -which instead takes an explicit subset of disjunct constraints. + copy_model_with_constraints( + model::JuMP.AbstractModel, + disjunct_crefs::Vector{<:DisjunctConstraintRef}, + method::LOA + ) + +Build a raw feasibility-restoration submodel by deep-copying `model`'s +decision variables, then including only the problem's global +constraints (those not added by BigM reformulation) and the original +pre-BigM constraints of `disjunct_crefs`. The shared slack `u` and +`min u` objective are applied separately by [`_embed_feas_slack`](@ref). +Mirrors MBM's subset-taking [`copy_model_with_constraints`](@ref). + +## Returns +- `NamedTuple` with `sub::GDPSubmodel` (copied model and + original→copy forward map) and `obj_ref_map` (objective-side + reference map for linearizing the original objective at the feas + point). """ function copy_model_with_constraints( - model::JuMP.AbstractModel, method::LOA + model::JuMP.AbstractModel, + disjunct_crefs::Vector{<:DisjunctConstraintRef}, + method::LOA ) - var_type = JuMP.variable_ref_type(model) + V = JuMP.variable_ref_type(model) sub_model = _copy_model(model) - fwd_map = Dict{var_type, var_type}() - for var in collect_all_vars(model) + decision_vars = collect_all_vars(model) + fwd_map = Dict{V, V}() + for var in decision_vars fwd_map[var] = variable_copy(sub_model, var) end VT = JuMP.variable_ref_type(typeof(model)) + reform_set = is_gdp_model(model) ? + Set(_reformulation_constraints(model)) : Set() for (F, S) in JuMP.list_of_constraint_types(model) F === VT && continue for cref in JuMP.all_constraints(model, F, S) + cref in reform_set && continue con = JuMP.constraint_object(cref) expr = _replace_variables_in_constraint(con.func, fwd_map) JuMP.@constraint(sub_model, expr in con.set) end end + for cref in disjunct_crefs + con = JuMP.constraint_object(cref) + expr = _replace_variables_in_constraint(con.func, fwd_map) + JuMP.@constraint(sub_model, expr in con.set) + end JuMP.set_optimizer(sub_model, method.nlp_optimizer) JuMP.set_silent(sub_model) - return _LOAFeasSubmodel(sub_model, fwd_map, fwd_map) + return (sub = GDPSubmodel(sub_model, decision_vars, fwd_map), + obj_ref_map = fwd_map) end -#embed shared scalar slack `u` into every constraint of the feas -#submodel, relax integrality, and set `min u` as the objective. Converts -#the raw copy produced by `copy_model_with_constraints` into a V&G 1990 -#NLPF feasibility-restoration problem. -function _embed_feas_slack(feas::_LOAFeasSubmodel) - u = JuMP.@variable(feas.model, base_name = "_loa_u", lower_bound = 0.0) - _slacken_model_constraints(feas.model, u) - JuMP.relax_integrality(feas.model) - JuMP.@objective(feas.model, Min, u) - return -end - -#replace every JuMP constraint in `model` (excluding variable bounds) with -#its slackened counterpart via `_slacken`. Shared by finite and InfiniteOpt -#feas submodel builders. -function _slacken_model_constraints(model, u) - VT = JuMP.variable_ref_type(typeof(model)) +# Convert the raw copy from `copy_model_with_constraints` into a V&G 1990 +# NLPF problem: shared slack `u` embedded in every constraint via +# `_slacken`, integrality relaxed, `min u` as the objective. +function _embed_feas_slack(feas) + m = feas.sub.model + u = JuMP.@variable(m, base_name = "_loa_u", lower_bound = 0.0) + VT = JuMP.variable_ref_type(typeof(m)) to_slacken = Any[] - for (F, S) in JuMP.list_of_constraint_types(model) + for (F, S) in JuMP.list_of_constraint_types(m) F === VT && continue - for cref in JuMP.all_constraints(model, F, S) + for cref in JuMP.all_constraints(m, F, S) push!(to_slacken, cref) end end for cref in to_slacken - JuMP.is_valid(model, cref) || continue + JuMP.is_valid(m, cref) || continue con = JuMP.constraint_object(cref) for (sf, ss) in _slacken(con.func, con.set, u) - JuMP.@constraint(model, sf in ss) + JuMP.@constraint(m, sf in ss) end - JuMP.delete(model, cref) + JuMP.delete(m, cref) end + JuMP.relax_integrality(m) + JuMP.@objective(m, Min, u) return end -#fix/unfix indicator binaries on the original model +""" + fix_combo_binaries(model::JuMP.AbstractModel, combo)::Nothing + +Fix every indicator binary in `combo` to its active / inactive value +on `model`. Complement indicators (stored as `1 - other_bv`) are +handled by fixing the underlying variable to the complement value via +[`fix_fv`](@ref). +""" function fix_combo_binaries(model, combo) for (ind, active) in combo - bv = _indicator_to_binary(model)[ind] - JuMP.fix(bv, active ? 1.0 : 0.0; force = true) + fix_fv(_indicator_to_binary(model)[ind], active) end end + +""" + unfix_combo_binaries(model::JuMP.AbstractModel, combo)::Nothing + +Undo the effect of [`fix_combo_binaries`](@ref): unfix every indicator +binary in `combo` on `model`. +""" function unfix_combo_binaries(model, combo) for (ind, _) in combo - bv = _indicator_to_binary(model)[ind] - JuMP.is_fixed(bv) && JuMP.unfix(bv) + unfix_fv(_indicator_to_binary(model)[ind]) end end ################################################################################ # SET COVERING INITIALIZATION ################################################################################ +#Türkay & Grossmann (1996) §2.2: pick a minimal set of combos that +#activates every indicator at least once, so the master starts with an +#OA cut for each disjunct. We enumerate nested disjunctions alongside +#top-level ones — inconsistent combos (nested active under inactive +#parent) are handled by feasibility restoration at NLP-solve time. function _set_covering_combos(model::JuMP.AbstractModel) M = typeof(model) LVR = LogicalVariableRef{M} per_disj = Vector{Tuple{DisjunctionIndex, LVR}}[] for (idx, disj_data) in _disjunctions(model) - disj_data.constraint.nested && continue push!(per_disj, [(idx, ind) for ind in disj_data.constraint.indicators]) end isempty(per_disj) && return Dict{LVR, Bool}[] @@ -287,78 +295,65 @@ end ################################################################################ # NLP SUBPROBLEM ################################################################################ -# Termination tokens. `_solve_nlp` and `_run_feas_restoration` dispatch on -# these to build the result tuple without inspecting the solver status at -# multiple call sites. -struct _Feasible end -struct _Infeasible end -_nlp_status(model) = JuMP.is_solved_and_feasible(model) ? - _Feasible() : _Infeasible() - -#solve primary NLP for a fixed combo; on infeasibility, dispatch to the -#feasibility-restoration submodel (min u) to get a least-infeasible point -#for OA cut generation (Viswanathan & Grossmann 1990) +# Solve the primary NLP for a fixed combo. If infeasible, build a fresh +# V&G 1990 NLPF submodel (globals + active-disjunct originals, with +# shared slack `u` and `min u` objective), solve it for a least- +# infeasible point to generate OA cuts from, then discard it. Returns +# a named tuple with +# `combo / x_values / duals / objective / feasible / obj_x_values`. function _solve_nlp( - model::M, combo, method::LOA, reform_map, feas::_LOAFeasSubmodel + model::M, combo, method::LOA, reform_map ) where {M <: JuMP.AbstractModel} + DCRef = DisjunctConstraintRef{M} + empty_duals = Dict{DCRef, Any}() fix_combo_binaries(model, combo) JuMP.optimize!(model, ignore_optimize_hook = true) - result = _nlp_primary_result( - _nlp_status(model), model, combo, reform_map, feas) + if JuMP.is_solved_and_feasible(model) + x_vals, obj_xv = read_primary_solution(model) + duals = _collect_nlp_duals(model, combo, reform_map) + obj_val = JuMP.objective_value(model) + unfix_combo_binaries(model, combo) + return (combo = combo, x_values = x_vals, duals = duals, + objective = obj_val, feasible = true, obj_x_values = obj_xv) + end unfix_combo_binaries(model, combo) - return result -end - -#feasible primary NLP: pack up x-values, duals, objective -function _nlp_primary_result( - ::_Feasible, model::M, combo, reform_map, feas - ) where {M <: JuMP.AbstractModel} - x_vals, obj_xv = extract_primary_x_values(model) - duals = _collect_nlp_duals(model, combo, reform_map) - return (combo = combo, x_values = x_vals, duals = duals, - objective = JuMP.objective_value(model), feasible = true, - obj_x_values = obj_xv) -end - -#infeasible primary NLP: hand off to the feas-restoration submodel -function _nlp_primary_result( - ::_Infeasible, model, combo, reform_map, feas - ) - return _run_feas_restoration(model, feas, combo) -end - -#fix binaries on the feas submodel, solve min u, and dispatch on outcome -function _run_feas_restoration( - model::M, feas::_LOAFeasSubmodel, combo - ) where {M <: JuMP.AbstractModel} - _fix_feas_combo_binaries(feas, model, combo) - JuMP.optimize!(feas.model) - result = _nlp_feas_result(_nlp_status(feas.model), model, feas, combo) - _unfix_feas_combo_binaries(feas, model, combo) - return result -end - -#feas submodel solved: return the least-infeasible point for OA cuts -function _nlp_feas_result( - ::_Feasible, model::M, feas, combo - ) where {M <: JuMP.AbstractModel} - x_vals, obj_xv = extract_feas_x_values(model, feas) - return (combo = combo, x_values = x_vals, - duals = Dict{DisjunctConstraintRef{M}, Any}(), - objective = Inf, feasible = false, obj_x_values = obj_xv) -end - -#feas submodel also infeasible: return empty result (no OA cut) -function _nlp_feas_result( - ::_Infeasible, model::M, feas, combo - ) where {M <: JuMP.AbstractModel} - empty = Dict{JuMP.AbstractVariableRef, Any}() - return (combo = combo, x_values = empty, - duals = Dict{DisjunctConstraintRef{M}, Any}(), - objective = Inf, feasible = false, obj_x_values = empty) + # Build a fresh per-combo NLPF: only the active-disjunct originals + # + globals, slackened with a shared `u`. Discarded after solve. + active_crefs = _active_disjunct_crefs(model, combo) + feas = copy_model_with_constraints(model, active_crefs, method) + _embed_feas_slack(feas) + feas_fwd = feas.sub.fwd_map + for (ind, val) in combo + orig_bv = _indicator_to_binary(model)[ind] + haskey(feas_fwd, orig_bv) || continue + fix_fv(feas_fwd[orig_bv], val) + end + JuMP.optimize!(feas.sub.model) + feas_ok = JuMP.is_solved_and_feasible(feas.sub.model) + x_vals = feas_ok ? first(read_feas_solution(model, feas)) : + Dict{JuMP.AbstractVariableRef, Any}() + return (combo = combo, x_values = x_vals, duals = empty_duals, + objective = Inf, feasible = false, obj_x_values = x_vals) +end + +# Collect the `DisjunctConstraintRef`s of every active indicator in +# `combo`. Used to feed `copy_model_with_constraints` the minimal +# per-combo subset for NLPF construction. +function _active_disjunct_crefs(model::M, combo) where {M} + crefs = DisjunctConstraintRef{M}[] + for (ind, active) in combo + any_active(active) || continue + haskey(_indicator_to_constraints(model), ind) || continue + for cref in _indicator_to_constraints(model)[ind] + cref isa DisjunctConstraintRef || continue + push!(crefs, cref) + end + end + return crefs end -#collect duals of reformulated disjunct constraints for active disjuncts +# Sum the duals of BigM-reformulated constraints for each active +# disjunct's original constraint ref. Used for OA cut generation. function _collect_nlp_duals( model::M, combo, reform_map ) where {M <: JuMP.AbstractModel} @@ -375,10 +370,16 @@ function _collect_nlp_duals( return duals end -#extract x-values from the primary NLP. Returns (x_vals, obj_x_values) -#where obj_x_values is keyed by whatever variables the original objective -#uses (same as x_vals for finite; flat-transcription vars for infinite). -function extract_primary_x_values(model::JuMP.AbstractModel) +""" + read_primary_solution(model::JuMP.AbstractModel)::Tuple{Dict, Dict} + +Read the primal solution of the primary NLP after a feasible solve. +Returns `(x_values, obj_x_values)` where both dicts are keyed by +variable reference; `obj_x_values` matches `x_values` for finite +models. The InfiniteOpt extension overrides this to return the +flat-transcription dict as the second element. +""" +function read_primary_solution(model::JuMP.AbstractModel) x_vals = Dict{JuMP.AbstractVariableRef, Any}() for v in JuMP.all_variables(model) JuMP.is_fixed(v) && continue @@ -387,44 +388,52 @@ function extract_primary_x_values(model::JuMP.AbstractModel) return x_vals, x_vals end -#extract x-values from the feasibility submodel, keyed by original-model -#variables via `feas.fwd_map`. Same return contract as the primary path. -function extract_feas_x_values( - model::JuMP.AbstractModel, feas::_LOAFeasSubmodel - ) - x_vals = Dict{JuMP.AbstractVariableRef, Any}() - for v in JuMP.all_variables(model) - JuMP.is_fixed(v) && continue - haskey(feas.fwd_map, v) || continue - x_vals[v] = JuMP.value(feas.fwd_map[v]) - end +""" + read_feas_solution( + model::JuMP.AbstractModel, feas + )::Tuple{Dict, Dict} + +Read the primal solution of the feasibility-restoration NLPF after a +feasible solve, keyed by original-model variables via `extract_solution` +on `feas.sub`. Same return contract as [`read_primary_solution`](@ref). +""" +function read_feas_solution(model::JuMP.AbstractModel, feas) + x_vals = extract_solution(feas.sub) return x_vals, x_vals end -#fix/unfix indicator binaries on the feas submodel via fwd_map. Dispatches -#through `fix_fv`/`unfix_fv` to handle scalar feas vars (finite) and -#per-support vectors (InfiniteOpt flat transcription). -function _fix_feas_combo_binaries(feas::_LOAFeasSubmodel, model, combo) - for (ind, val) in combo - orig_bv = _indicator_to_binary(model)[ind] - haskey(feas.fwd_map, orig_bv) || continue - fix_fv(feas.fwd_map[orig_bv], val) - end -end -function _unfix_feas_combo_binaries(feas::_LOAFeasSubmodel, model, combo) - for (ind, _) in combo - orig_bv = _indicator_to_binary(model)[ind] - haskey(feas.fwd_map, orig_bv) || continue - unfix_fv(feas.fwd_map[orig_bv]) - end -end +""" + fix_fv(bv, val::Bool)::Nothing + +Fix a binary indicator reference `bv` to `val`. Dispatches on: +- `AbstractVariableRef`: calls `JuMP.fix(bv, val ? 1.0 : 0.0; force = true)`. +- `GenericAffExpr`: the complement-indicator form `1 - other_bv`; fix + `other_bv` to the complement of `val`. -#fix/unfix a feas-submodel binary variable at a scalar Bool value +The InfiniteOpt extension adds an `AbstractArray` dispatch for +per-support indicator vectors. +""" fix_fv(bv, val::Bool) = JuMP.fix(bv, val ? 1.0 : 0.0; force = true) +function fix_fv(bv::JuMP.GenericAffExpr, val::Bool) + under, coeff = only(bv.terms) + JuMP.fix(under, val ? 0.0 : 1.0; force = true) +end + +""" + unfix_fv(bv)::Nothing + +Undo [`fix_fv`](@ref) on `bv`. No-op if `bv` is not currently fixed. +For complement AffExprs, unfixes the underlying variable. +""" function unfix_fv(bv) JuMP.is_fixed(bv) && JuMP.unfix(bv) return end +function unfix_fv(bv::JuMP.GenericAffExpr) + under = only(keys(bv.terms)) + JuMP.is_fixed(under) && JuMP.unfix(under) + return +end ################################################################################ # REFORM MAP (BigM constraint index) @@ -436,7 +445,6 @@ function _build_reform_map(model::M) where {M <: JuMP.AbstractModel} rmap = Dict{DisjunctConstraintRef{M}, Vector{CRT}}() idx = 1 for (_, disj_data) in _disjunctions(model) - disj_data.constraint.nested && continue for ind in disj_data.constraint.indicators haskey(_indicator_to_constraints(model), ind) || continue for cref in _indicator_to_constraints(model)[ind] @@ -478,11 +486,10 @@ end #extract combo from master solution. `combo_val` dispatches on scalar vs #array bin_map values (extension adds the array method for per-support). function _extract_combo( - model::M, master::_LOAMaster + model::M, master ) where {M <: JuMP.AbstractModel} combo = Dict{LogicalVariableRef{M}, Any}() for (_, disj_data) in _disjunctions(model) - disj_data.constraint.nested && continue for ind in disj_data.constraint.indicators haskey(master.bin_map, ind) || continue combo[ind] = combo_val(master.bin_map[ind]) @@ -490,11 +497,16 @@ function _extract_combo( end return combo end +""" + combo_val(bv)::Bool + +Round the master's current binary solution for indicator ref `bv` to a +`Bool`. The InfiniteOpt extension adds an `AbstractArray` dispatch +that returns a `Vector{Bool}` per support. +""" combo_val(bv) = round(JuMP.value(bv)) > 0.5 -#no-good cut on the master excluding the current combo. `add_ng_terms` -#dispatches on scalar vs array bin_map / active values. -function _add_no_good_cut(model, master::_LOAMaster, combo) +function _add_no_good_cut(model, master, combo) cut_expr = JuMP.AffExpr(0.0) for (ind, active) in combo haskey(master.bin_map, ind) || continue @@ -502,6 +514,16 @@ function _add_no_good_cut(model, master::_LOAMaster, combo) end JuMP.@constraint(master.model, cut_expr >= 1.0) end + +""" + add_ng_terms(cut, bv, active::Bool)::Nothing + +Assemble one indicator's contribution to the no-good cut `cut_expr >= +1`: add `1 - y_j` if the indicator was active in the excluded combo, +or `y_j` otherwise. The InfiniteOpt extension adds an +`AbstractArray`/`AbstractArray{Bool}` dispatch that folds per-support +contributions. +""" function add_ng_terms(cut, bv, active::Bool) if active JuMP.add_to_expression!(cut, -1.0, bv) @@ -512,8 +534,13 @@ function add_ng_terms(cut, bv, active::Bool) return end -#predicate used by OA cut generation: is there any active indicator in -#this combo entry? Scalar Bool case; extension adds `AbstractVector{Bool}` +""" + any_active(active)::Bool + +Return `true` if any indicator value in `active` is truthy. The base +dispatch is the trivial scalar `Bool` case; the InfiniteOpt extension +adds an `AbstractVector{Bool}` dispatch for per-support indicators. +""" any_active(active::Bool) = active ################################################################################ @@ -543,8 +570,9 @@ _slacken(f, set, u) = [(f, set)] ################################################################################ # OA CUT GENERATION ################################################################################ -#sense-dependent coefficients dispatched on Val(obj_sense). See usage in -#`_add_objective_oa_cut` and `_add_disjunct_oa_cuts`. +# Sense-dependent coefficients for OA and convergence calls. Dispatched +# on `Val(master.obj_sense)` so downstream call sites can read like +# regular Julia without branching on the sense. _disjunct_cut_coeffs(::Val{_MOI.MIN_SENSE}) = (-1, 1) _disjunct_cut_coeffs(::Val{_MOI.MAX_SENSE}) = (1, -1) _worst_obj(::Val{_MOI.MIN_SENSE}) = Inf @@ -556,16 +584,16 @@ _gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best _flip_sense(::Val{_MOI.MIN_SENSE}) = Val(_MOI.MAX_SENSE) _flip_sense(::Val{_MOI.MAX_SENSE}) = Val(_MOI.MIN_SENSE) -function _add_oa_cuts(model, master::_LOAMaster, result, method::LOA) +function _add_oa_cuts(model, master, result, method::LOA) isempty(result.x_values) && return _add_objective_oa_cut(master, result) _add_disjunct_oa_cuts(model, master, result, method) return end -#linearize the original objective at `result.obj_x_values` and add it as -#a cut on `alpha_oa` with direction determined by `master.obj_sense` -function _add_objective_oa_cut(master::_LOAMaster, result) +# Linearize the original objective at the NLP's solution and add the +# bounding cut `lin ≤ α` (min) or `lin ≥ α` (max) on the master. +function _add_objective_oa_cut(master, result) lin = _linearize_at( master.orig_obj, result.obj_x_values, master.obj_ref_map) _add_obj_cut(Val(master.obj_sense), master, lin) @@ -576,11 +604,11 @@ _add_obj_cut(::Val{_MOI.MIN_SENSE}, master, lin) = _add_obj_cut(::Val{_MOI.MAX_SENSE}, master, lin) = JuMP.@constraint(master.model, lin >= master.alpha_oa) -#add V&G 1990 augmented-penalty OA cuts for each active disjunct's -#nonlinear constraints. `cut_sites` yields the per-cut tuples: 1 site -#for finite, K sites for the InfiniteOpt case (extension override). +# Add V&G 1990 augmented-penalty OA cuts for each active disjunct's +# nonlinear constraints: fresh per-cut slack `σ_ik` with a penalty term +# in the master objective, and cut body `s (lin - rhs) - σ ≤ M(1 - y)`. function _add_disjunct_oa_cuts( - model, master::_LOAMaster, result, method::LOA + model, master, result, method::LOA ) sgn, pen = _disjunct_cut_coeffs(Val(master.obj_sense)) for (ind, active) in result.combo @@ -594,47 +622,44 @@ function _add_disjunct_oa_cuts( con.func isa JuMP.GenericAffExpr && continue d = get(result.duals, orig_cref, nothing) d === nothing && continue - for (bv, xk, smap, d_k) in cut_sites( + rhs = _set_rhs(con.set) + for (bv, xk, smap, d_k) in cut_info( master.bin_map[ind], active, result.x_values, master.var_map, d) - dv = _dv(d_k) - s = sign(sgn * dv) + s = sign(sgn * _dv(d_k)) s == 0 && continue - _add_one_disjunct_oa_cut( - master, con, method, bv, xk, smap, s, pen) + lin_expr = _linearize_at(con.func, xk, smap) + slack = JuMP.@variable(master.model, + lower_bound = 0.0, upper_bound = method.max_slack) + JuMP.set_objective_function(master.model, + JuMP.objective_function(master.model) + + pen * method.OA_penalty_factor * slack) + JuMP.@constraint(master.model, + s * (lin_expr - rhs) - slack <= + method.M_value * (1 - bv)) end end end end -#one OA cut site: (binary, x_values, var_map, dual). Scalar case yields -#a single-element tuple collection; extension's array dispatch yields -#K per-support sites with sliced x_values and var_map. -cut_sites(bv, active::Bool, x_values, var_map, d) = +""" + cut_info(bv, active, x_values, var_map, d) + +Yield the inputs `(bv, xk, smap, d_k)` needed to emit each OA cut for +one active disjunct constraint. The scalar base returns one tuple +(one cut per constraint). The InfiniteOpt extension adds an +`AbstractArray` dispatch that yields K tuples with per-support-sliced +`x_values`, `var_map`, and dual (one cut per active support). +""" +cut_info(bv, active::Bool, x_values, var_map, d) = ((bv, x_values, var_map, d),) -#extract a scalar dual value from a dual "cell" that may be a vector -#(from a vector-valued reform constraint in the infinite case) +# Collapse a dual value (scalar for a single reformulated constraint, +# vector for reform constraints that produced multiple JuMP constraints +# like Interval / EqualTo / Zeros) to a scalar sign-carrier. _dv(d::Number) = d _dv(d) = sum(d) -#add a single disjunct OA cut with augmented-penalty slack -function _add_one_disjunct_oa_cut( - master::_LOAMaster, con, method, bv, x_values, var_map, s, pen - ) - lin_expr = _linearize_at(con.func, x_values, var_map) - rhs = _set_rhs(con.set) - slack = JuMP.@variable(master.model, - lower_bound = 0.0, upper_bound = method.max_slack) - JuMP.set_objective_function(master.model, - JuMP.objective_function(master.model) + - pen * method.OA_penalty_factor * slack) - JuMP.@constraint(master.model, - s * (lin_expr - rhs) - slack <= - method.M_value * (1 - bv)) - return -end - ################################################################################ # CONVERGENCE CHECK ################################################################################ diff --git a/src/utilities.jl b/src/utilities.jl index 431c539a..9dd168e2 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -62,6 +62,19 @@ function reformulate_and_relax( return sub, undo_relax end +""" + extract_solution(sub::GDPSubmodel) + +Read the primal solution of `sub.model` after a solve, keyed by the +parent-model decision variables via `sub.fwd_map`. Shape follows +`fwd_map` values: `Vector`-valued fwd_maps (MBM/CP) yield per-support +`Vector`s; scalar fwd_maps (LOA feas) yield scalars. +""" +function extract_solution(sub::GDPSubmodel) + return Dict( + var => JuMP.value.(sub.fwd_map[var]) for var in sub.decision_vars) +end + ################################################################################ # LOGICAL VARIABLE RELAXATION ################################################################################ diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 3b92cd5b..8009ece0 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -51,7 +51,7 @@ function test_no_good_cut() @disjunction(model, Y) DP.reformulate_model(model, BigM(1e9)) - master = DP._build_loa_master( + master = DP.build_loa_master( model, LOA(HiGHS.Optimizer)) master_model = master.model From eb65da6d506b0f6566b54befe57b20c446912151 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sat, 25 Apr 2026 13:34:18 -0400 Subject: [PATCH 34/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 51 --------------------- src/loa.jl | 64 ++++++++++++++++++++------- 2 files changed, 49 insertions(+), 66 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index a72ede75..ce86ba8f 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -405,57 +405,6 @@ function _flat_xk(model::InfiniteOpt.InfiniteModel, x_values) return fxk end -#per-support dispatch methods: the base DP.jl LOA code calls scalar-form -#helpers; these array methods let the same code path handle vector-valued -#bin_map / var_map / x_values / active entries without any extra branches -#in the base file. - -DP.fix_fv(bvs::AbstractArray, val::Bool) = - (for bv in bvs; DP.fix_fv(bv, val); end; return) -DP.fix_fv(bvs::AbstractArray, val::AbstractArray) = - (for (bv, v) in zip(bvs, val); DP.fix_fv(bv, v); end; return) -DP.unfix_fv(bvs::AbstractArray) = - (for bv in bvs; DP.unfix_fv(bv); end; return) - -DP.any_active(v::AbstractVector{Bool}) = any(v) - -#combo extraction: round per-support binary values to a Vector{Bool} -DP.combo_val(bvs::AbstractArray) = Bool.(round.(JuMP.value.(bvs))) - -#no-good cut: fold one scalar term per (binary, active) pair. The scalar- -#active method handles the set-covering phase where combos are Bool-valued. -DP.add_ng_terms(cut, bvs::AbstractArray, active::Bool) = - DP.add_ng_terms(cut, bvs, fill(active, length(bvs))) -function DP.add_ng_terms(cut, bvs::AbstractArray, actives::AbstractArray) - for (bv, a) in zip(bvs, actives) - DP.add_ng_terms(cut, bv, a) - end - return -end - -#OA cut sites: one site per active support, with per-support restrictions -#of `x_values` and `var_map` -DP.cut_info(bvs::AbstractArray, active::Bool, x_values, var_map, d) = - DP.cut_info(bvs, fill(active, length(bvs)), x_values, var_map, d) -function DP.cut_info( - bvs::AbstractArray, actives::AbstractArray, - x_values, var_map, d - ) - sites = Any[] - for k in 1:length(bvs) - actives[k] || continue - smap_k = Dict{Any, Any}( - v => (mv isa AbstractVector ? mv[k] : mv) - for (v, mv) in var_map) - x_k = Dict{Any, Any}( - v => (xv isa AbstractVector ? xv[k] : xv) - for (v, xv) in x_values) - d_k = d isa AbstractVector ? d[k] : d - push!(sites, (bvs[k], x_k, smap_k, d_k)) - end - return sites -end - #detect number of supports from a bin_map; used below in `build_loa_master` function _detect_K(bin_map) for (_, bvs) in bin_map diff --git a/src/loa.jl b/src/loa.jl index 81cbd3e9..0c4fdc65 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -409,15 +409,18 @@ Fix a binary indicator reference `bv` to `val`. Dispatches on: - `AbstractVariableRef`: calls `JuMP.fix(bv, val ? 1.0 : 0.0; force = true)`. - `GenericAffExpr`: the complement-indicator form `1 - other_bv`; fix `other_bv` to the complement of `val`. - -The InfiniteOpt extension adds an `AbstractArray` dispatch for -per-support indicator vectors. +- `AbstractArray`: iterate elementwise over `bvs` (and `vals` if also + array). Used by per-support InfiniteOpt indicators. """ fix_fv(bv, val::Bool) = JuMP.fix(bv, val ? 1.0 : 0.0; force = true) function fix_fv(bv::JuMP.GenericAffExpr, val::Bool) under, coeff = only(bv.terms) JuMP.fix(under, val ? 0.0 : 1.0; force = true) end +fix_fv(bvs::AbstractArray, val::Bool) = + (for bv in bvs; fix_fv(bv, val); end; return) +fix_fv(bvs::AbstractArray, vals::AbstractArray) = + (for (bv, v) in zip(bvs, vals); fix_fv(bv, v); end; return) """ unfix_fv(bv)::Nothing @@ -434,6 +437,8 @@ function unfix_fv(bv::JuMP.GenericAffExpr) JuMP.is_fixed(under) && JuMP.unfix(under) return end +unfix_fv(bvs::AbstractArray) = + (for bv in bvs; unfix_fv(bv); end; return) ################################################################################ # REFORM MAP (BigM constraint index) @@ -501,10 +506,11 @@ end combo_val(bv)::Bool Round the master's current binary solution for indicator ref `bv` to a -`Bool`. The InfiniteOpt extension adds an `AbstractArray` dispatch -that returns a `Vector{Bool}` per support. +`Bool`. Scalar form returns `Bool`; `AbstractArray` form returns +`Vector{Bool}` per support for InfiniteOpt indicators. """ combo_val(bv) = round(JuMP.value(bv)) > 0.5 +combo_val(bvs::AbstractArray) = Bool.(round.(JuMP.value.(bvs))) function _add_no_good_cut(model, master, combo) cut_expr = JuMP.AffExpr(0.0) @@ -520,9 +526,8 @@ end Assemble one indicator's contribution to the no-good cut `cut_expr >= 1`: add `1 - y_j` if the indicator was active in the excluded combo, -or `y_j` otherwise. The InfiniteOpt extension adds an -`AbstractArray`/`AbstractArray{Bool}` dispatch that folds per-support -contributions. +or `y_j` otherwise. `AbstractArray` forms (with Bool or per-support +Vector) fold per-support contributions for InfiniteOpt indicators. """ function add_ng_terms(cut, bv, active::Bool) if active @@ -533,15 +538,24 @@ function add_ng_terms(cut, bv, active::Bool) end return end +add_ng_terms(cut, bvs::AbstractArray, active::Bool) = + add_ng_terms(cut, bvs, fill(active, length(bvs))) +function add_ng_terms(cut, bvs::AbstractArray, actives::AbstractArray) + for (bv, a) in zip(bvs, actives) + add_ng_terms(cut, bv, a) + end + return +end """ any_active(active)::Bool -Return `true` if any indicator value in `active` is truthy. The base -dispatch is the trivial scalar `Bool` case; the InfiniteOpt extension -adds an `AbstractVector{Bool}` dispatch for per-support indicators. +Return `true` if any indicator value in `active` is truthy. Scalar +`Bool` returns itself; `AbstractVector{Bool}` reduces with `any` for +per-support InfiniteOpt indicators. """ any_active(active::Bool) = active +any_active(actives::AbstractVector{Bool}) = any(actives) ################################################################################ # LINEARIZATION HELPERS @@ -646,13 +660,33 @@ end cut_info(bv, active, x_values, var_map, d) Yield the inputs `(bv, xk, smap, d_k)` needed to emit each OA cut for -one active disjunct constraint. The scalar base returns one tuple -(one cut per constraint). The InfiniteOpt extension adds an -`AbstractArray` dispatch that yields K tuples with per-support-sliced -`x_values`, `var_map`, and dual (one cut per active support). +one active disjunct constraint. The scalar form returns one tuple +(one cut per constraint). The `AbstractArray` form yields K tuples +with per-support-sliced `x_values`, `var_map`, and dual (one cut per +active support, used for InfiniteOpt indicators). """ cut_info(bv, active::Bool, x_values, var_map, d) = ((bv, x_values, var_map, d),) +cut_info(bvs::AbstractArray, active::Bool, x_values, var_map, d) = + cut_info(bvs, fill(active, length(bvs)), x_values, var_map, d) +function cut_info( + bvs::AbstractArray, actives::AbstractArray, + x_values, var_map, d + ) + sites = Any[] + for k in 1:length(bvs) + actives[k] || continue + smap_k = Dict{Any, Any}( + v => (mv isa AbstractVector ? mv[k] : mv) + for (v, mv) in var_map) + x_k = Dict{Any, Any}( + v => (xv isa AbstractVector ? xv[k] : xv) + for (v, xv) in x_values) + d_k = d isa AbstractVector ? d[k] : d + push!(sites, (bvs[k], x_k, smap_k, d_k)) + end + return sites +end # Collapse a dual value (scalar for a single reformulated constraint, # vector for reform constraints that produced multiple JuMP constraints From 3bf94fa1f27d119b38f8d3e5e41dd18f1eec12cd Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 26 Apr 2026 21:21:59 -0400 Subject: [PATCH 35/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 764 +++++++++++++++---- src/loa.jl | 1012 ++++++++++++------------- src/mbm.jl | 7 +- src/utilities.jl | 145 ++++ test/constraints/loa.jl | 13 +- test/constraints/mbm.jl | 4 +- 6 files changed, 1263 insertions(+), 682 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index ce86ba8f..ff04a82f 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -329,19 +329,18 @@ function DP.copy_and_reformulate( return sub end +# InfiniteOpt-internal primitive: for an InfiniteOpt variable, return +# its per-support values as a Vector. `JuMP.value` returns a scalar +# (finite vars), Vector (1 infinite param), or N-D Array (multiple +# independent param groups). `vcat` lifts a scalar to a 1-element +# Vector; `vec` flattens any N-D Array to a Vector. +_per_support_values(variable::InfiniteOpt.GeneralVariableRef) = + vec(vcat(JuMP.value(variable))) + # Read per-support values from the transformation backend. function DP.extract_solution(model::InfiniteOpt.InfiniteModel) - dvars = DP.collect_cutting_planes_vars(model) - V = eltype(dvars) - T = JuMP.value_type(typeof(model)) - sol = Dict{V, Vector{T}}() - for v in dvars - transcription_var = InfiniteOpt.transformation_variable(v) - var_prefs = InfiniteOpt.parameter_refs(v) - sol[v] = isempty(var_prefs) ? [JuMP.value(transcription_var)] : - JuMP.value.(vec(transcription_var)) - end - return sol + return Dict(variable => _per_support_values(variable) + for variable in DP.collect_cutting_planes_vars(model)) end # Add a pointwise-sum cut directly to the transformation backend and mark @@ -377,163 +376,668 @@ end ################################################################################ # LOA FOR INFINITEMODEL ################################################################################ -# Dispatch overrides for InfiniteModel. The base LOA algorithm in src/loa.jl -# is written for finite (scalar) models; these overrides handle transcription -# to a flat master and per-support binary/variable indexing. - -# Helper: map an InfiniteOpt var to its flat master var(s) via transcription + -# copy_map. Returns a vector for infinite vars, scalar for finite. -function _tv_map(v, copy_map) - tv = InfiniteOpt.transformation_variable(v) - return tv isa AbstractArray ? [copy_map[fv] for fv in vec(tv)] : copy_map[tv] -end - -# Convert InfiniteModel x_values to flat-var x_values (for objective OA cut). -function _flat_xk(model::InfiniteOpt.InfiniteModel, x_values) - fxk = Dict{JuMP.VariableRef, Float64}() - for (v, val) in x_values - tv = InfiniteOpt.transformation_variable(v) - if tv isa AbstractArray - vals = val isa AbstractVector ? val : fill(Float64(val), length(tv)) - for (i, fv) in enumerate(vec(tv)) - fxk[fv] = vals[i] - end - else - fxk[tv] = val isa Number ? Float64(val) : Float64(first(val)) +# Dispatch overrides for InfiniteModel. The base LOA in src/loa.jl is +# written for finite (scalar) models. The InfiniteOpt master and feas +# submodel are themselves InfiniteModels, with per-support handling +# via point evaluation on infinite `GeneralVariableRef`s. + +DP.any_active(actives::AbstractVector{Bool}) = any(actives) + +# `JuMP.value` returns a per-support `Array` for infinite vars and a +# scalar for finite vars. The `> 0.5` cutoff handles solver-side +# integer-feasibility slack (e.g. HiGHS can return 2.75e-40 for a +# "0" binary), where direct `Bool(val)` would `InexactError`. +# `JuMP.value` returns a per-support `Array` for infinite vars and a +# scalar for finite vars; `round(Bool, ·)` handles both via broadcast +# and absorbs solver integer-feasibility slack. +function DP.combination_val(v::InfiniteOpt.GeneralVariableRef) + val = JuMP.value(v) + return val isa AbstractArray ? vec(round.(Bool, val)) : round(Bool, val) +end + +_supports_of(v::InfiniteOpt.GeneralVariableRef) = + vec(InfiniteOpt.supports(only(InfiniteOpt.parameter_refs(v)))) + +_is_point_var(v::InfiniteOpt.GeneralVariableRef) = + InfiniteOpt.dispatch_variable_ref(v) isa InfiniteOpt.PointVariableRef + +# A `MeasureRef` collapses `f(x, t)` over an infinite parameter into +# one ref; a `ParameterFunctionRef` collapses a parameter-dependent +# function value into one ref. Either hides decision-variable +# dependence from MOI Nonlinear AD, so LOA needs to transcribe the +# enclosing expression to recover correct gradients. +DP.is_aggregate_ref(v::InfiniteOpt.GeneralVariableRef) = + InfiniteOpt.dispatch_variable_ref(v) isa Union{ + InfiniteOpt.MeasureRef, InfiniteOpt.ParameterFunctionRef} + +# Resolve a `PointVariableRef` (e.g., `L(0)` from a boundary +# condition) to the master's corresponding point variable: look up +# the underlying infinite var in `var_map`, then point-evaluate at +# the same support values. For all other variable refs (parameters, +# infinite/finite decision vars), fall back to direct lookup. +function DP.replace_variables_in_constraint( + v::InfiniteOpt.GeneralVariableRef, var_map::AbstractDict + ) + if _is_point_var(v) + underlying = InfiniteOpt.infinite_variable_ref(v) + return var_map[underlying](InfiniteOpt.parameter_values(v)...) + end + return var_map[v] +end + +# Per-support fix via point constraints. Used by the LOA feas +# submodel (`fix_indicator(feas_binary, combination_value)`) when the +# master returned a per-support combination. +function DP.fix_indicator( + binary_ref::InfiniteOpt.GeneralVariableRef, + values::AbstractVector{Bool} + ) + model = JuMP.owner_model(binary_ref) + for (k, support) in enumerate(_supports_of(binary_ref)) + JuMP.@constraint(model, + binary_ref(support) == (values[k] ? 1.0 : 0.0)) + end + return +end + +# Bool active arises from `_set_covering_combinations`, which keys +# combinations on `LogicalVariableRef → Bool` regardless of whether +# the indicator is infinite. Broadcast over all supports for an +# infinite indicator; for a finite (or point-variable) ref, fall +# through to the base scalar dispatch. +function DP.add_no_good_terms( + cut, binary_ref::InfiniteOpt.GeneralVariableRef, active::Bool + ) + isempty(InfiniteOpt.parameter_refs(binary_ref)) && + return invoke(DP.add_no_good_terms, + Tuple{Any, Any, Bool}, cut, binary_ref, active) + for support in _supports_of(binary_ref) + DP.add_no_good_terms(cut, binary_ref(support), active) + end + return +end + +# AbstractVector active arises from `combination_val` after a master +# solve, which yields per-support `Vector{Bool}`. +function DP.add_no_good_terms( + cut, binary_ref::InfiniteOpt.GeneralVariableRef, + actives::AbstractVector + ) + supports = _supports_of(binary_ref) + for (k, support) in enumerate(supports) + DP.add_no_good_terms(cut, binary_ref(support), actives[k]) + end + return +end + +function DP.cut_info( + binary_ref::InfiniteOpt.GeneralVariableRef, active::Bool, + linearization_point, variable_map, dual + ) + supports = _supports_of(binary_ref) + return _cut_sites(binary_ref, supports, + fill(true, length(supports)), + linearization_point, variable_map, dual) +end + +function DP.cut_info( + binary_ref::InfiniteOpt.GeneralVariableRef, + actives::AbstractVector, + linearization_point, variable_map, dual + ) + return _cut_sites(binary_ref, _supports_of(binary_ref), actives, + linearization_point, variable_map, dual) +end + +function _cut_sites( + binary_ref::InfiniteOpt.GeneralVariableRef, + supports::AbstractVector, + actives::AbstractVector, + linearization_point::AbstractDict, + variable_map::AbstractDict, + dual + ) + sites = Any[] + for (k, support) in enumerate(supports) + actives[k] || continue + point_var_map = Dict{ + InfiniteOpt.GeneralVariableRef, + InfiniteOpt.GeneralVariableRef + }() + for (variable, master_var) in variable_map + point_var_map[variable] = _at_support(master_var, support) end + point = Dict{InfiniteOpt.GeneralVariableRef, Float64}() + for (variable, point_value) in linearization_point + point[variable] = _at(point_value, k) + end + push!(sites, ( + binary_ref(support), + point, + point_var_map, + _at(dual, k) + )) end - return fxk + return sites end -#detect number of supports from a bin_map; used below in `build_loa_master` -function _detect_K(bin_map) - for (_, bvs) in bin_map - bvs isa AbstractVector && return length(bvs) +# Slice a per-support container at index `k`; pass scalars through. +_at(values::AbstractArray, k::Integer) = values[k] +_at(scalar, ::Integer) = scalar + +# Point-evaluate an InfiniteOpt var at `support` if it's infinite; +# return the var as-is if it's finite. +_at_support(v::InfiniteOpt.GeneralVariableRef, support) = + isempty(InfiniteOpt.parameter_refs(v)) ? v : v(support) + +# Override the base `copy_variables_onto_model` for `InfiniteModel`: in addition +# to decision variables, copy infinite parameters (with supports), +# derivatives, and parameter functions. `PointVariableRef`s (e.g. +# `L(0)` from boundary conditions) are skipped — they re-emerge on +# the target via `replace_variables_in_constraint`. +function DP.copy_variables_onto_model( + target::InfiniteOpt.InfiniteModel, + source::InfiniteOpt.InfiniteModel + ) + ref_map = Dict{InfiniteOpt.GeneralVariableRef, + InfiniteOpt.GeneralVariableRef}() + for p in InfiniteOpt.all_parameters(source) + domain = InfiniteOpt.infinite_domain(p) + supports = Float64.(InfiniteOpt.supports(p)) + param = InfiniteOpt.build_parameter(error, domain; + supports = supports) + ref_map[p] = InfiniteOpt.add_parameter(target, param, + JuMP.name(p)) end - return 1 + for v in JuMP.all_variables(source) + # Point variables (e.g. `L(0)` from boundary conditions) are + # implicit evaluations of their underlying infinite var; they + # carry NaN start values by default and don't need to be + # copied as primary decision variables. They re-emerge on the + # target via `replace_variables_in_constraint`. + _is_point_var(v) && continue + prefs = InfiniteOpt.parameter_refs(v) + var_type = isempty(prefs) ? nothing : + InfiniteOpt.Infinite(Tuple(ref_map[p] for p in prefs)...) + props = DP.VariableProperties( + DP.get_variable_info(v), JuMP.name(v), nothing, var_type) + ref_map[v] = DP.create_variable(target, props) + end + for d in InfiniteOpt.all_derivatives(source) + vref = InfiniteOpt.derivative_argument(d) + pref = InfiniteOpt.operator_parameter(d) + new_d = InfiniteOpt.deriv(ref_map[vref], ref_map[pref]) + info = DP.get_variable_info(d) + info.has_lb && JuMP.set_lower_bound(new_d, info.lower_bound) + info.has_ub && JuMP.set_upper_bound(new_d, info.upper_bound) + ref_map[d] = new_d + end + for pfunc in InfiniteOpt.all_parameter_functions(source) + func = InfiniteOpt.raw_function(pfunc) + prefs = InfiniteOpt.parameter_refs(pfunc) + mapped_prefs = Tuple(ref_map[p] for p in prefs) + pref_arg = length(mapped_prefs) == 1 ? + only(mapped_prefs) : mapped_prefs + param_func = InfiniteOpt.build_parameter_function(error, + func, pref_arg) + ref_map[pfunc] = InfiniteOpt.add_parameter_function(target, + param_func) + end + return ref_map end -#transcribe the BigM'd InfiniteModel to flat, copy it, create alpha_oa. -#Number of supports K is stashed in `master.model.ext[:_loa_K]` for the -#per-support OA cut and combo overrides below. -function DP.build_loa_master(model::InfiniteOpt.InfiniteModel, method::DP.LOA) - InfiniteOpt.build_transformation_backend!(model) - flat = InfiniteOpt.transformation_model(model) - orig_obj = JuMP.objective_function(flat) - master, copy_map = JuMP.copy_model(flat) +# Build map: transcribed input JuMP var → master point variable. +# For an infinite input var v, every transcribed support `v_k` +# maps to `ref_map[v](d_k)` (master point variable). Used as +# `objective_ref_map` so the objective OA cut can linearize the +# (already flat) transcribed objective and land in master point +# variables. +# +# Why the transcribe-then-AD detour exists: MOI Nonlinear AD has +# no walker for `InfiniteOpt.MeasureRef`, so an objective like +# `∫(f(z, t), t)` cannot be differentiated directly. The objective +# OA cut therefore transcribes the input model once, ADs the +# resulting flat scalar objective, and uses this map to translate +# the gradient back into master point variables. Per-support +# DISJUNCT cuts do NOT need this — they linearize natively in +# InfiniteModel space via `cut_info`. If the objective contains no +# measures, this whole layer is dead weight; killing it would +# require either banning measure objectives or hand-writing a +# `_linearize_at` that walks `MeasureRef` symbolically. +function _transcribed_to_master_point( + model::InfiniteOpt.InfiniteModel, + ref_map::AbstractDict + ) + result = Dict{JuMP.VariableRef, InfiniteOpt.GeneralVariableRef}() + for v in DP.collect_all_vars(model) + # Point vars share their transcribed instance with the + # underlying infinite var's per-support transcription, so + # skipping them here doesn't lose any transcribed→master + # mappings. + _is_point_var(v) && continue + master_var = ref_map[v] + transcribed = InfiniteOpt.transformation_variable(v) + prefs = InfiniteOpt.parameter_refs(v) + if isempty(prefs) + result[transcribed] = master_var + else + supports = vec(InfiniteOpt.supports(only(prefs))) + for (k, ref) in enumerate(vec(transcribed)) + result[ref] = master_var(supports[k]) + end + end + end + return result +end + +# Build the LOA master as a fresh empty `InfiniteModel` populated +# with the input model's structural skeleton plus its linear +# constraints. Install `alpha_oa` as a finite variable. +# `binary_map[indicator]` and `variable_map[v]` hold single +# InfiniteOpt vars on the master; per-support handling happens +# downstream via point evaluation on those refs. OA cuts added in +# the LOA loop are point-evaluated scalar constraints on the master +# InfiniteModel; transcription is rebuilt before each master solve. +# +# Objective handling branches on `has_aggregate_ref`. When the +# objective is aggregate-free (no MeasureRef / ParameterFunctionRef), +# `original_objective` is the InfiniteOpt objective itself and +# `objective_ref_map = ref_map`, so AD walks the InfiniteOpt +# expression directly. When it contains an aggregate, AD cannot see +# inside it; we fall back to transcribing the input model and using +# the flat scalar objective with a transcribed-to-master point map. +function DP.build_loa_master( + model::InfiniteOpt.InfiniteModel, method::DP.LOA + ) + master = InfiniteOpt.InfiniteModel() JuMP.set_optimizer(master, method.mip_optimizer) JuMP.set_silent(master) - bin_map = Dict{DP.LogicalVariableRef, Any}() - for (ind, bv) in DP._indicator_to_binary(model) - bin_map[ind] = _tv_map(bv, copy_map) + + ref_map = DP.copy_variables_onto_model(master, model) + + variable_type = InfiniteOpt.GeneralVariableRef + for (F, S) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + DP._is_linear_F(F) || continue + for cref in JuMP.all_constraints(model, F, S) + con = JuMP.constraint_object(cref) + new_func = DP.replace_variables_in_constraint( + con.func, ref_map) + JuMP.@constraint(master, new_func in con.set) + end end - var_map = Dict{InfiniteOpt.GeneralVariableRef, Any}() + + raw_objective = JuMP.objective_function(model) + objective_sense = JuMP.objective_sense(model) + if DP.has_aggregate_ref(raw_objective) + InfiniteOpt.build_transformation_backend!(model) + transcribed_input = InfiniteOpt.transformation_model(model) + original_objective = JuMP.objective_function(transcribed_input) + objective_ref_map = _transcribed_to_master_point( + model, ref_map) + else + original_objective = raw_objective + objective_ref_map = ref_map + end + + alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") + JuMP.@objective(master, objective_sense, alpha_oa) + + binary_map = Dict{DP.LogicalVariableRef, Any}() + for (indicator, binary_ref) in DP._indicator_to_binary(model) + binary_map[indicator] = DP._remap_indicator_to_binary( + binary_ref, ref_map) + end + variable_map = Dict{InfiniteOpt.GeneralVariableRef, + InfiniteOpt.GeneralVariableRef}() for v in DP.collect_all_vars(model) - var_map[v] = _tv_map(v, copy_map) + _is_point_var(v) && continue + variable_map[v] = ref_map[v] end - #also store a flat→master copy_map for objective OA cuts - flat_copy_map = Dict{JuMP.VariableRef, JuMP.VariableRef}() - for v in JuMP.all_variables(flat) - flat_copy_map[v] = copy_map[v] + + return (model = master, binary_map = binary_map, + variable_map = variable_map, + objective_sense = objective_sense, + original_objective = original_objective, + alpha_oa = alpha_oa, + objective_ref_map = objective_ref_map) +end + +# Override the disjunct-cut loop for `InfiniteModel`. Same shape as +# the base loop, but each constraint is checked for aggregate refs. +# Aggregate constraints (e.g. those containing a `MeasureRef`) are +# transcribed via `InfiniteOpt.transformation_expression`, then +# handed back to the base `_add_oa_cut_for_constraint` as a regular +# `JuMP.ScalarConstraint` over flat `JuMP.VariableRef`s — which the +# base AD pipeline can linearize correctly. +# +# `transcribed_to_master` and `transcribed_xk` are built lazily once +# per `add_disjunct_oa_cuts` call and shared across all aggregate +# constraints in the iteration. +function DP.add_disjunct_oa_cuts( + model::InfiniteOpt.InfiniteModel, + master::NamedTuple, + result::NamedTuple, + method::DP.LOA + ) + sign_factor, penalty_sign = DP._disjunct_cut_coefficients( + Val(master.objective_sense)) + transcribed_to_master = Ref{Any}(nothing) + transcribed_xk = Ref{Any}(nothing) + ensure_transcribed = function () + transcribed_to_master[] === nothing || return + InfiniteOpt.build_transformation_backend!(model) + transcribed_to_master[] = _transcribed_to_master_point( + model, master.variable_map) + transcribed_xk[] = _transcribe_linearization_point( + model, result.linearization_point) + return end - obj_sense = JuMP.objective_sense(master) - alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") - JuMP.@objective(master, obj_sense, alpha_oa) - m = (model = master, bin_map = bin_map, var_map = var_map, - obj_sense = obj_sense, orig_obj = orig_obj, - alpha_oa = alpha_oa, obj_ref_map = flat_copy_map) - master.ext[:_loa_K] = _detect_K(bin_map) - master.ext[:_loa_flat_copy_map] = flat_copy_map - return m -end - -#fix per-support via point equality constraints -function DP.fix_combo_binaries(model::InfiniteOpt.InfiniteModel, combo) - crefs = InfiniteOpt.InfOptConstraintRef[] - for (ind, val) in combo - bv = DP._indicator_to_binary(model)[ind] - if val isa Bool - JuMP.fix(bv, val ? 1.0 : 0.0; force = true) - else - sups = InfiniteOpt.supports(first(InfiniteOpt.parameter_refs(bv))) - for (k, s) in enumerate(vec(sups)) - push!(crefs, - JuMP.@constraint(model, bv(s) == (val[k] ? 1.0 : 0.0))) + for (indicator, active) in result.combination + DP.any_active(active) || continue + haskey(master.binary_map, indicator) || continue + haskey(DP._indicator_to_constraints(model), indicator) || + continue + for orig_constraint_ref in + DP._indicator_to_constraints(model)[indicator] + orig_constraint_ref isa DP.DisjunctConstraintRef || + continue + constraint = DP._disjunct_constraints(model)[ + JuMP.index(orig_constraint_ref)].constraint + dual = get(result.duals, orig_constraint_ref, nothing) + dual === nothing && continue + if DP.has_aggregate_ref(constraint.func) + ensure_transcribed() + transcribed_func = + InfiniteOpt.transformation_expression( + constraint.func) + transcribed_constraint = JuMP.ScalarConstraint( + transcribed_func, constraint.set) + for (binary_ref, _, _, dual_value) in DP.cut_info( + master.binary_map[indicator], active, + result.linearization_point, + master.variable_map, dual) + DP._add_oa_cut_for_constraint( + transcribed_constraint, master, binary_ref, + transcribed_xk[], transcribed_to_master[], + dual_value, method, sign_factor, + penalty_sign) + end + continue + end + for (binary_ref, linearization_point, var_map, + dual_value) in DP.cut_info( + master.binary_map[indicator], active, + result.linearization_point, + master.variable_map, dual) + DP._add_oa_cut_for_constraint( + constraint, master, binary_ref, + linearization_point, var_map, dual_value, + method, sign_factor, penalty_sign) end end end - model.ext[:_loa_fix_crefs] = crefs end -function DP.unfix_combo_binaries(model::InfiniteOpt.InfiniteModel, combo) - if haskey(model.ext, :_loa_fix_crefs) - for c in model.ext[:_loa_fix_crefs] - JuMP.is_valid(model, c) && JuMP.delete(model, c) +# Fix indicator binaries on the NLP model. Bool: fix the whole +# infinite var across all supports. Vector{Bool}: fix per-support via +# point equality constraints (refs stashed for `unfix_combination_binaries`). +function DP.fix_combination_binaries( + model::InfiniteOpt.InfiniteModel, + combination::AbstractDict + ) + fixing_constraint_refs = InfiniteOpt.InfOptConstraintRef[] + for (indicator, value) in combination + binary_ref = DP._indicator_to_binary(model)[indicator] + _fix_binary_at_supports( + fixing_constraint_refs, model, binary_ref, value + ) + end + model.ext[:_loa_fixing_constraint_refs] = fixing_constraint_refs +end + +function _fix_binary_at_supports( + _, + _, + binary_ref::InfiniteOpt.GeneralVariableRef, + value::Bool + ) + JuMP.fix(binary_ref, value ? 1.0 : 0.0; force = true) + return +end + +function _fix_binary_at_supports( + refs::AbstractVector, + model::InfiniteOpt.InfiniteModel, + binary_ref::InfiniteOpt.GeneralVariableRef, + values::AbstractVector{Bool} + ) + for (k, support) in enumerate(_supports_of(binary_ref)) + push!(refs, JuMP.@constraint(model, + binary_ref(support) == (values[k] ? 1.0 : 0.0))) + end + return +end + +function DP.set_start_values( + ::InfiniteOpt.InfiniteModel, + linearization_point::AbstractDict + ) + for (variable, values) in linearization_point + _set_starts_for_transcribed( + InfiniteOpt.transformation_variable(variable), + values + ) + end +end + +# Infinite var: per-support transcribed array +function _set_starts_for_transcribed( + transcribed::AbstractArray, + values::AbstractVector + ) + for (k, ref) in enumerate(vec(transcribed)) + JuMP.set_start_value(ref, values[k]) + end + return +end + +# Finite var: single transcribed ref, `values` is 1-element +_set_starts_for_transcribed( + transcribed::JuMP.AbstractVariableRef, + values::AbstractVector + ) = + JuMP.set_start_value(transcribed, values[1]) + +# Finite var: single transcribed ref, scalar value (feas-side path) +_set_starts_for_transcribed( + transcribed::JuMP.AbstractVariableRef, + value::Real + ) = + JuMP.set_start_value(transcribed, value) + +function DP.unfix_combination_binaries( + model::InfiniteOpt.InfiniteModel, + combination::AbstractDict + ) + if haskey(model.ext, :_loa_fixing_constraint_refs) + for fixing_ref in model.ext[:_loa_fixing_constraint_refs] + JuMP.is_valid(model, fixing_ref) && + JuMP.delete(model, fixing_ref) end - delete!(model.ext, :_loa_fix_crefs) + delete!(model.ext, :_loa_fixing_constraint_refs) end - for (ind, val) in combo - val isa Bool || continue - bv = DP._indicator_to_binary(model)[ind] - JuMP.is_fixed(bv) && JuMP.unfix(bv) + for (indicator, value) in combination + binary_ref = DP._indicator_to_binary(model)[indicator] + _unfix_binary_at_supports(binary_ref, value) end end -#Transcribe the BigM'd InfiniteModel, then hand off to the base -#`copy_model_with_constraints` on the flat model. `disjunct_crefs` -#are InfiniteModel-level refs; for the InfiniteOpt path we pass an -#empty Vector and the base includes every non-bound flat constraint -#(flat has no GDPData so no reformulation-constraint set to skip), -#giving us the whole transcribed+BigM'd model. Per-combo flat pruning -#is deferred. fwd_map is rekeyed to InfiniteModel vars for use by -#`read_*_solution`; obj_ref_map stays keyed by flat vars (the -#flat-level objective OA cut lives there). +# Bool: unfix the whole-var fix +function _unfix_binary_at_supports( + binary_ref::InfiniteOpt.GeneralVariableRef, + ::Bool + ) + JuMP.is_fixed(binary_ref) && JuMP.unfix(binary_ref) + return +end + +# Vector{Bool}: per-support fixing was via constraints already deleted above +_unfix_binary_at_supports( + ::InfiniteOpt.GeneralVariableRef, + ::AbstractVector{Bool} + ) = nothing + +# Build the LOA feasibility-restoration submodel as a fresh +# `InfiniteModel`: copy the structural skeleton, then copy every +# non-reformulation constraint plus the active disjuncts' +# `disjunct_constraint_refs`. `fwd_map` maps input InfiniteOpt vars +# to single feas-side InfiniteOpt vars; per-support handling falls +# through to the `GeneralVariableRef` dispatches above. function DP.copy_model_with_constraints( model::InfiniteOpt.InfiniteModel, - disjunct_crefs::Vector{<:DP.DisjunctConstraintRef}, + disjunct_constraint_refs::Vector{<:DP.DisjunctConstraintRef}, method::DP.LOA ) - InfiniteOpt.build_transformation_backend!(model) - flat = InfiniteOpt.transformation_model(model) - flat_crefs = DP.DisjunctConstraintRef{typeof(flat)}[] - base = DP.copy_model_with_constraints(flat, flat_crefs, method) - fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Any}() - decision_vars = DP.collect_all_vars(model) + sub_model = InfiniteOpt.InfiniteModel() + JuMP.set_optimizer(sub_model, method.nlp_optimizer) + JuMP.set_silent(sub_model) + + ref_map = DP.copy_variables_onto_model(sub_model, model) + + variable_type = InfiniteOpt.GeneralVariableRef + reform_set = DP.is_gdp_model(model) ? + Set(DP._reformulation_constraints(model)) : Set() + for (F, S) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + for cref in JuMP.all_constraints(model, F, S) + cref in reform_set && continue + con = JuMP.constraint_object(cref) + new_func = DP.replace_variables_in_constraint( + con.func, ref_map) + JuMP.@constraint(sub_model, new_func in con.set) + end + end + for cref in disjunct_constraint_refs + con = JuMP.constraint_object(cref) + new_func = DP.replace_variables_in_constraint( + con.func, ref_map) + JuMP.@constraint(sub_model, new_func in con.set) + end + + decision_vars = filter(!_is_point_var, DP.collect_all_vars(model)) + fwd_map = Dict{InfiniteOpt.GeneralVariableRef, + InfiniteOpt.GeneralVariableRef}() for v in decision_vars - fwd_map[v] = _tv_map(v, base.sub.fwd_map) + fwd_map[v] = ref_map[v] end - return (sub = DP.GDPSubmodel(base.sub.model, decision_vars, fwd_map), - obj_ref_map = base.sub.fwd_map) + return (sub = DP.GDPSubmodel(sub_model, decision_vars, fwd_map), + objective_ref_map = ref_map) end -#read per-support JuMP values from either a vector of flat vars or a -#single scalar. Dispatch handles both transformation_variable (may be -#N-dim) and fwd_map (already flat) shapes. -_read_values(v::AbstractArray) = Float64[JuMP.value(fv) for fv in vec(v)] -_read_values(v) = Float64(JuMP.value(v)) +# Convert an InfiniteModel-var-keyed per-support point into a +# transcribed-JuMP-var-keyed scalar point. Companion to +# `_transcribed_to_master_point`: feeds the AD walker for the +# objective OA cut. See the WHY note above +# `_transcribed_to_master_point` for the MeasureRef/AD constraint +# that motivates this whole transcribe-then-AD layer. +function _transcribe_linearization_point( + model::InfiniteOpt.InfiniteModel, + linearization_point::AbstractDict + ) + T = eltype(valtype(linearization_point)) + transcribed = Dict{JuMP.VariableRef, T}() + for (variable, values) in linearization_point + _add_to_transcribed_dict( + transcribed, + InfiniteOpt.transformation_variable(variable), + values + ) + end + return transcribed +end -#extract per-support x-values from the InfiniteModel NLP; objective -#x-values are keyed by the flat transcription vars for the objective cut -function DP.read_primary_solution(model::InfiniteOpt.InfiniteModel) - x_vals = Dict{JuMP.AbstractVariableRef, Any}() - for v in DP.collect_all_vars(model) - JuMP.is_fixed(v) && continue - x_vals[v] = _read_values(InfiniteOpt.transformation_variable(v)) +# Infinite var: per-support transcribed array, values per-support +function _add_to_transcribed_dict( + d::AbstractDict, + ts::AbstractArray, + values::AbstractVector + ) + for (k, ref) in enumerate(vec(ts)) + d[ref] = values[k] end - return x_vals, _flat_xk(model, x_vals) + return end -#extract per-support x-values from the flat feas submodel. x_vals keys are -#InfiniteModel vars (via fwd_map); obj_x_values keys are flat vars. +# Finite var: single transcribed ref, values 1-element +_add_to_transcribed_dict( + d::AbstractDict, + ts::JuMP.AbstractVariableRef, + values::AbstractVector + ) = + (d[ts] = values[1]; nothing) + +# Finite var: single transcribed ref, scalar value (feas-side path) +_add_to_transcribed_dict( + d::AbstractDict, + ts::JuMP.AbstractVariableRef, + value::Real + ) = + (d[ts] = value; nothing) + +# Extract per-support x-values from the InfiniteModel NLP. The +# first return is keyed on InfiniteModel vars (used for disjunct +# OA cuts via `cut_info`). The second return is what the objective +# OA cut consumes: when the objective is aggregate-free we hand +# back the same dict (objective AD walks InfiniteOpt vars +# directly); when it has aggregates we transcribe so the flat +# objective's AD pipeline can read it. +function DP.read_primary_solution( + model::InfiniteOpt.InfiniteModel + ) + T = JuMP.value_type(typeof(model)) + linearization_point = Dict{ + InfiniteOpt.GeneralVariableRef, Vector{T} + }( + variable => _per_support_values(variable) + for variable in DP.collect_all_vars(model) + if !JuMP.is_fixed(variable)) + return linearization_point, + _objective_linearization_point(model, linearization_point) +end + +# Same split as `read_primary_solution`: InfiniteOpt-keyed dict for +# disjunct cuts, plus the matching objective-side point. function DP.read_feas_solution( - model::InfiniteOpt.InfiniteModel, feas + model::InfiniteOpt.InfiniteModel, feas::NamedTuple + ) + linearization_point = DP.extract_solution(feas.sub) + return linearization_point, + _objective_linearization_point(model, linearization_point) +end + +# Bridge `linearization_point` (InfiniteOpt-keyed, per-support +# `Vector` values) to the scalar-valued dict the base +# `_linearize_at` AD pipeline expects. Aggregate objectives go +# through transcription (flat JuMP-keyed scalar dict). +# Aggregate-free objectives use only finite vars; we keep the +# InfiniteOpt keys and collapse the 1-element per-support vector +# to a scalar so AD reads `xk[v]` as a number. +function _objective_linearization_point( + model::InfiniteOpt.InfiniteModel, + linearization_point::AbstractDict ) - x_vals = DP.extract_solution(feas.sub) - obj_xv = Dict{JuMP.VariableRef, Float64}() - for (flat_v, feas_v) in feas.obj_ref_map - obj_xv[flat_v] = Float64(JuMP.value(feas_v)) + if DP.has_aggregate_ref(JuMP.objective_function(model)) + return _transcribe_linearization_point( + model, linearization_point) + end + T = eltype(valtype(linearization_point)) + scalar_point = Dict{InfiniteOpt.GeneralVariableRef, T}() + for (variable, values) in linearization_point + isempty(InfiniteOpt.parameter_refs(variable)) || continue + scalar_point[variable] = values[1] end - return x_vals, obj_xv + return scalar_point end end diff --git a/src/loa.jl b/src/loa.jl index 0c4fdc65..eac14ccf 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -6,6 +6,9 @@ # Viswanathan & Grossmann (1990), Comp. & Chem. Eng. 14(7), 769-782 ################################################################################ +################################################################################ +# METHOD TYPE +################################################################################ """ LOA{O, P} <: AbstractReformulationMethod @@ -25,9 +28,12 @@ struct LOA{O, P} <: AbstractReformulationMethod function LOA( nlp_optimizer::O; mip_optimizer::P = nlp_optimizer, - max_iter::Int = 10, atol::Float64 = 1e-6, - rtol::Float64 = 1e-4, M_value::Float64 = 1e9, - max_slack::Float64 = 1000.0, OA_penalty_factor::Float64 = 1000.0 + max_iter::Int = 10, + atol::Float64 = 1e-6, + rtol::Float64 = 1e-4, + M_value::Float64 = 1e9, + max_slack::Float64 = 1000.0, + OA_penalty_factor::Float64 = 1000.0 ) where {O, P} new{O, P}(nlp_optimizer, mip_optimizer, max_iter, atol, rtol, M_value, max_slack, OA_penalty_factor) @@ -35,448 +41,410 @@ struct LOA{O, P} <: AbstractReformulationMethod end ################################################################################ -# MAIN ALGORITHM +# SENSE PRIMITIVES +################################################################################ +# Val(MIN/MAX)-dispatched primitives so the algorithm reads sense-agnostic. +_disjunct_cut_coefficients(::Val{_MOI.MIN_SENSE}) = (-1, 1) +_disjunct_cut_coefficients(::Val{_MOI.MAX_SENSE}) = (1, -1) +_worst_objective(::Val{_MOI.MIN_SENSE}) = Inf +_worst_objective(::Val{_MOI.MAX_SENSE}) = -Inf +_is_better(::Val{_MOI.MIN_SENSE}, new, best) = new < best +_is_better(::Val{_MOI.MAX_SENSE}, new, best) = new > best +_gap(::Val{_MOI.MIN_SENSE}, best, bound) = best - bound +_gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best +_flip_sense(::Val{_MOI.MIN_SENSE}) = Val(_MOI.MAX_SENSE) +_flip_sense(::Val{_MOI.MAX_SENSE}) = Val(_MOI.MIN_SENSE) + +################################################################################ +# MAIN ALGORITHM ################################################################################ function reformulate_model(model::JuMP.AbstractModel, method::LOA) _clear_reformulations(model) - combos = _set_covering_combos(model) + combinations = _set_covering_combinations(model) reformulate_model(model, BigM(method.M_value)) master = build_loa_master(model, method) - reform_map = _build_reform_map(model) + reformulation_map = _build_reformulation_map(model) JuMP.set_optimizer(model, method.nlp_optimizer) JuMP.set_silent(model) - + # Cached for repeated use in `_worst_objective`, `_is_better`, + # `_flip_sense`, and `_loa_converged`. sense_token = Val(JuMP.objective_sense(model)) - best_obj = _worst_obj(sense_token) - is_better(o) = _is_better(sense_token, o, best_obj) + best_objective = _worst_objective(sense_token) + is_better(candidate) = _is_better(sense_token, candidate, best_objective) best_result = nothing - #Initialization Procedure (Türkay & Grossmann 1996 §2.2): solve the - #set-covering NLPs to seed the master with at least one OA cut per - #disjunct before the main iteration. - for combo in combos - result = _solve_nlp(model, combo, method, reform_map) - _add_no_good_cut(model, master, combo) + # Initialization Procedure (Türkay & Grossmann 1996, sec. 2.2): solve + # the set-covering NLPs to seed the master with at least one OA cut + # per disjunct before the main iteration. + for combination in combinations + result = _solve_nlp(model, combination, method, reformulation_map) + avoid_combination(master.model, combination, master.binary_map) _add_oa_cuts(model, master, result, method) if result.feasible && is_better(result.objective) - best_obj = result.objective + best_objective = result.objective best_result = result end end - master_bound = _worst_obj(_flip_sense(sense_token)) + master_bound = _worst_objective(_flip_sense(sense_token)) for iter in 1:method.max_iter JuMP.optimize!(master.model) JuMP.is_solved_and_feasible(master.model) || break master_bound = JuMP.objective_value(master.model) - _loa_converged(best_obj, master_bound, sense_token, method) && break - combo = _extract_combo(model, master) - result = _solve_nlp(model, combo, method, reform_map) - _add_no_good_cut(model, master, combo) + _loa_converged(best_objective, master_bound, sense_token, method) && break + combination = _extract_combination(model, master) + result = _solve_nlp(model, combination, method, reformulation_map) + avoid_combination(master.model, combination, master.binary_map) _add_oa_cuts(model, master, result, method) if result.feasible && is_better(result.objective) - best_obj = result.objective + best_objective = result.objective best_result = result end end - _finalize_model(model, best_result) + if best_result !== nothing + fix_combination_binaries(model, best_result.combination) + set_start_values(model, best_result.linearization_point) + end _set_solution_method(model, method) _set_ready_to_optimize(model, true) return end -#fix best combo permanently and set start values -function _finalize_model(model, best_result) - best_result === nothing && return - fix_combo_binaries(model, best_result.combo) - for (var, v) in best_result.x_values - JuMP.is_valid(model, var) || continue - v isa Number || continue - JuMP.set_start_value(var, v) - end +# Convergence: absolute gap ≤ atol or relative gap ≤ rtol. Distinct +# from CP's `separation_obj ≤ tolerance` check (different convergence +# shapes — gap vs. single-threshold), so not shared. +function _loa_converged( + best_objective::Real, + master_bound::Real, + sense_token::Val, + method::LOA + ) + (isinf(best_objective) || isinf(master_bound)) && return false + gap = _gap(sense_token, best_objective, master_bound) + gap <= method.atol && return true + abs(best_objective) > 1e-10 && + gap / abs(best_objective) <= method.rtol && return true + return false +end + +# Error fallback for unsupported model types. +function reformulate_model(::M, ::LOA) where {M} + error("reformulate_model not implemented for model type `$(M)` with LOA.") end ################################################################################ -# EXTENSION POINTS +# SET-COVERING INITIALIZATION ################################################################################ -""" - build_loa_master(model::JuMP.AbstractModel, method::LOA) - -Build the LOA master MILP as a deep copy of the BigM-reformulated -`model`, install the `alpha_oa` objective auxiliary, and wire up -`method.mip_optimizer`. OA and no-good cuts are added to the returned -master each iteration of the main LOA loop. - -## Returns -- `NamedTuple` with `model` (master MILP), `bin_map` - (indicator→binary), `var_map` (original→master var), - `obj_sense`, `orig_obj`, `alpha_oa`, and `obj_ref_map` - (objective-side map for linearization). -""" +# Türkay & Grossmann (1996), sec. 2.2: produce a minimal set of +# combinations that activates every indicator at least once, so the +# master starts with an OA cut per disjunct. Nested disjunctions are +# enumerated alongside top-level ones — inconsistent combinations +# (nested active under inactive parent) are handled by feasibility +# restoration at NLP-solve time. +# +# `K = max(disjunction sizes)` combinations suffice: combination `k` +# activates the `k`-th indicator of each disjunction, cycling via +# `mod1` for disjunctions shorter than `K`. Every indicator gets +# activated at least once over k=1..K. +# +# Note: keeps the less-specific `model::JuMP.AbstractModel` +# signature instead of `model::M where {M <: JuMP.AbstractModel}` to +# avoid ambiguity with InfiniteOpt overrides; this function is +# internal so no public-API impact. +function _set_covering_combinations(model::JuMP.AbstractModel) + LogicalRef = LogicalVariableRef{typeof(model)} + indicator_lists = [collect(d.constraint.indicators) + for (_, d) in _disjunctions(model)] + isempty(indicator_lists) && return Dict{LogicalRef, Bool}[] + K = maximum(length, indicator_lists) + return [ + Dict{LogicalRef, Bool}( + indicator => (indicator == indicators[mod1(k, length(indicators))]) + for indicators in indicator_lists + for indicator in indicators) + for k in 1:K + ] +end + +################################################################################ +# MASTER CONSTRUCTION +################################################################################ +# True for linear constraint function types (scalar/vector variable refs +# and affine expressions). LOA master per Türkay & Grossmann (1996) is +# pure MILP; nonlinear `f`, `g`, `h_{ij}` enter as OA cuts after each +# NLP solve. +_is_linear_F(::Type{<:JuMP.AbstractVariableRef}) = true +_is_linear_F(::Type{<:JuMP.GenericAffExpr}) = true +_is_linear_F(::Type{<:AbstractVector{<:JuMP.AbstractVariableRef}}) = true +_is_linear_F(::Type{<:AbstractVector{<:JuMP.GenericAffExpr}}) = true +_is_linear_F(::Type) = false + +# OVERRIDABLE. Build the LOA master MILP `M^b_{LA}` (Türkay & Grossmann +# 1996, eq. 12): copy decision variables and only the linear +# constraints, install `alpha_oa` as the objective auxiliary. Nonlinear +# objective and disjunct constraints enter as OA cuts after each NLP +# solve. Returns a NamedTuple of (model, binary_map, variable_map, +# objective_sense, original_objective, alpha_oa, objective_ref_map). function build_loa_master(model::JuMP.AbstractModel, method::LOA) - orig_obj = JuMP.objective_function(model) - master, copy_map = JuMP.copy_model(model) + original_objective = JuMP.objective_function(model) + objective_sense = JuMP.objective_sense(model) + variable_type = JuMP.variable_ref_type(typeof(model)) + + master = _copy_model(model) JuMP.set_optimizer(master, method.mip_optimizer) JuMP.set_silent(master) - bin_map = Dict{LogicalVariableRef, Any}() - for (ind, bv) in _indicator_to_binary(model) - bin_map[ind] = copy_map[bv] - end - #replace objective with alpha_oa; OA cuts are added each iteration - obj_sense = JuMP.objective_sense(master) - alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") - JuMP.@objective(master, obj_sense, alpha_oa) - return (model = master, bin_map = bin_map, var_map = copy_map, - obj_sense = obj_sense, orig_obj = orig_obj, - alpha_oa = alpha_oa, obj_ref_map = copy_map) -end -""" - copy_model_with_constraints( - model::JuMP.AbstractModel, - disjunct_crefs::Vector{<:DisjunctConstraintRef}, - method::LOA - ) + variable_map = copy_variables_onto_model(master, model) -Build a raw feasibility-restoration submodel by deep-copying `model`'s -decision variables, then including only the problem's global -constraints (those not added by BigM reformulation) and the original -pre-BigM constraints of `disjunct_crefs`. The shared slack `u` and -`min u` objective are applied separately by [`_embed_feas_slack`](@ref). -Mirrors MBM's subset-taking [`copy_model_with_constraints`](@ref). - -## Returns -- `NamedTuple` with `sub::GDPSubmodel` (copied model and - original→copy forward map) and `obj_ref_map` (objective-side - reference map for linearizing the original objective at the feas - point). -""" -function copy_model_with_constraints( - model::JuMP.AbstractModel, - disjunct_crefs::Vector{<:DisjunctConstraintRef}, - method::LOA - ) - V = JuMP.variable_ref_type(model) - sub_model = _copy_model(model) - decision_vars = collect_all_vars(model) - fwd_map = Dict{V, V}() - for var in decision_vars - fwd_map[var] = variable_copy(sub_model, var) - end - VT = JuMP.variable_ref_type(typeof(model)) - reform_set = is_gdp_model(model) ? - Set(_reformulation_constraints(model)) : Set() for (F, S) in JuMP.list_of_constraint_types(model) - F === VT && continue - for cref in JuMP.all_constraints(model, F, S) - cref in reform_set && continue - con = JuMP.constraint_object(cref) - expr = _replace_variables_in_constraint(con.func, fwd_map) - JuMP.@constraint(sub_model, expr in con.set) - end - end - for cref in disjunct_crefs - con = JuMP.constraint_object(cref) - expr = _replace_variables_in_constraint(con.func, fwd_map) - JuMP.@constraint(sub_model, expr in con.set) - end - JuMP.set_optimizer(sub_model, method.nlp_optimizer) - JuMP.set_silent(sub_model) - return (sub = GDPSubmodel(sub_model, decision_vars, fwd_map), - obj_ref_map = fwd_map) -end - -# Convert the raw copy from `copy_model_with_constraints` into a V&G 1990 -# NLPF problem: shared slack `u` embedded in every constraint via -# `_slacken`, integrality relaxed, `min u` as the objective. -function _embed_feas_slack(feas) - m = feas.sub.model - u = JuMP.@variable(m, base_name = "_loa_u", lower_bound = 0.0) - VT = JuMP.variable_ref_type(typeof(m)) - to_slacken = Any[] - for (F, S) in JuMP.list_of_constraint_types(m) - F === VT && continue - for cref in JuMP.all_constraints(m, F, S) - push!(to_slacken, cref) - end - end - for cref in to_slacken - JuMP.is_valid(m, cref) || continue - con = JuMP.constraint_object(cref) - for (sf, ss) in _slacken(con.func, con.set, u) - JuMP.@constraint(m, sf in ss) + F === variable_type && continue + _is_linear_F(F) || continue + for constraint_ref in JuMP.all_constraints(model, F, S) + constraint = JuMP.constraint_object(constraint_ref) + new_func = replace_variables_in_constraint( + constraint.func, variable_map) + JuMP.@constraint(master, new_func in constraint.set) end - JuMP.delete(m, cref) end - JuMP.relax_integrality(m) - JuMP.@objective(m, Min, u) - return -end - -""" - fix_combo_binaries(model::JuMP.AbstractModel, combo)::Nothing -Fix every indicator binary in `combo` to its active / inactive value -on `model`. Complement indicators (stored as `1 - other_bv`) are -handled by fixing the underlying variable to the complement value via -[`fix_fv`](@ref). -""" -function fix_combo_binaries(model, combo) - for (ind, active) in combo - fix_fv(_indicator_to_binary(model)[ind], active) + binary_map = Dict{LogicalVariableRef, Any}() + for (indicator, binary_ref) in _indicator_to_binary(model) + binary_map[indicator] = _remap_indicator_to_binary( + binary_ref, variable_map) end -end -""" - unfix_combo_binaries(model::JuMP.AbstractModel, combo)::Nothing - -Undo the effect of [`fix_combo_binaries`](@ref): unfix every indicator -binary in `combo` on `model`. -""" -function unfix_combo_binaries(model, combo) - for (ind, _) in combo - unfix_fv(_indicator_to_binary(model)[ind]) - end -end + alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") + JuMP.@objective(master, objective_sense, alpha_oa) -################################################################################ -# SET COVERING INITIALIZATION -################################################################################ -#Türkay & Grossmann (1996) §2.2: pick a minimal set of combos that -#activates every indicator at least once, so the master starts with an -#OA cut for each disjunct. We enumerate nested disjunctions alongside -#top-level ones — inconsistent combos (nested active under inactive -#parent) are handled by feasibility restoration at NLP-solve time. -function _set_covering_combos(model::JuMP.AbstractModel) - M = typeof(model) - LVR = LogicalVariableRef{M} - per_disj = Vector{Tuple{DisjunctionIndex, LVR}}[] - for (idx, disj_data) in _disjunctions(model) - push!(per_disj, [(idx, ind) for ind in disj_data.constraint.indicators]) - end - isempty(per_disj) && return Dict{LVR, Bool}[] - all_combos = [Tuple{DisjunctionIndex, LVR}[c...] - for c in Iterators.product(per_disj...)] - uncovered = Set{LVR}() - for group in per_disj - for (_, ind) in group - push!(uncovered, ind) - end - end - selected = Dict{LVR, Bool}[] - while !isempty(uncovered) - best_combo = nothing - best_count = 0 - for combo in all_combos - cnt = sum(ind in uncovered for (_, ind) in combo) - if cnt > best_count - best_count = cnt - best_combo = combo - end - end - best_combo === nothing && break - combo_dict = Dict{LVR, Bool}() - for (disj_idx, active_ind) in best_combo - disj_data = _disjunctions(model)[disj_idx] - for ind in disj_data.constraint.indicators - combo_dict[ind] = (ind == active_ind) - end - end - push!(selected, combo_dict) - for (_, active_ind) in best_combo - delete!(uncovered, active_ind) - end - end - return selected + return (model = master, binary_map = binary_map, + variable_map = variable_map, objective_sense = objective_sense, + original_objective = original_objective, alpha_oa = alpha_oa, + objective_ref_map = variable_map) end ################################################################################ -# NLP SUBPROBLEM +# NLP SUBPROBLEM ################################################################################ -# Solve the primary NLP for a fixed combo. If infeasible, build a fresh -# V&G 1990 NLPF submodel (globals + active-disjunct originals, with -# shared slack `u` and `min u` objective), solve it for a least- -# infeasible point to generate OA cuts from, then discard it. Returns -# a named tuple with -# `combo / x_values / duals / objective / feasible / obj_x_values`. +# Solve the primary NLP for a fixed combination. If feasible, read the +# primal point, duals, and objective and return. If infeasible, build a +# fresh V&G 1990 NLPF submodel (active-disjunct originals + globals, +# slackened with shared `u` and `min u` objective), solve it for a +# least-infeasible point, and return that. The NLPF is discarded. function _solve_nlp( - model::M, combo, method::LOA, reform_map + model::M, combination, method::LOA, reformulation_map ) where {M <: JuMP.AbstractModel} - DCRef = DisjunctConstraintRef{M} - empty_duals = Dict{DCRef, Any}() - fix_combo_binaries(model, combo) + fix_combination_binaries(model, combination) JuMP.optimize!(model, ignore_optimize_hook = true) if JuMP.is_solved_and_feasible(model) - x_vals, obj_xv = read_primary_solution(model) - duals = _collect_nlp_duals(model, combo, reform_map) - obj_val = JuMP.objective_value(model) - unfix_combo_binaries(model, combo) - return (combo = combo, x_values = x_vals, duals = duals, - objective = obj_val, feasible = true, obj_x_values = obj_xv) + lin_point, obj_lin_point = read_primary_solution(model) + duals = _collect_nlp_duals(model, combination, reformulation_map) + objective_val = JuMP.objective_value(model) + unfix_combination_binaries(model, combination) + return (combination = combination, + linearization_point = lin_point, duals = duals, + objective = objective_val, feasible = true, + objective_linearization_point = obj_lin_point) end - unfix_combo_binaries(model, combo) - # Build a fresh per-combo NLPF: only the active-disjunct originals - # + globals, slackened with a shared `u`. Discarded after solve. - active_crefs = _active_disjunct_crefs(model, combo) - feas = copy_model_with_constraints(model, active_crefs, method) + unfix_combination_binaries(model, combination) + active_refs = _active_disjunct_constraint_refs(model, combination) + feas = copy_model_with_constraints(model, active_refs, method) _embed_feas_slack(feas) - feas_fwd = feas.sub.fwd_map - for (ind, val) in combo - orig_bv = _indicator_to_binary(model)[ind] - haskey(feas_fwd, orig_bv) || continue - fix_fv(feas_fwd[orig_bv], val) + for (indicator, value) in combination + orig_binary_ref = _indicator_to_binary(model)[indicator] + fix_indicator(_remap_indicator_to_binary( + orig_binary_ref, feas.sub.fwd_map), value) end JuMP.optimize!(feas.sub.model) - feas_ok = JuMP.is_solved_and_feasible(feas.sub.model) - x_vals = feas_ok ? first(read_feas_solution(model, feas)) : + lin_point = JuMP.is_solved_and_feasible(feas.sub.model) ? + first(read_feas_solution(model, feas)) : Dict{JuMP.AbstractVariableRef, Any}() - return (combo = combo, x_values = x_vals, duals = empty_duals, - objective = Inf, feasible = false, obj_x_values = x_vals) + return (combination = combination, + linearization_point = lin_point, + duals = Dict{DisjunctConstraintRef{M}, Any}(), + objective = Inf, feasible = false, + objective_linearization_point = lin_point) end -# Collect the `DisjunctConstraintRef`s of every active indicator in -# `combo`. Used to feed `copy_model_with_constraints` the minimal -# per-combo subset for NLPF construction. -function _active_disjunct_crefs(model::M, combo) where {M} - crefs = DisjunctConstraintRef{M}[] - for (ind, active) in combo +# `DisjunctConstraintRef`s of every active indicator in `combination`. +function _active_disjunct_constraint_refs( + model::M, + combination::AbstractDict + ) where {M <: JuMP.AbstractModel} + refs = DisjunctConstraintRef{M}[] + for (indicator, active) in combination any_active(active) || continue - haskey(_indicator_to_constraints(model), ind) || continue - for cref in _indicator_to_constraints(model)[ind] - cref isa DisjunctConstraintRef || continue - push!(crefs, cref) + haskey(_indicator_to_constraints(model), indicator) || continue + for cref in _indicator_to_constraints(model)[indicator] + cref isa DisjunctConstraintRef && push!(refs, cref) end end - return crefs + return refs end -# Sum the duals of BigM-reformulated constraints for each active -# disjunct's original constraint ref. Used for OA cut generation. -function _collect_nlp_duals( - model::M, combo, reform_map - ) where {M <: JuMP.AbstractModel} - duals = Dict{DisjunctConstraintRef{M}, Any}() - JuMP.has_duals(model) || return duals - for (ind, active) in combo - active || continue - haskey(_indicator_to_constraints(model), ind) || continue - for cref in _indicator_to_constraints(model)[ind] - cref isa DisjunctConstraintRef || continue - duals[cref] = _sum_duals(reform_map, cref) - end +# OVERRIDABLE. Fix every indicator in `combination` on `model`. +function fix_combination_binaries( + model::JuMP.AbstractModel, + combination::AbstractDict + ) + for (indicator, active) in combination + fix_indicator(model, indicator, active) end - return duals end -""" - read_primary_solution(model::JuMP.AbstractModel)::Tuple{Dict, Dict} - -Read the primal solution of the primary NLP after a feasible solve. -Returns `(x_values, obj_x_values)` where both dicts are keyed by -variable reference; `obj_x_values` matches `x_values` for finite -models. The InfiniteOpt extension overrides this to return the -flat-transcription dict as the second element. -""" -function read_primary_solution(model::JuMP.AbstractModel) - x_vals = Dict{JuMP.AbstractVariableRef, Any}() - for v in JuMP.all_variables(model) - JuMP.is_fixed(v) && continue - x_vals[v] = JuMP.value(v) +# OVERRIDABLE. Inverse of `fix_combination_binaries`. +function unfix_combination_binaries( + model::JuMP.AbstractModel, + combination::AbstractDict + ) + for (indicator, _) in combination + unfix_indicator(model, indicator) end - return x_vals, x_vals end -""" - read_feas_solution( - model::JuMP.AbstractModel, feas - )::Tuple{Dict, Dict} - -Read the primal solution of the feasibility-restoration NLPF after a -feasible solve, keyed by original-model variables via `extract_solution` -on `feas.sub`. Same return contract as [`read_primary_solution`](@ref). -""" -function read_feas_solution(model::JuMP.AbstractModel, feas) - x_vals = extract_solution(feas.sub) - return x_vals, x_vals +# OVERRIDABLE. Build a raw V&G 1990 NLPF submodel: copy decision +# variables, copy global (non-reformulation) constraints and the active +# disjuncts' original pre-BigM constraints. The shared slack `u` and +# `min u` objective are layered on by `_embed_feas_slack`. Returns +# `(sub::GDPSubmodel, objective_ref_map)`. +function copy_model_with_constraints( + model::JuMP.AbstractModel, + disjunct_constraint_refs::Vector{<:DisjunctConstraintRef}, + method::LOA + ) + sub_model = _copy_model(model) + fwd_map = copy_variables_onto_model(sub_model, model) + variable_type = JuMP.variable_ref_type(typeof(model)) + reform_set = is_gdp_model(model) ? + Set(_reformulation_constraints(model)) : Set() + for (F, S) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + for cref in JuMP.all_constraints(model, F, S) + cref in reform_set && continue + con = JuMP.constraint_object(cref) + expr = replace_variables_in_constraint(con.func, fwd_map) + JuMP.@constraint(sub_model, expr in con.set) + end + end + for cref in disjunct_constraint_refs + con = JuMP.constraint_object(cref) + expr = replace_variables_in_constraint(con.func, fwd_map) + JuMP.@constraint(sub_model, expr in con.set) + end + JuMP.set_optimizer(sub_model, method.nlp_optimizer) + JuMP.set_silent(sub_model) + return ( + sub = GDPSubmodel(sub_model, collect_all_vars(model), fwd_map), + objective_ref_map = fwd_map + ) end -""" - fix_fv(bv, val::Bool)::Nothing - -Fix a binary indicator reference `bv` to `val`. Dispatches on: -- `AbstractVariableRef`: calls `JuMP.fix(bv, val ? 1.0 : 0.0; force = true)`. -- `GenericAffExpr`: the complement-indicator form `1 - other_bv`; fix - `other_bv` to the complement of `val`. -- `AbstractArray`: iterate elementwise over `bvs` (and `vals` if also - array). Used by per-support InfiniteOpt indicators. -""" -fix_fv(bv, val::Bool) = JuMP.fix(bv, val ? 1.0 : 0.0; force = true) -function fix_fv(bv::JuMP.GenericAffExpr, val::Bool) - under, coeff = only(bv.terms) - JuMP.fix(under, val ? 0.0 : 1.0; force = true) +# Convert the raw copy into a V&G 1990 NLPF problem: shared slack +# embedded in every constraint via `_slacken`, `min slack` objective. +function _embed_feas_slack(feas::NamedTuple) + model = feas.sub.model + slack = JuMP.@variable(model, base_name = "_feas_slack", lower_bound = 0.0) + variable_type = JuMP.variable_ref_type(typeof(model)) + to_slacken = Any[] + for (F, S) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + append!(to_slacken, JuMP.all_constraints(model, F, S)) + end + for constraint_ref in to_slacken + constraint = JuMP.constraint_object(constraint_ref) + for (slack_func, slack_set) in _slacken( + constraint.func, constraint.set, slack) + JuMP.@constraint(model, slack_func in slack_set) + end + JuMP.delete(model, constraint_ref) + end + JuMP.@objective(model, Min, slack) + return end -fix_fv(bvs::AbstractArray, val::Bool) = - (for bv in bvs; fix_fv(bv, val); end; return) -fix_fv(bvs::AbstractArray, vals::AbstractArray) = - (for (bv, v) in zip(bvs, vals); fix_fv(bv, v); end; return) -""" - unfix_fv(bv)::Nothing +# Apply slack `u` to a constraint based on its set type. ≤: `f − u`; +# ≥: `f + u`; ==: split into ≤ and ≥; otherwise passthrough. +_slacken(f, set::_MOI.LessThan, u) = [(f - u, set)] +_slacken(f, set::_MOI.GreaterThan, u) = [(f + u, set)] +function _slacken(f, set::_MOI.EqualTo, u) + b = _MOI.constant(set) + return [(f - u, _MOI.LessThan(b)), (f + u, _MOI.GreaterThan(b))] +end +_slacken(f, set, u) = [(f, set)] -Undo [`fix_fv`](@ref) on `bv`. No-op if `bv` is not currently fixed. -For complement AffExprs, unfixes the underlying variable. -""" -function unfix_fv(bv) - JuMP.is_fixed(bv) && JuMP.unfix(bv) - return +# OVERRIDABLE. Read the primal NLP solution. Returns +# `(linearization_point, objective_linearization_point)`; both dicts +# are equal here. InfiniteOpt overrides to return a transcribed scalar +# dict for the second element when needed. +function read_primary_solution(model::JuMP.AbstractModel) + linearization_point = Dict{JuMP.AbstractVariableRef, Any}() + for variable in JuMP.all_variables(model) + JuMP.is_fixed(variable) && continue + linearization_point[variable] = JuMP.value(variable) + end + return linearization_point, linearization_point end -function unfix_fv(bv::JuMP.GenericAffExpr) - under = only(keys(bv.terms)) - JuMP.is_fixed(under) && JuMP.unfix(under) - return + +# OVERRIDABLE. As `read_primary_solution`, but for the V&G +# feasibility-restoration submodel; values keyed back to original-model +# vars via `feas.sub`. +function read_feas_solution(model::JuMP.AbstractModel, feas::NamedTuple) + linearization_point = extract_solution(feas.sub) + return linearization_point, linearization_point end -unfix_fv(bvs::AbstractArray) = - (for bv in bvs; unfix_fv(bv); end; return) ################################################################################ -# REFORM MAP (BigM constraint index) +# BIGM DUAL COLLECTION ################################################################################ -function _build_reform_map(model::M) where {M <: JuMP.AbstractModel} +# Build a map `DisjunctConstraintRef → Vector{}` so +# `_collect_nlp_duals` can sum BigM duals per original disjunct +# constraint. Uses `_num_reform_constraints` to slice the flat +# reformulation-constraint list per original. (Brittle: assumes BigM +# emits constraints in disjunction-iteration order; a cleaner +# alternative would be to have BigM record this map directly.) +function _build_reformulation_map(model::M) where {M <: JuMP.AbstractModel} ref_cons = _reformulation_constraints(model) isempty(ref_cons) && return Dict{DisjunctConstraintRef{M}, Vector{Any}}() CRT = eltype(ref_cons) rmap = Dict{DisjunctConstraintRef{M}, Vector{CRT}}() - idx = 1 - for (_, disj_data) in _disjunctions(model) - for ind in disj_data.constraint.indicators - haskey(_indicator_to_constraints(model), ind) || continue - for cref in _indicator_to_constraints(model)[ind] - cref isa DisjunctConstraintRef || continue - con = _disjunct_constraints(model)[JuMP.index(cref)].constraint - n = _num_reform_cons(con) - idx + n - 1 <= length(ref_cons) || break - rmap[cref] = collect(ref_cons[idx:idx + n - 1]) - idx += n + cursor = 1 + for (_, disjunction_data) in _disjunctions(model) + for indicator in disjunction_data.constraint.indicators + haskey(_indicator_to_constraints(model), indicator) || continue + for constraint_ref in _indicator_to_constraints(model)[indicator] + constraint_ref isa DisjunctConstraintRef || continue + constraint = _disjunct_constraints(model)[ + JuMP.index(constraint_ref)].constraint + num_reform = _num_reform_constraints(constraint) + cursor + num_reform - 1 <= length(ref_cons) || break + rmap[constraint_ref] = collect( + ref_cons[cursor:cursor + num_reform - 1]) + cursor += num_reform end end end return rmap end -_num_reform_cons(::JuMP.ScalarConstraint{T, S}) where { +# Number of BigM-reformulated JuMP constraints per original constraint. +_num_reform_constraints(::JuMP.ScalarConstraint{T, S}) where { T <: Union{Number, JuMP.AbstractJuMPScalar}, S <: Union{_MOI.EqualTo, _MOI.Interval}} = 2 -_num_reform_cons(::JuMP.ScalarConstraint) = 1 -_num_reform_cons(con::JuMP.VectorConstraint{T, S}) where { +_num_reform_constraints(::JuMP.ScalarConstraint) = 1 +_num_reform_constraints(constraint::JuMP.VectorConstraint{T, S}) where { T <: Union{Number, JuMP.AbstractJuMPScalar}, - S <: _MOI.Zeros} = 2 * _MOI.dimension(con.set) -_num_reform_cons(con::JuMP.VectorConstraint) = _MOI.dimension(con.set) - -function _sum_duals(reform_map, cref) - haskey(reform_map, cref) || return 0.0 - rcs = reform_map[cref] + S <: _MOI.Zeros} = 2 * _MOI.dimension(constraint.set) +_num_reform_constraints(constraint::JuMP.VectorConstraint) = + _MOI.dimension(constraint.set) + +# Sum the duals of all reformulation constraints for one disjunct cref. +function _sum_duals( + reformulation_map::AbstractDict, + constraint_ref::DisjunctConstraintRef + ) + haskey(reformulation_map, constraint_ref) || return 0.0 + rcs = reformulation_map[constraint_ref] isempty(rcs) && return 0.0 total = JuMP.dual(rcs[1]) for i in 2:length(rcs) @@ -485,231 +453,191 @@ function _sum_duals(reform_map, cref) return total end -################################################################################ -# COMBO EXTRACTION + CUTS -################################################################################ -#extract combo from master solution. `combo_val` dispatches on scalar vs -#array bin_map values (extension adds the array method for per-support). -function _extract_combo( - model::M, master +# Sum BigM-reformulation duals per active disjunct constraint ref. +function _collect_nlp_duals( + model::M, + combination::AbstractDict, + reformulation_map::AbstractDict ) where {M <: JuMP.AbstractModel} - combo = Dict{LogicalVariableRef{M}, Any}() - for (_, disj_data) in _disjunctions(model) - for ind in disj_data.constraint.indicators - haskey(master.bin_map, ind) || continue - combo[ind] = combo_val(master.bin_map[ind]) + duals = Dict{DisjunctConstraintRef{M}, Any}() + JuMP.has_duals(model) || return duals + for (indicator, active) in combination + any_active(active) || continue + haskey(_indicator_to_constraints(model), indicator) || continue + for cref in _indicator_to_constraints(model)[indicator] + cref isa DisjunctConstraintRef || continue + duals[cref] = _sum_duals(reformulation_map, cref) end end - return combo -end -""" - combo_val(bv)::Bool - -Round the master's current binary solution for indicator ref `bv` to a -`Bool`. Scalar form returns `Bool`; `AbstractArray` form returns -`Vector{Bool}` per support for InfiniteOpt indicators. -""" -combo_val(bv) = round(JuMP.value(bv)) > 0.5 -combo_val(bvs::AbstractArray) = Bool.(round.(JuMP.value.(bvs))) - -function _add_no_good_cut(model, master, combo) - cut_expr = JuMP.AffExpr(0.0) - for (ind, active) in combo - haskey(master.bin_map, ind) || continue - add_ng_terms(cut_expr, master.bin_map[ind], active) - end - JuMP.@constraint(master.model, cut_expr >= 1.0) -end - -""" - add_ng_terms(cut, bv, active::Bool)::Nothing - -Assemble one indicator's contribution to the no-good cut `cut_expr >= -1`: add `1 - y_j` if the indicator was active in the excluded combo, -or `y_j` otherwise. `AbstractArray` forms (with Bool or per-support -Vector) fold per-support contributions for InfiniteOpt indicators. -""" -function add_ng_terms(cut, bv, active::Bool) - if active - JuMP.add_to_expression!(cut, -1.0, bv) - JuMP.add_to_expression!(cut, 1.0) - else - JuMP.add_to_expression!(cut, 1.0, bv) - end - return -end -add_ng_terms(cut, bvs::AbstractArray, active::Bool) = - add_ng_terms(cut, bvs, fill(active, length(bvs))) -function add_ng_terms(cut, bvs::AbstractArray, actives::AbstractArray) - for (bv, a) in zip(bvs, actives) - add_ng_terms(cut, bv, a) - end - return + return duals end -""" - any_active(active)::Bool - -Return `true` if any indicator value in `active` is truthy. Scalar -`Bool` returns itself; `AbstractVector{Bool}` reduces with `any` for -per-support InfiniteOpt indicators. -""" -any_active(active::Bool) = active -any_active(actives::AbstractVector{Bool}) = any(actives) - ################################################################################ -# LINEARIZATION HELPERS +# COMBO EXTRACTION (master → NLP) ################################################################################ -function _linearize_at(var::JuMP.AbstractVariableRef, xk, ref_map) - return JuMP.AffExpr(0.0, ref_map[var] => 1.0) -end -function _linearize_at(func::JuMP.GenericAffExpr, xk, ref_map) - result = JuMP.AffExpr(func.constant) - for (var, coef) in func.terms - JuMP.add_to_expression!(result, coef, ref_map[var]) +# Read each indicator's binary value from the master MILP solution. +# `combination_val` dispatches per binary type (scalar in base; +# extensions add per-support). +function _extract_combination( + model::M, + master::NamedTuple + ) where {M <: JuMP.AbstractModel} + combination = Dict{LogicalVariableRef{M}, Any}() + for (_, disjunction_data) in _disjunctions(model) + for indicator in disjunction_data.constraint.indicators + haskey(master.binary_map, indicator) || continue + combination[indicator] = combination_val(master.binary_map[indicator]) + end end - return result -end -################################################################################ -# SLACK HELPERS -################################################################################ -_slacken(f, set::_MOI.LessThan, u) = [(f - u, set)] -_slacken(f, set::_MOI.GreaterThan, u) = [(f + u, set)] -function _slacken(f, set::_MOI.EqualTo, u) - b = _MOI.constant(set) - return [(f - u, _MOI.LessThan(b)), (f + u, _MOI.GreaterThan(b))] + return combination end -_slacken(f, set, u) = [(f, set)] + +# OVERRIDABLE. One-shot float→Bool conversion; downstream dispatches on Bool. +combination_val(binary_ref) = round(Bool, JuMP.value(binary_ref)) ################################################################################ -# OA CUT GENERATION +# OA CUT EMISSION ################################################################################ -# Sense-dependent coefficients for OA and convergence calls. Dispatched -# on `Val(master.obj_sense)` so downstream call sites can read like -# regular Julia without branching on the sense. -_disjunct_cut_coeffs(::Val{_MOI.MIN_SENSE}) = (-1, 1) -_disjunct_cut_coeffs(::Val{_MOI.MAX_SENSE}) = (1, -1) -_worst_obj(::Val{_MOI.MIN_SENSE}) = Inf -_worst_obj(::Val{_MOI.MAX_SENSE}) = -Inf -_is_better(::Val{_MOI.MIN_SENSE}, new, best) = new < best -_is_better(::Val{_MOI.MAX_SENSE}, new, best) = new > best -_gap(::Val{_MOI.MIN_SENSE}, best, bound) = best - bound -_gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best -_flip_sense(::Val{_MOI.MIN_SENSE}) = Val(_MOI.MAX_SENSE) -_flip_sense(::Val{_MOI.MAX_SENSE}) = Val(_MOI.MIN_SENSE) - -function _add_oa_cuts(model, master, result, method::LOA) - isempty(result.x_values) && return - _add_objective_oa_cut(master, result) - _add_disjunct_oa_cuts(model, master, result, method) +function _add_oa_cuts( + model::JuMP.AbstractModel, + master::NamedTuple, + result::NamedTuple, + method::LOA + ) + isempty(result.linearization_point) && return + linearization = _linearize_at(master.original_objective, + result.objective_linearization_point, master.objective_ref_map) + _add_objective_cut(Val(master.objective_sense), master, linearization) + add_disjunct_oa_cuts(model, master, result, method) return end -# Linearize the original objective at the NLP's solution and add the -# bounding cut `lin ≤ α` (min) or `lin ≥ α` (max) on the master. -function _add_objective_oa_cut(master, result) - lin = _linearize_at( - master.orig_obj, result.obj_x_values, master.obj_ref_map) - _add_obj_cut(Val(master.obj_sense), master, lin) - return -end -_add_obj_cut(::Val{_MOI.MIN_SENSE}, master, lin) = +# Bounding cut `lin ≤ α` (min) or `lin ≥ α` (max) on the master. +_add_objective_cut(::Val{_MOI.MIN_SENSE}, master, lin) = JuMP.@constraint(master.model, lin <= master.alpha_oa) -_add_obj_cut(::Val{_MOI.MAX_SENSE}, master, lin) = +_add_objective_cut(::Val{_MOI.MAX_SENSE}, master, lin) = JuMP.@constraint(master.model, lin >= master.alpha_oa) -# Add V&G 1990 augmented-penalty OA cuts for each active disjunct's -# nonlinear constraints: fresh per-cut slack `σ_ik` with a penalty term -# in the master objective, and cut body `s (lin - rhs) - σ ≤ M(1 - y)`. -function _add_disjunct_oa_cuts( - model, master, result, method::LOA +# OVERRIDABLE. Add V&G 1990 augmented-penalty OA cuts for each active +# disjunct's nonlinear constraints: fresh per-cut slack `σ_ik` with a +# penalty term in the master objective, and cut body +# `s (lin − rhs) − σ ≤ M(1 − y)`. +function add_disjunct_oa_cuts( + model::JuMP.AbstractModel, + master::NamedTuple, + result::NamedTuple, + method::LOA ) - sgn, pen = _disjunct_cut_coeffs(Val(master.obj_sense)) - for (ind, active) in result.combo + sign_factor, penalty_sign = _disjunct_cut_coefficients( + Val(master.objective_sense)) + for (indicator, active) in result.combination any_active(active) || continue - haskey(master.bin_map, ind) || continue - haskey(_indicator_to_constraints(model), ind) || continue - for orig_cref in _indicator_to_constraints(model)[ind] - orig_cref isa DisjunctConstraintRef || continue - con = _disjunct_constraints(model)[ - JuMP.index(orig_cref)].constraint - con.func isa JuMP.GenericAffExpr && continue - d = get(result.duals, orig_cref, nothing) - d === nothing && continue - rhs = _set_rhs(con.set) - for (bv, xk, smap, d_k) in cut_info( - master.bin_map[ind], active, - result.x_values, master.var_map, d) - s = sign(sgn * _dv(d_k)) - s == 0 && continue - lin_expr = _linearize_at(con.func, xk, smap) - slack = JuMP.@variable(master.model, - lower_bound = 0.0, upper_bound = method.max_slack) - JuMP.set_objective_function(master.model, - JuMP.objective_function(master.model) + - pen * method.OA_penalty_factor * slack) - JuMP.@constraint(master.model, - s * (lin_expr - rhs) - slack <= - method.M_value * (1 - bv)) + haskey(master.binary_map, indicator) || continue + haskey(_indicator_to_constraints(model), indicator) || continue + for cref in _indicator_to_constraints(model)[indicator] + cref isa DisjunctConstraintRef || continue + constraint = _disjunct_constraints(model)[ + JuMP.index(cref)].constraint + dual = get(result.duals, cref, nothing) + dual === nothing && continue + for (binary_ref, lin_point, var_map, dual_value) in cut_info( + master.binary_map[indicator], active, + result.linearization_point, master.variable_map, dual) + _add_oa_cut_for_constraint( + constraint, master, binary_ref, lin_point, + var_map, dual_value, method, sign_factor, penalty_sign) end end end end -""" - cut_info(bv, active, x_values, var_map, d) - -Yield the inputs `(bv, xk, smap, d_k)` needed to emit each OA cut for -one active disjunct constraint. The scalar form returns one tuple -(one cut per constraint). The `AbstractArray` form yields K tuples -with per-support-sliced `x_values`, `var_map`, and dual (one cut per -active support, used for InfiniteOpt indicators). -""" -cut_info(bv, active::Bool, x_values, var_map, d) = - ((bv, x_values, var_map, d),) -cut_info(bvs::AbstractArray, active::Bool, x_values, var_map, d) = - cut_info(bvs, fill(active, length(bvs)), x_values, var_map, d) -function cut_info( - bvs::AbstractArray, actives::AbstractArray, - x_values, var_map, d +# Linearize constraint at `linearization_point`, append a fresh per-cut +# slack with V&G penalty, gate by `M(1 − binary)`. Linear constraints +# are exact via BigM and skipped. +function _add_oa_cut_for_constraint( + constraint::JuMP.AbstractConstraint, + master::NamedTuple, + binary_ref, + linearization_point::AbstractDict, + var_map::AbstractDict, + dual_value, + method::LOA, + sign_factor::Int, + penalty_sign::Int ) - sites = Any[] - for k in 1:length(bvs) - actives[k] || continue - smap_k = Dict{Any, Any}( - v => (mv isa AbstractVector ? mv[k] : mv) - for (v, mv) in var_map) - x_k = Dict{Any, Any}( - v => (xv isa AbstractVector ? xv[k] : xv) - for (v, xv) in x_values) - d_k = d isa AbstractVector ? d[k] : d - push!(sites, (bvs[k], x_k, smap_k, d_k)) - end - return sites + _is_linear_F(typeof(constraint.func)) && return + sign_value = sign(sign_factor * _collapse_dual(dual_value)) + sign_value == 0 && return + rhs = _set_rhs(constraint.set) + linearization = _linearize_at(constraint.func, linearization_point, var_map) + slack = JuMP.@variable(master.model, + lower_bound = 0.0, upper_bound = method.max_slack) + JuMP.set_objective_function(master.model, + JuMP.objective_function(master.model) + + penalty_sign * method.OA_penalty_factor * slack) + JuMP.@constraint(master.model, + sign_value * (linearization - rhs) - slack <= + method.M_value * (1 - binary_ref)) + return end -# Collapse a dual value (scalar for a single reformulated constraint, -# vector for reform constraints that produced multiple JuMP constraints -# like Interval / EqualTo / Zeros) to a scalar sign-carrier. -_dv(d::Number) = d -_dv(d) = sum(d) +# OVERRIDABLE. Yield `(binary_ref, lin_point, var_map, dual)` per OA +# cut to emit. Scalar binary returns one tuple; InfiniteOpt overrides +# for per-support `Vector` binaries to yield multiple sliced tuples. +cut_info( + binary_ref::JuMP.AbstractVariableRef, + active::Bool, + linearization_point::AbstractDict, + variable_map::AbstractDict, + dual + ) = ((binary_ref, linearization_point, variable_map, dual),) -################################################################################ -# CONVERGENCE CHECK -################################################################################ -function _loa_converged(best_obj, master_bound, sense_token, method::LOA) - (isinf(best_obj) || isinf(master_bound)) && return false - gap = _gap(sense_token, best_obj, master_bound) - gap <= method.atol && return true - abs(best_obj) > 1e-10 && gap / abs(best_obj) <= method.rtol && return true - return false +# OVERRIDABLE. Truthiness of an active descriptor. InfiniteOpt +# overrides for per-support `Vector{Bool}`. +any_active(active::Bool) = active + +# Collapse a per-constraint dual (scalar or vector for Interval / +# EqualTo / Zeros) to a scalar sign-carrier. +_collapse_dual(dual::Number) = dual +_collapse_dual(dual) = sum(dual) + +# First-order Taylor for a single var or affine expression mapped into +# master space. (Quad/nonlinear `_linearize_at` lives in `utilities.jl` +# and uses MOI Nonlinear AD.) +function _linearize_at( + variable::JuMP.AbstractVariableRef, + ::AbstractDict, + ref_map::AbstractDict + ) + target = ref_map[variable] + return JuMP.GenericAffExpr{Float64, typeof(target)}(0.0, target => 1.0) +end +function _linearize_at( + func::JuMP.GenericAffExpr, + ::AbstractDict, + ref_map::AbstractDict + ) + V = valtype(ref_map) + T = JuMP.value_type(V) <: Number ? JuMP.value_type(V) : Float64 + result = JuMP.GenericAffExpr{T, V}(T(func.constant)) + for (variable, coefficient) in func.terms + JuMP.add_to_expression!(result, coefficient, ref_map[variable]) + end + return result end -_loa_converged(z_upper, z_lower, method::LOA) = - _loa_converged(z_upper, z_lower, Val(_MOI.MIN_SENSE), method) ################################################################################ -# ERROR FALLBACK +# FINALIZATION ################################################################################ -function reformulate_model(::M, ::LOA) where {M} - error("reformulate_model not implemented for model type `$(M)` with LOA.") +# OVERRIDABLE. Set JuMP start values from a scalar-valued +# linearization-point dict. Warm-starts the post-hook `optimize!` +# that JuMP fires after `reformulate_model` — without this the final +# NLP solve restarts from wherever the last LOA iteration left the +# model. InfiniteOpt overrides for per-support Vector values. +function set_start_values( + ::JuMP.AbstractModel, linearization_point::AbstractDict + ) + for (variable, value) in linearization_point + JuMP.set_start_value(variable, value) + end end diff --git a/src/mbm.jl b/src/mbm.jl index 23c5096e..cb726fc1 100644 --- a/src/mbm.jl +++ b/src/mbm.jl @@ -508,7 +508,8 @@ function replace_variables_in_constraint( W = _var_ref_type(T, var_map) new_aff = zero(JuMP.GenericAffExpr{C, W}) for (var, coef) in fun.terms - JuMP.add_to_expression!(new_aff, coef, var_map[var]) + JuMP.add_to_expression!(new_aff, coef, + replace_variables_in_constraint(var, var_map)) end new_aff.constant = new_aff.constant + fun.constant return new_aff @@ -522,7 +523,9 @@ function replace_variables_in_constraint( new_quad = zero(JuMP.GenericQuadExpr{C, W}) for (vars, coef) in fun.terms JuMP.add_to_expression!(new_quad, - coef * var_map[vars.a] * var_map[vars.b]) + coef * + replace_variables_in_constraint(vars.a, var_map) * + replace_variables_in_constraint(vars.b, var_map)) end new_aff = replace_variables_in_constraint(fun.aff, var_map) JuMP.add_to_expression!(new_quad, new_aff) diff --git a/src/utilities.jl b/src/utilities.jl index 9dd168e2..881c77fb 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -8,6 +8,23 @@ function _copy_model( return M() end +# OVERRIDABLE. Copy `source`'s decision variables into `target` (with +# bounds and integrality) and return a `source → target` ref map. +# Constraints and objective are NOT copied — the caller adds whichever +# subset it wants. InfiniteOpt overrides to additionally copy +# parameters, derivatives, and parameter functions. +function copy_variables_onto_model( + target::JuMP.AbstractModel, + source::JuMP.AbstractModel + ) + V = JuMP.variable_ref_type(typeof(source)) + ref_map = Dict{V, V}() + for variable in JuMP.all_variables(source) + ref_map[variable] = variable_copy(target, variable) + end + return ref_map +end + """ copy_and_reformulate(model, decision_vars, reform_method, method) @@ -75,6 +92,107 @@ function extract_solution(sub::GDPSubmodel) var => JuMP.value.(sub.fwd_map[var]) for var in sub.decision_vars) end +################################################################################ +# INDICATOR FIXING +################################################################################ +""" + fix_indicator(model, indicator::LogicalVariableRef, value::Bool) + fix_indicator(binary_ref, value::Bool) + +Fix a logical indicator's binary backing variable to `value` (true → +1.0, false → 0.0). The 3-arg form is the user-facing API: pass the +model and the `LogicalVariableRef`. The 2-arg form takes the binary +backing reference directly (or its complement-form expression +`1 - other_binary` when the indicator was declared as a complement). + +Mirrors [`relax_logical_vars`](@ref) for selectively fixing rather +than relaxing. +""" +fix_indicator(model::JuMP.AbstractModel, + indicator::LogicalVariableRef, value::Bool) = + fix_indicator(_indicator_to_binary(model)[indicator], value) +fix_indicator(binary_ref::JuMP.AbstractVariableRef, value::Bool) = + JuMP.fix(binary_ref, value ? 1.0 : 0.0; force = true) +function fix_indicator( + binary_ref::JuMP.GenericAffExpr, value::Bool + ) + underlying, _ = only(binary_ref.terms) + JuMP.fix(underlying, value ? 0.0 : 1.0; force = true) + return +end + +""" + unfix_indicator(model, indicator::LogicalVariableRef) + unfix_indicator(binary_ref) + +Undo [`fix_indicator`](@ref). No-op if not currently fixed. +""" +unfix_indicator(model::JuMP.AbstractModel, + indicator::LogicalVariableRef) = + unfix_indicator(_indicator_to_binary(model)[indicator]) +function unfix_indicator(binary_ref) + JuMP.is_fixed(binary_ref) && JuMP.unfix(binary_ref) + return +end +function unfix_indicator(binary_ref::JuMP.GenericAffExpr) + underlying = only(keys(binary_ref.terms)) + JuMP.is_fixed(underlying) && JuMP.unfix(underlying) + return +end + +################################################################################ +# NO-GOOD CUTS +################################################################################ +""" + avoid_combination(model, combination) + avoid_combination(model, combination, binary_map) + +Add a no-good cut to `model` excluding `combination` from any future +solution. The added constraint + Σ_{j active} (1 - y_j) + Σ_{j inactive} y_j ≥ 1 +forces at least one indicator to differ from `combination`. + +`combination` maps `LogicalVariableRef` → `Bool` (whether each +indicator was active). The 3-arg form lets you supply an explicit +`binary_map` (defaulting to `_indicator_to_binary(model)`) when the +binaries used in the cut belong to a copy of the original model +(e.g. an LOA master). + +Returns the constraint reference of the added cut. +""" +avoid_combination(model::JuMP.AbstractModel, combination) = + avoid_combination( + model, combination, _indicator_to_binary(model)) +function avoid_combination(model::JuMP.AbstractModel, + combination, binary_map + ) + V = JuMP.variable_ref_type(typeof(model)) + T = JuMP.value_type(typeof(model)) + cut = JuMP.GenericAffExpr{T, V}(zero(T)) + for (indicator, active) in combination + haskey(binary_map, indicator) || continue + add_no_good_terms(cut, binary_map[indicator], active) + end + return JuMP.@constraint(model, cut >= one(T)) +end + +""" + add_no_good_terms(cut, binary_ref, active::Bool) + +Append one indicator's contribution to a no-good cut expression: +adds `1 - y_j` if the indicator was active in the excluded +combination, or `y_j` otherwise. Used by [`avoid_combination`](@ref). +""" +function add_no_good_terms(cut, binary_ref, active::Bool) + if active + JuMP.add_to_expression!(cut, -1.0, binary_ref) + JuMP.add_to_expression!(cut, 1.0) + else + JuMP.add_to_expression!(cut, 1.0, binary_ref) + end + return +end + ################################################################################ # LOGICAL VARIABLE RELAXATION ################################################################################ @@ -421,6 +539,33 @@ end # for outer approximation methods (LOA, future OA variants). ################################################################################ +################################################################################ +# AGGREGATE-REF DETECTION +################################################################################ +# Predicate: does the variable ref aggregate multiple decision +# variables behind a single leaf? An "aggregate" ref is one that AD +# cannot see inside — e.g. an InfiniteOpt `MeasureRef` (`∫ f(x,t) dt` +# is one ref but depends on `x(t_1), …, x(t_K)`) or a +# `ParameterFunctionRef`. Base returns false; the InfiniteOpt +# extension overrides for aggregate ref types. +# +# When `has_aggregate_ref(expr)` is true, MOI Nonlinear AD on `expr` +# would treat the aggregate as a single opaque variable and produce a +# meaningless gradient. The LOA pipeline falls back to transcription +# in that case (flat scalar expression, AD on the flat form, then map +# back to master refs). +# +# #suggestions for names are welcome +is_aggregate_ref(::JuMP.AbstractVariableRef) = false + +function has_aggregate_ref(expr) + found = Ref(false) + _interrogate_variables(expr) do v + found[] || (found[] = is_aggregate_ref(v)) + end + return found[] +end + ################################################################################ # MOI NONLINEAR EXPRESSION CONVERSION ################################################################################ diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 8009ece0..f75d1305 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -30,7 +30,7 @@ function test_set_covering_combos() @constraint(model, x >= 5, Disjunct(Y[2])) @disjunction(model, Y) - combos = DP._set_covering_combos(model) + combos = DP._set_covering_combinations(model) # Should cover both Y[1] and Y[2] all_active = Set() @@ -60,7 +60,7 @@ function test_no_good_cut() num_cons_before = length(JuMP.all_constraints( master_model; include_variable_in_set_constraints = false)) - DP._add_no_good_cut(model, master, combo) + DP.avoid_combination(master.model, combo, master.binary_map) num_cons_after = length(JuMP.all_constraints( master_model; include_variable_in_set_constraints = false)) @@ -71,10 +71,11 @@ end function test_loa_convergence_check() method = LOA(HiGHS.Optimizer; atol = 1e-6, rtol = 1e-4) - @test DP._loa_converged(1.0, 1.0, method) == true - @test DP._loa_converged(1.0, 0.9999, method) == true - @test DP._loa_converged(1.0, 0.5, method) == false - @test DP._loa_converged(1e-8, 0.0, method) == true + sense = Val(MOI.MIN_SENSE) + @test DP._loa_converged(1.0, 1.0, sense, method) == true + @test DP._loa_converged(1.0, 0.9999, sense, method) == true + @test DP._loa_converged(1.0, 0.5, sense, method) == false + @test DP._loa_converged(1e-8, 0.0, sense, method) == true end function test_loa_reformulate_simple() diff --git a/test/constraints/mbm.jl b/test/constraints/mbm.jl index 11f164da..ab9e8287 100644 --- a/test/constraints/mbm.jl +++ b/test/constraints/mbm.jl @@ -52,7 +52,7 @@ function test__replace_variables_quad_numeric_map() @test result3.aff.terms[y] ≈ 3.0 end -function test_replace_variables_in_constraint() +function testreplace_variables_in_constraint() model = Model() sub_model = Model() @variable(model, x[1:3]) @@ -809,7 +809,7 @@ end test_mbm() test__var_ref_type_numeric_map() test__replace_variables_quad_numeric_map() - test_replace_variables_in_constraint() + testreplace_variables_in_constraint() test_prepare_max_M_objective() test_raw_M() test_maximize_M() From b6563d78183cf6b71902bc4d74c7c6379e6ba7d1 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 26 Apr 2026 23:24:37 -0400 Subject: [PATCH 36/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 101 +++++++++++--------------- src/cuttingplanes.jl | 20 +++-- src/loa.jl | 33 ++------- src/utilities.jl | 2 +- test/constraints/cuttingplanes.jl | 4 +- 5 files changed, 66 insertions(+), 94 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index ff04a82f..747ae6a2 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -337,10 +337,15 @@ end _per_support_values(variable::InfiniteOpt.GeneralVariableRef) = vec(vcat(JuMP.value(variable))) -# Read per-support values from the transformation backend. +# Read per-support values from the transformation backend, keyed by +# InfiniteOpt vars. Skips fixed vars. The objective-side translation +# (transcribe-then-AD when the objective has aggregate refs) lives +# in the `_add_oa_cuts(::InfiniteModel, ...)` override below — base +# `extract_solution` doesn't need to anticipate it. function DP.extract_solution(model::InfiniteOpt.InfiniteModel) - return Dict(variable => _per_support_values(variable) - for variable in DP.collect_cutting_planes_vars(model)) + return Dict(var => _per_support_values(var) + for var in DP.collect_all_vars(model) + if !JuMP.is_fixed(var)) end # Add a pointwise-sum cut directly to the transformation backend and mark @@ -704,6 +709,41 @@ function DP.build_loa_master( end # Override the disjunct-cut loop for `InfiniteModel`. Same shape as +# Override `_add_oa_cuts` for `InfiniteModel` to translate the +# linearization point into the form the master's `original_objective` +# expects. The base `result.linearization_point` has per-support +# `Vector` values keyed on InfiniteOpt vars; the master's objective +# is either the raw InfiniteOpt expression (non-aggregate, expects +# scalar `xk[v]`) or the transcribed flat scalar expression +# (aggregate, expects transcribed-`JuMP.VariableRef`-keyed scalar +# dict). The translation produces whichever shape `_linearize_at` +# needs for this master. +function DP._add_oa_cuts( + model::InfiniteOpt.InfiniteModel, + master::NamedTuple, + result::NamedTuple, + method::DP.LOA + ) + isempty(result.linearization_point) && return + obj_point = if DP.has_aggregate_ref(JuMP.objective_function(model)) + _transcribe_linearization_point( + model, result.linearization_point) + else + T = eltype(valtype(result.linearization_point)) + Dict{InfiniteOpt.GeneralVariableRef, T}( + var => values[1] + for (var, values) in result.linearization_point + if isempty(InfiniteOpt.parameter_refs(var))) + end + linearization = DP._linearize_at(master.original_objective, + obj_point, master.objective_ref_map) + DP._add_objective_cut( + Val(master.objective_sense), master, linearization) + DP.add_disjunct_oa_cuts(model, master, result, method) + return +end + +# Override `add_disjunct_oa_cuts` for `InfiniteModel`. Same shape as # the base loop, but each constraint is checked for aggregate refs. # Aggregate constraints (e.g. those containing a `MeasureRef`) are # transcribed via `InfiniteOpt.transformation_expression`, then @@ -985,59 +1025,4 @@ _add_to_transcribed_dict( ) = (d[ts] = value; nothing) -# Extract per-support x-values from the InfiniteModel NLP. The -# first return is keyed on InfiniteModel vars (used for disjunct -# OA cuts via `cut_info`). The second return is what the objective -# OA cut consumes: when the objective is aggregate-free we hand -# back the same dict (objective AD walks InfiniteOpt vars -# directly); when it has aggregates we transcribe so the flat -# objective's AD pipeline can read it. -function DP.read_primary_solution( - model::InfiniteOpt.InfiniteModel - ) - T = JuMP.value_type(typeof(model)) - linearization_point = Dict{ - InfiniteOpt.GeneralVariableRef, Vector{T} - }( - variable => _per_support_values(variable) - for variable in DP.collect_all_vars(model) - if !JuMP.is_fixed(variable)) - return linearization_point, - _objective_linearization_point(model, linearization_point) -end - -# Same split as `read_primary_solution`: InfiniteOpt-keyed dict for -# disjunct cuts, plus the matching objective-side point. -function DP.read_feas_solution( - model::InfiniteOpt.InfiniteModel, feas::NamedTuple - ) - linearization_point = DP.extract_solution(feas.sub) - return linearization_point, - _objective_linearization_point(model, linearization_point) -end - -# Bridge `linearization_point` (InfiniteOpt-keyed, per-support -# `Vector` values) to the scalar-valued dict the base -# `_linearize_at` AD pipeline expects. Aggregate objectives go -# through transcription (flat JuMP-keyed scalar dict). -# Aggregate-free objectives use only finite vars; we keep the -# InfiniteOpt keys and collapse the 1-element per-support vector -# to a scalar so AD reads `xk[v]` as a number. -function _objective_linearization_point( - model::InfiniteOpt.InfiniteModel, - linearization_point::AbstractDict - ) - if DP.has_aggregate_ref(JuMP.objective_function(model)) - return _transcribe_linearization_point( - model, linearization_point) - end - T = eltype(valtype(linearization_point)) - scalar_point = Dict{InfiniteOpt.GeneralVariableRef, T}() - for (variable, values) in linearization_point - isempty(InfiniteOpt.parameter_refs(variable)) || continue - scalar_point[variable] = values[1] - end - return scalar_point -end - end diff --git a/src/cuttingplanes.jl b/src/cuttingplanes.jl index ddb3710d..91e26fe6 100644 --- a/src/cuttingplanes.jl +++ b/src/cuttingplanes.jl @@ -8,14 +8,17 @@ function collect_cutting_planes_vars(model::JuMP.AbstractModel) return collect_all_vars(model) end -# Extract solution from a solved model (in-place). Extensions -# override for models where values live on a backend. +# Read primal values from a solved model. Returns a scalar-valued +# `Dict{var, value}`, skipping fixed vars. CP callers wrap to +# per-support `Vector` shape via `_cp_per_support`. The InfiniteOpt +# extension overrides this dispatch to give per-support `Vector` +# values directly. function extract_solution(model::JuMP.AbstractModel) dvars = collect_cutting_planes_vars(model) V = eltype(dvars) T = JuMP.value_type(typeof(model)) - return Dict{V, Vector{T}}( - v => [JuMP.value(v)] for v in dvars) + return Dict{V, T}( + v => JuMP.value(v) for v in dvars if !JuMP.is_fixed(v)) end # Set quadratic separation objective: min Σ (x_k - rBM_k)². @@ -102,7 +105,7 @@ function reformulate_model( # Cutting plane loop: rBM <-> SEP until convergence for iter in 1:method.max_iter JuMP.optimize!(model, ignore_optimize_hook = true) - rBM_sol = extract_solution(model) + rBM_sol = _cp_per_support(extract_solution(model)) separation_obj, separation_sol = _solve_separation(separation, rBM_sol) if separation_obj <= method.seperation_tolerance break @@ -116,6 +119,13 @@ function reformulate_model( return end +# Wrap scalar `extract_solution(model).point` values into 1-element +# `Vector`s — uniform per-support shape that the CP loop expects. +# `Vector` values (from the InfiniteOpt extension's per-support read) +# pass through unchanged. +_cp_per_support(point::AbstractDict) = + Dict(v => val isa AbstractVector ? val : [val] for (v, val) in point) + ################################################################################ # ERROR MESSAGES ################################################################################ diff --git a/src/loa.jl b/src/loa.jl index eac14ccf..580071ec 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -238,14 +238,13 @@ function _solve_nlp( fix_combination_binaries(model, combination) JuMP.optimize!(model, ignore_optimize_hook = true) if JuMP.is_solved_and_feasible(model) - lin_point, obj_lin_point = read_primary_solution(model) + lin_point = extract_solution(model) duals = _collect_nlp_duals(model, combination, reformulation_map) objective_val = JuMP.objective_value(model) unfix_combination_binaries(model, combination) return (combination = combination, linearization_point = lin_point, duals = duals, - objective = objective_val, feasible = true, - objective_linearization_point = obj_lin_point) + objective = objective_val, feasible = true) end unfix_combination_binaries(model, combination) active_refs = _active_disjunct_constraint_refs(model, combination) @@ -258,13 +257,12 @@ function _solve_nlp( end JuMP.optimize!(feas.sub.model) lin_point = JuMP.is_solved_and_feasible(feas.sub.model) ? - first(read_feas_solution(model, feas)) : + extract_solution(feas.sub) : Dict{JuMP.AbstractVariableRef, Any}() return (combination = combination, linearization_point = lin_point, duals = Dict{DisjunctConstraintRef{M}, Any}(), - objective = Inf, feasible = false, - objective_linearization_point = lin_point) + objective = Inf, feasible = false) end # `DisjunctConstraintRef`s of every active indicator in `combination`. @@ -373,27 +371,6 @@ function _slacken(f, set::_MOI.EqualTo, u) end _slacken(f, set, u) = [(f, set)] -# OVERRIDABLE. Read the primal NLP solution. Returns -# `(linearization_point, objective_linearization_point)`; both dicts -# are equal here. InfiniteOpt overrides to return a transcribed scalar -# dict for the second element when needed. -function read_primary_solution(model::JuMP.AbstractModel) - linearization_point = Dict{JuMP.AbstractVariableRef, Any}() - for variable in JuMP.all_variables(model) - JuMP.is_fixed(variable) && continue - linearization_point[variable] = JuMP.value(variable) - end - return linearization_point, linearization_point -end - -# OVERRIDABLE. As `read_primary_solution`, but for the V&G -# feasibility-restoration submodel; values keyed back to original-model -# vars via `feas.sub`. -function read_feas_solution(model::JuMP.AbstractModel, feas::NamedTuple) - linearization_point = extract_solution(feas.sub) - return linearization_point, linearization_point -end - ################################################################################ # BIGM DUAL COLLECTION ################################################################################ @@ -506,7 +483,7 @@ function _add_oa_cuts( ) isempty(result.linearization_point) && return linearization = _linearize_at(master.original_objective, - result.objective_linearization_point, master.objective_ref_map) + result.linearization_point, master.objective_ref_map) _add_objective_cut(Val(master.objective_sense), master, linearization) add_disjunct_oa_cuts(model, master, result, method) return diff --git a/src/utilities.jl b/src/utilities.jl index 881c77fb..7d8e8a9d 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -84,7 +84,7 @@ end Read the primal solution of `sub.model` after a solve, keyed by the parent-model decision variables via `sub.fwd_map`. Shape follows -`fwd_map` values: `Vector`-valued fwd_maps (MBM/CP) yield per-support +`fwd_map` values: `Vector`-valued fwd_maps (CP/MBM) yield per-support `Vector`s; scalar fwd_maps (LOA feas) yield scalars. """ function extract_solution(sub::GDPSubmodel) diff --git a/test/constraints/cuttingplanes.jl b/test/constraints/cuttingplanes.jl index 9f3a7931..e15976dd 100644 --- a/test/constraints/cuttingplanes.jl +++ b/test/constraints/cuttingplanes.jl @@ -146,7 +146,7 @@ function test_cp_cut_generation() JuMP.set_silent(model) relaxed = DP.relax_logical_vars(model) optimize!(model, ignore_optimize_hook = true) - rBM_sol = DP.extract_solution(model) + rBM_sol = DP._cp_per_support(DP.extract_solution(model)) # Solve SEP DP._set_separation_objective(separation, rBM_sol) @@ -168,7 +168,7 @@ function test_cp_cut_generation() # Re-solve with cut → should tighten optimize!(model, ignore_optimize_hook = true) rBM_sol2 = DP.extract_solution(model) - @test rBM_sol2[x][1] ≈ 4.0 atol = 0.1 + @test rBM_sol2[x] ≈ 4.0 atol = 0.1 DP.unrelax_logical_vars(relaxed) end From efff096e40cb20f38d198de27a72e50036368c57 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 26 Apr 2026 23:41:35 -0400 Subject: [PATCH 37/72] . --- src/cuttingplanes.jl | 23 ++++++++--------------- src/loa.jl | 2 +- src/utilities.jl | 10 +++++++++- test/constraints/cuttingplanes.jl | 4 ++-- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/cuttingplanes.jl b/src/cuttingplanes.jl index 91e26fe6..63692258 100644 --- a/src/cuttingplanes.jl +++ b/src/cuttingplanes.jl @@ -8,17 +8,17 @@ function collect_cutting_planes_vars(model::JuMP.AbstractModel) return collect_all_vars(model) end -# Read primal values from a solved model. Returns a scalar-valued -# `Dict{var, value}`, skipping fixed vars. CP callers wrap to -# per-support `Vector` shape via `_cp_per_support`. The InfiniteOpt -# extension overrides this dispatch to give per-support `Vector` -# values directly. +# Read primal values from a solved model. Returns +# `Dict{var, Vector{value}}` — per-support shape uniformly: finite +# models trivially have one "support" (length-1 Vector), the +# InfiniteOpt extension overrides this dispatch to populate +# multi-support Vectors. Skips fixed vars. function extract_solution(model::JuMP.AbstractModel) dvars = collect_cutting_planes_vars(model) V = eltype(dvars) T = JuMP.value_type(typeof(model)) - return Dict{V, T}( - v => JuMP.value(v) for v in dvars if !JuMP.is_fixed(v)) + return Dict{V, Vector{T}}( + v => [JuMP.value(v)] for v in dvars if !JuMP.is_fixed(v)) end # Set quadratic separation objective: min Σ (x_k - rBM_k)². @@ -105,7 +105,7 @@ function reformulate_model( # Cutting plane loop: rBM <-> SEP until convergence for iter in 1:method.max_iter JuMP.optimize!(model, ignore_optimize_hook = true) - rBM_sol = _cp_per_support(extract_solution(model)) + rBM_sol = extract_solution(model) separation_obj, separation_sol = _solve_separation(separation, rBM_sol) if separation_obj <= method.seperation_tolerance break @@ -119,13 +119,6 @@ function reformulate_model( return end -# Wrap scalar `extract_solution(model).point` values into 1-element -# `Vector`s — uniform per-support shape that the CP loop expects. -# `Vector` values (from the InfiniteOpt extension's per-support read) -# pass through unchanged. -_cp_per_support(point::AbstractDict) = - Dict(v => val isa AbstractVector ? val : [val] for (v, val) in point) - ################################################################################ # ERROR MESSAGES ################################################################################ diff --git a/src/loa.jl b/src/loa.jl index 580071ec..fe857840 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -615,6 +615,6 @@ function set_start_values( ::JuMP.AbstractModel, linearization_point::AbstractDict ) for (variable, value) in linearization_point - JuMP.set_start_value(variable, value) + JuMP.set_start_value(variable, _unwrap_scalar(value)) end end diff --git a/src/utilities.jl b/src/utilities.jl index 7d8e8a9d..435ed27c 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -619,7 +619,7 @@ function _linearize_at( nlp, _MOI.Nonlinear.SparseReverseMode(), ord) _MOI.initialize(evaluator, [:Grad]) - xk_vec = [get(xk, v, zero(T)) for v in vars] + xk_vec = [_unwrap_scalar(get(xk, v, zero(T))) for v in vars] f_xk = _MOI.eval_objective(evaluator, xk_vec) grad = zeros(T, n) _MOI.eval_objective_gradient(evaluator, grad, xk_vec) @@ -641,3 +641,11 @@ end _set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = _MOI.constant(s) _set_rhs(::Any) = 0.0 + +# Unwrap a 1-element per-support `Vector` to its scalar value; +# scalars pass through. `extract_solution` returns per-support +# `Vector`s uniformly (length-1 for finite, length-K for InfiniteOpt). +# AD pipelines and `set_start_value` need a scalar in the finite +# case; per-support consumers slice out a scalar themselves. +_unwrap_scalar(v::Real) = v +_unwrap_scalar(v::AbstractVector) = only(v) diff --git a/test/constraints/cuttingplanes.jl b/test/constraints/cuttingplanes.jl index e15976dd..9f3a7931 100644 --- a/test/constraints/cuttingplanes.jl +++ b/test/constraints/cuttingplanes.jl @@ -146,7 +146,7 @@ function test_cp_cut_generation() JuMP.set_silent(model) relaxed = DP.relax_logical_vars(model) optimize!(model, ignore_optimize_hook = true) - rBM_sol = DP._cp_per_support(DP.extract_solution(model)) + rBM_sol = DP.extract_solution(model) # Solve SEP DP._set_separation_objective(separation, rBM_sol) @@ -168,7 +168,7 @@ function test_cp_cut_generation() # Re-solve with cut → should tighten optimize!(model, ignore_optimize_hook = true) rBM_sol2 = DP.extract_solution(model) - @test rBM_sol2[x] ≈ 4.0 atol = 0.1 + @test rBM_sol2[x][1] ≈ 4.0 atol = 0.1 DP.unrelax_logical_vars(relaxed) end From 76a55f8e875ccc58b82a32139749d653fd391609 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Mon, 27 Apr 2026 12:56:50 -0400 Subject: [PATCH 38/72] prelim --- ext/InfiniteDisjunctiveProgramming.jl | 5 ++++ src/loa.jl | 39 +++++++++++++++++++++++++++ test/constraints/loa.jl | 28 ++++++++++++++++++- 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 747ae6a2..3d980556 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -739,6 +739,11 @@ function DP._add_oa_cuts( obj_point, master.objective_ref_map) DP._add_objective_cut( Val(master.objective_sense), master, linearization) + # WIP: InfiniteOpt globals need per-support handling analogous to + # the obj_point translation above (transcribe-or-flatten). Until + # that's implemented, base `_add_global_oa_cuts` is skipped here — + # calling it would break on infinite vars (per-support `Vector` + # values flow into `_linearize_at`'s scalar-only AD path). DP.add_disjunct_oa_cuts(model, master, result, method) return end diff --git a/src/loa.jl b/src/loa.jl index fe857840..1d0e10bb 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -485,6 +485,7 @@ function _add_oa_cuts( linearization = _linearize_at(master.original_objective, result.linearization_point, master.objective_ref_map) _add_objective_cut(Val(master.objective_sense), master, linearization) + _add_global_oa_cuts(model, master, result, method) add_disjunct_oa_cuts(model, master, result, method) return end @@ -495,6 +496,44 @@ _add_objective_cut(::Val{_MOI.MIN_SENSE}, master, lin) = _add_objective_cut(::Val{_MOI.MAX_SENSE}, master, lin) = JuMP.@constraint(master.model, lin >= master.alpha_oa) +# WIP: finite-only. InfiniteOpt's `_add_oa_cuts(::InfiniteModel, ...)` +# override skips the call to this — globals over infinite vars yield +# per-support `Vector` values that the scalar-only `_linearize_at` AD +# path can't consume. A per-support-aware variant is the next step. +# +# Add the OA cut `g(x^l) + ∇g(x^l)^T (x − x^l) in con.set` for every +# nonlinear global constraint of `model` — the third cut class in +# Türkay & Grossmann (1996, eq. 12) alongside the objective and the +# disjunct cuts. Walks `JuMP.list_of_constraint_types`, skipping +# variable bounds, linear functions (already in the master), and +# BigM-reformulated forms (in `_reformulation_constraints`). +# `LessThan` and `GreaterThan` are supported; equalities and vector +# constraints are passed through to `JuMP.@constraint` as-is — +# valid for affine-after-linearization sets. +function _add_global_oa_cuts( + model::JuMP.AbstractModel, + master::NamedTuple, + result::NamedTuple, + method::LOA + ) + variable_type = JuMP.variable_ref_type(typeof(model)) + reform_set = is_gdp_model(model) ? + Set(_reformulation_constraints(model)) : Set() + for (F, S) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + _is_linear_F(F) && continue + for cref in JuMP.all_constraints(model, F, S) + cref in reform_set && continue + con = JuMP.constraint_object(cref) + con isa JuMP.ScalarConstraint || continue + linearization = _linearize_at(con.func, + result.linearization_point, master.variable_map) + JuMP.@constraint(master.model, linearization in con.set) + end + end + return +end + # OVERRIDABLE. Add V&G 1990 augmented-penalty OA cuts for each active # disjunct's nonlinear constraints: fresh per-cut slack `σ_ik` with a # penalty term in the master objective, and cut body diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index f75d1305..712e48c1 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -1,4 +1,4 @@ -using HiGHS +using HiGHS, Ipopt, Juniper function test_loa_datatype() method = LOA(HiGHS.Optimizer) @@ -139,6 +139,31 @@ function test_loa_error_fallback() @test_throws ErrorException DP.reformulate_model(42, method) end +function test_loa_nonlinear_global() + # max x s.t. x^2 <= 25 (global), (x <= 3) ∨ (x <= 8), 0 <= x <= 10. + # Disjunct Y[2] permits x up to 8 but the global x^2 <= 25 bounds + # x to 5. Verifies that `_add_global_oa_cuts` emits the + # linearization of the global into the master without breaking + # the loop. Result must hit the global-binding optimum. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = GDPModel(juniper) + set_silent(model) + @variable(model, 0 <= x <= 10) + @constraint(model, x^2 <= 25) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 8, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + optimize!(model, + gdp_method = LOA(juniper; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 5.0 atol = 1e-3 +end + function test_linearize_nonlinear_exp() # exp(x) + y at (1, 2): # f = e + 2, ∇f = [e, 1] @@ -219,6 +244,7 @@ end test_loa_solve_simple() test_loa_solve_two_disjunctions() test_loa_error_fallback() + test_loa_nonlinear_global() test_linearize_nonlinear_exp() test_linearize_nonlinear_sin() test_linearize_nonlinear_multivar() From 1edcb942e8453fe52f4ff1ed2214be3c2126b017 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Thu, 14 May 2026 12:51:38 -0400 Subject: [PATCH 39/72] Removed NLP-feas problem --- ext/InfiniteDisjunctiveProgramming.jl | 47 ----------- src/loa.jl | 112 +++----------------------- 2 files changed, 9 insertions(+), 150 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 3d980556..c875c280 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -933,53 +933,6 @@ _unfix_binary_at_supports( ::AbstractVector{Bool} ) = nothing -# Build the LOA feasibility-restoration submodel as a fresh -# `InfiniteModel`: copy the structural skeleton, then copy every -# non-reformulation constraint plus the active disjuncts' -# `disjunct_constraint_refs`. `fwd_map` maps input InfiniteOpt vars -# to single feas-side InfiniteOpt vars; per-support handling falls -# through to the `GeneralVariableRef` dispatches above. -function DP.copy_model_with_constraints( - model::InfiniteOpt.InfiniteModel, - disjunct_constraint_refs::Vector{<:DP.DisjunctConstraintRef}, - method::DP.LOA - ) - sub_model = InfiniteOpt.InfiniteModel() - JuMP.set_optimizer(sub_model, method.nlp_optimizer) - JuMP.set_silent(sub_model) - - ref_map = DP.copy_variables_onto_model(sub_model, model) - - variable_type = InfiniteOpt.GeneralVariableRef - reform_set = DP.is_gdp_model(model) ? - Set(DP._reformulation_constraints(model)) : Set() - for (F, S) in JuMP.list_of_constraint_types(model) - F === variable_type && continue - for cref in JuMP.all_constraints(model, F, S) - cref in reform_set && continue - con = JuMP.constraint_object(cref) - new_func = DP.replace_variables_in_constraint( - con.func, ref_map) - JuMP.@constraint(sub_model, new_func in con.set) - end - end - for cref in disjunct_constraint_refs - con = JuMP.constraint_object(cref) - new_func = DP.replace_variables_in_constraint( - con.func, ref_map) - JuMP.@constraint(sub_model, new_func in con.set) - end - - decision_vars = filter(!_is_point_var, DP.collect_all_vars(model)) - fwd_map = Dict{InfiniteOpt.GeneralVariableRef, - InfiniteOpt.GeneralVariableRef}() - for v in decision_vars - fwd_map[v] = ref_map[v] - end - return (sub = DP.GDPSubmodel(sub_model, decision_vars, fwd_map), - objective_ref_map = ref_map) -end - # Convert an InfiniteModel-var-keyed per-support point into a # transcribed-JuMP-var-keyed scalar point. Companion to # `_transcribed_to_master_point`: feeds the AD walker for the diff --git a/src/loa.jl b/src/loa.jl index 1d0e10bb..a6c9e297 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -4,6 +4,11 @@ # Türkay & Grossmann (1996), Comp. & Chem. Eng. 20(8), 959-978 # With augmented-penalty OA master from: # Viswanathan & Grossmann (1990), Comp. & Chem. Eng. 14(7), 769-782 +# +# Infeasible primary NLPs are handled solely via the master's augmented +# penalty: the combination is forbidden by a no-good cut and no OA cut +# is emitted from that iteration. No separate feasibility-restoration +# (NLPF) subproblem is built. ################################################################################ ################################################################################ @@ -228,10 +233,9 @@ end # NLP SUBPROBLEM ################################################################################ # Solve the primary NLP for a fixed combination. If feasible, read the -# primal point, duals, and objective and return. If infeasible, build a -# fresh V&G 1990 NLPF submodel (active-disjunct originals + globals, -# slackened with shared `u` and `min u` objective), solve it for a -# least-infeasible point, and return that. The NLPF is discarded. +# primal point, duals, and objective. If infeasible, return an empty +# result — the master's augmented penalty (slacks `σ_ik` on disjunct OA +# cuts) absorbs infeasibility and a no-good cut forbids the combination. function _solve_nlp( model::M, combination, method::LOA, reformulation_map ) where {M <: JuMP.AbstractModel} @@ -247,40 +251,12 @@ function _solve_nlp( objective = objective_val, feasible = true) end unfix_combination_binaries(model, combination) - active_refs = _active_disjunct_constraint_refs(model, combination) - feas = copy_model_with_constraints(model, active_refs, method) - _embed_feas_slack(feas) - for (indicator, value) in combination - orig_binary_ref = _indicator_to_binary(model)[indicator] - fix_indicator(_remap_indicator_to_binary( - orig_binary_ref, feas.sub.fwd_map), value) - end - JuMP.optimize!(feas.sub.model) - lin_point = JuMP.is_solved_and_feasible(feas.sub.model) ? - extract_solution(feas.sub) : - Dict{JuMP.AbstractVariableRef, Any}() return (combination = combination, - linearization_point = lin_point, + linearization_point = Dict{JuMP.AbstractVariableRef, Any}(), duals = Dict{DisjunctConstraintRef{M}, Any}(), objective = Inf, feasible = false) end -# `DisjunctConstraintRef`s of every active indicator in `combination`. -function _active_disjunct_constraint_refs( - model::M, - combination::AbstractDict - ) where {M <: JuMP.AbstractModel} - refs = DisjunctConstraintRef{M}[] - for (indicator, active) in combination - any_active(active) || continue - haskey(_indicator_to_constraints(model), indicator) || continue - for cref in _indicator_to_constraints(model)[indicator] - cref isa DisjunctConstraintRef && push!(refs, cref) - end - end - return refs -end - # OVERRIDABLE. Fix every indicator in `combination` on `model`. function fix_combination_binaries( model::JuMP.AbstractModel, @@ -301,76 +277,6 @@ function unfix_combination_binaries( end end -# OVERRIDABLE. Build a raw V&G 1990 NLPF submodel: copy decision -# variables, copy global (non-reformulation) constraints and the active -# disjuncts' original pre-BigM constraints. The shared slack `u` and -# `min u` objective are layered on by `_embed_feas_slack`. Returns -# `(sub::GDPSubmodel, objective_ref_map)`. -function copy_model_with_constraints( - model::JuMP.AbstractModel, - disjunct_constraint_refs::Vector{<:DisjunctConstraintRef}, - method::LOA - ) - sub_model = _copy_model(model) - fwd_map = copy_variables_onto_model(sub_model, model) - variable_type = JuMP.variable_ref_type(typeof(model)) - reform_set = is_gdp_model(model) ? - Set(_reformulation_constraints(model)) : Set() - for (F, S) in JuMP.list_of_constraint_types(model) - F === variable_type && continue - for cref in JuMP.all_constraints(model, F, S) - cref in reform_set && continue - con = JuMP.constraint_object(cref) - expr = replace_variables_in_constraint(con.func, fwd_map) - JuMP.@constraint(sub_model, expr in con.set) - end - end - for cref in disjunct_constraint_refs - con = JuMP.constraint_object(cref) - expr = replace_variables_in_constraint(con.func, fwd_map) - JuMP.@constraint(sub_model, expr in con.set) - end - JuMP.set_optimizer(sub_model, method.nlp_optimizer) - JuMP.set_silent(sub_model) - return ( - sub = GDPSubmodel(sub_model, collect_all_vars(model), fwd_map), - objective_ref_map = fwd_map - ) -end - -# Convert the raw copy into a V&G 1990 NLPF problem: shared slack -# embedded in every constraint via `_slacken`, `min slack` objective. -function _embed_feas_slack(feas::NamedTuple) - model = feas.sub.model - slack = JuMP.@variable(model, base_name = "_feas_slack", lower_bound = 0.0) - variable_type = JuMP.variable_ref_type(typeof(model)) - to_slacken = Any[] - for (F, S) in JuMP.list_of_constraint_types(model) - F === variable_type && continue - append!(to_slacken, JuMP.all_constraints(model, F, S)) - end - for constraint_ref in to_slacken - constraint = JuMP.constraint_object(constraint_ref) - for (slack_func, slack_set) in _slacken( - constraint.func, constraint.set, slack) - JuMP.@constraint(model, slack_func in slack_set) - end - JuMP.delete(model, constraint_ref) - end - JuMP.@objective(model, Min, slack) - return -end - -# Apply slack `u` to a constraint based on its set type. ≤: `f − u`; -# ≥: `f + u`; ==: split into ≤ and ≥; otherwise passthrough. -_slacken(f, set::_MOI.LessThan, u) = [(f - u, set)] -_slacken(f, set::_MOI.GreaterThan, u) = [(f + u, set)] -function _slacken(f, set::_MOI.EqualTo, u) - b = _MOI.constant(set) - return [(f - u, _MOI.LessThan(b)), (f + u, _MOI.GreaterThan(b))] -end -_slacken(f, set, u) = [(f, set)] - ################################################################################ # BIGM DUAL COLLECTION ################################################################################ From 9a9662fd8fc986789d035767d74f821f6b0d8a82 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Thu, 14 May 2026 15:59:32 -0400 Subject: [PATCH 40/72] Adding global constraints to cuts --- ext/InfiniteDisjunctiveProgramming.jl | 331 +++++++++++++------------- src/loa.jl | 93 ++++---- src/utilities.jl | 27 --- 3 files changed, 213 insertions(+), 238 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index c875c280..7c2024a2 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -340,7 +340,7 @@ _per_support_values(variable::InfiniteOpt.GeneralVariableRef) = # Read per-support values from the transformation backend, keyed by # InfiniteOpt vars. Skips fixed vars. The objective-side translation # (transcribe-then-AD when the objective has aggregate refs) lives -# in the `_add_oa_cuts(::InfiniteModel, ...)` override below — base +# in the `add_oa_cuts(::InfiniteModel, ...)` override below — base # `extract_solution` doesn't need to anticipate it. function DP.extract_solution(model::InfiniteOpt.InfiniteModel) return Dict(var => _per_support_values(var) @@ -411,10 +411,18 @@ _is_point_var(v::InfiniteOpt.GeneralVariableRef) = # function value into one ref. Either hides decision-variable # dependence from MOI Nonlinear AD, so LOA needs to transcribe the # enclosing expression to recover correct gradients. -DP.is_aggregate_ref(v::InfiniteOpt.GeneralVariableRef) = +_is_aggregate_ref(v::InfiniteOpt.GeneralVariableRef) = InfiniteOpt.dispatch_variable_ref(v) isa Union{ InfiniteOpt.MeasureRef, InfiniteOpt.ParameterFunctionRef} +function _has_aggregate_ref(expr) + found = Ref(false) + DP._interrogate_variables(expr) do v + found[] || (found[] = _is_aggregate_ref(v)) + end + return found[] +end + # Resolve a `PointVariableRef` (e.g., `L(0)` from a boundary # condition) to the master's corresponding point variable: look up # the underlying infinite var in `var_map`, then point-evaluate at @@ -430,21 +438,6 @@ function DP.replace_variables_in_constraint( return var_map[v] end -# Per-support fix via point constraints. Used by the LOA feas -# submodel (`fix_indicator(feas_binary, combination_value)`) when the -# master returned a per-support combination. -function DP.fix_indicator( - binary_ref::InfiniteOpt.GeneralVariableRef, - values::AbstractVector{Bool} - ) - model = JuMP.owner_model(binary_ref) - for (k, support) in enumerate(_supports_of(binary_ref)) - JuMP.@constraint(model, - binary_ref(support) == (values[k] ? 1.0 : 0.0)) - end - return -end - # Bool active arises from `_set_covering_combinations`, which keys # combinations on `LogicalVariableRef → Bool` regardless of whether # the indicator is infinite. Broadcast over all supports for an @@ -535,62 +528,6 @@ _at(scalar, ::Integer) = scalar _at_support(v::InfiniteOpt.GeneralVariableRef, support) = isempty(InfiniteOpt.parameter_refs(v)) ? v : v(support) -# Override the base `copy_variables_onto_model` for `InfiniteModel`: in addition -# to decision variables, copy infinite parameters (with supports), -# derivatives, and parameter functions. `PointVariableRef`s (e.g. -# `L(0)` from boundary conditions) are skipped — they re-emerge on -# the target via `replace_variables_in_constraint`. -function DP.copy_variables_onto_model( - target::InfiniteOpt.InfiniteModel, - source::InfiniteOpt.InfiniteModel - ) - ref_map = Dict{InfiniteOpt.GeneralVariableRef, - InfiniteOpt.GeneralVariableRef}() - for p in InfiniteOpt.all_parameters(source) - domain = InfiniteOpt.infinite_domain(p) - supports = Float64.(InfiniteOpt.supports(p)) - param = InfiniteOpt.build_parameter(error, domain; - supports = supports) - ref_map[p] = InfiniteOpt.add_parameter(target, param, - JuMP.name(p)) - end - for v in JuMP.all_variables(source) - # Point variables (e.g. `L(0)` from boundary conditions) are - # implicit evaluations of their underlying infinite var; they - # carry NaN start values by default and don't need to be - # copied as primary decision variables. They re-emerge on the - # target via `replace_variables_in_constraint`. - _is_point_var(v) && continue - prefs = InfiniteOpt.parameter_refs(v) - var_type = isempty(prefs) ? nothing : - InfiniteOpt.Infinite(Tuple(ref_map[p] for p in prefs)...) - props = DP.VariableProperties( - DP.get_variable_info(v), JuMP.name(v), nothing, var_type) - ref_map[v] = DP.create_variable(target, props) - end - for d in InfiniteOpt.all_derivatives(source) - vref = InfiniteOpt.derivative_argument(d) - pref = InfiniteOpt.operator_parameter(d) - new_d = InfiniteOpt.deriv(ref_map[vref], ref_map[pref]) - info = DP.get_variable_info(d) - info.has_lb && JuMP.set_lower_bound(new_d, info.lower_bound) - info.has_ub && JuMP.set_upper_bound(new_d, info.upper_bound) - ref_map[d] = new_d - end - for pfunc in InfiniteOpt.all_parameter_functions(source) - func = InfiniteOpt.raw_function(pfunc) - prefs = InfiniteOpt.parameter_refs(pfunc) - mapped_prefs = Tuple(ref_map[p] for p in prefs) - pref_arg = length(mapped_prefs) == 1 ? - only(mapped_prefs) : mapped_prefs - param_func = InfiniteOpt.build_parameter_function(error, - func, pref_arg) - ref_map[pfunc] = InfiniteOpt.add_parameter_function(target, - param_func) - end - return ref_map -end - # Build map: transcribed input JuMP var → master point variable. # For an infinite input var v, every transcribed support `v_k` # maps to `ref_map[v](d_k)` (master point variable). Used as @@ -635,16 +572,15 @@ function _transcribed_to_master_point( return result end -# Build the LOA master as a fresh empty `InfiniteModel` populated -# with the input model's structural skeleton plus its linear -# constraints. Install `alpha_oa` as a finite variable. -# `binary_map[indicator]` and `variable_map[v]` hold single -# InfiniteOpt vars on the master; per-support handling happens -# downstream via point evaluation on those refs. OA cuts added in -# the LOA loop are point-evaluated scalar constraints on the master -# InfiniteModel; transcription is rebuilt before each master solve. +# Build the LOA master from `JuMP.copy_model(model)` and strip the +# nonlinear constraints (they re-enter via OA cuts). `binary_map[ +# indicator]` and `variable_map[v]` hold single InfiniteOpt vars on +# the master; per-support handling happens downstream via point +# evaluation on those refs. OA cuts added in the LOA loop are +# point-evaluated scalar constraints on the master InfiniteModel; +# transcription is rebuilt before each master solve. # -# Objective handling branches on `has_aggregate_ref`. When the +# Objective handling branches on `_has_aggregate_ref`. When the # objective is aggregate-free (no MeasureRef / ParameterFunctionRef), # `original_objective` is the InfiniteOpt objective itself and # `objective_ref_map = ref_map`, so AD walks the InfiniteOpt @@ -654,27 +590,44 @@ end function DP.build_loa_master( model::InfiniteOpt.InfiniteModel, method::DP.LOA ) - master = InfiniteOpt.InfiniteModel() + master, copy_ref_map = JuMP.copy_model(model) + # `copy_model` copies the GDP optimize-hook; clear it so + # `optimize!(master)` doesn't re-trigger reformulation on the + # (empty-GDP-data) master copy. + JuMP.set_optimize_hook(master, nothing) JuMP.set_optimizer(master, method.mip_optimizer) JuMP.set_silent(master) - ref_map = DP.copy_variables_onto_model(master, model) - + # Strip nonlinear constraints; they re-enter via OA cuts. + # Variable bounds (F=GeneralVariableRef) are kept by `copy_model`. variable_type = InfiniteOpt.GeneralVariableRef - for (F, S) in JuMP.list_of_constraint_types(model) + for (F, S) in JuMP.list_of_constraint_types(master) F === variable_type && continue - DP._is_linear_F(F) || continue - for cref in JuMP.all_constraints(model, F, S) - con = JuMP.constraint_object(cref) - new_func = DP.replace_variables_in_constraint( - con.func, ref_map) - JuMP.@constraint(master, new_func in con.set) + DP._is_linear_F(F) && continue + for cref in JuMP.all_constraints(master, F, S) + JuMP.delete(master, cref) end end + # InfiniteReferenceMap supports indexing but not iteration; build + # a Dict so downstream LOA code can `haskey` / iterate over the + # source-side refs LOA cares about. + ref_map = Dict{InfiniteOpt.GeneralVariableRef, + InfiniteOpt.GeneralVariableRef}() + for v in DP.collect_all_vars(model) + _is_point_var(v) && continue + ref_map[v] = copy_ref_map[v] + end + for p in InfiniteOpt.all_parameters(model) + ref_map[p] = copy_ref_map[p] + end + for pfunc in InfiniteOpt.all_parameter_functions(model) + ref_map[pfunc] = copy_ref_map[pfunc] + end + raw_objective = JuMP.objective_function(model) objective_sense = JuMP.objective_sense(model) - if DP.has_aggregate_ref(raw_objective) + if _has_aggregate_ref(raw_objective) InfiniteOpt.build_transformation_backend!(model) transcribed_input = InfiniteOpt.transformation_model(model) original_objective = JuMP.objective_function(transcribed_input) @@ -709,23 +662,24 @@ function DP.build_loa_master( end # Override the disjunct-cut loop for `InfiniteModel`. Same shape as -# Override `_add_oa_cuts` for `InfiniteModel` to translate the +# Override `add_oa_cuts` for `InfiniteModel` to translate the # linearization point into the form the master's `original_objective` -# expects. The base `result.linearization_point` has per-support -# `Vector` values keyed on InfiniteOpt vars; the master's objective -# is either the raw InfiniteOpt expression (non-aggregate, expects -# scalar `xk[v]`) or the transcribed flat scalar expression -# (aggregate, expects transcribed-`JuMP.VariableRef`-keyed scalar -# dict). The translation produces whichever shape `_linearize_at` -# needs for this master. -function DP._add_oa_cuts( +# expects, and to route global OA cuts through transcription so they +# work over infinite vars and aggregate refs. The base +# `result.linearization_point` has per-support `Vector` values keyed +# on InfiniteOpt vars; the master's objective is either the raw +# InfiniteOpt expression (non-aggregate, expects scalar `xk[v]`) or +# the transcribed flat scalar expression (aggregate, expects +# transcribed-`JuMP.VariableRef`-keyed scalar dict). The translation +# produces whichever shape `_linearize_at` needs. +function DP.add_oa_cuts( model::InfiniteOpt.InfiniteModel, master::NamedTuple, result::NamedTuple, method::DP.LOA ) isempty(result.linearization_point) && return - obj_point = if DP.has_aggregate_ref(JuMP.objective_function(model)) + obj_point = if _has_aggregate_ref(JuMP.objective_function(model)) _transcribe_linearization_point( model, result.linearization_point) else @@ -739,15 +693,64 @@ function DP._add_oa_cuts( obj_point, master.objective_ref_map) DP._add_objective_cut( Val(master.objective_sense), master, linearization) - # WIP: InfiniteOpt globals need per-support handling analogous to - # the obj_point translation above (transcribe-or-flatten). Until - # that's implemented, base `_add_global_oa_cuts` is skipped here — - # calling it would break on infinite vars (per-support `Vector` - # values flow into `_linearize_at`'s scalar-only AD path). + _add_global_oa_cuts_infinite(model, master, result, method) DP.add_disjunct_oa_cuts(model, master, result, method) return end +# Global OA cuts for `InfiniteModel`. Mirrors base `_add_global_oa_cuts` +# but routes through transcription so per-support / aggregate-ref +# expressions reach `_linearize_at` as flat scalars over `JuMP.VariableRef`s. +# Aggregate-containing constraints (e.g. `∫f(x,t)dt ≤ 0`) transcribe +# to a single scalar expression. Constraints with infinite-parameter +# dependence (e.g. `x(t) ≥ 0`) transcribe to a per-support `AbstractArray` +# of scalar expressions — one OA cut is emitted per support. +function _add_global_oa_cuts_infinite( + model::InfiniteOpt.InfiniteModel, + master::NamedTuple, + result::NamedTuple, + method::DP.LOA + ) + variable_type = InfiniteOpt.GeneralVariableRef + reform_set = DP.is_gdp_model(model) ? + Set(DP._reformulation_constraints(model)) : Set() + transcribed_xk = Ref{Any}(nothing) + transcribed_to_master = Ref{Any}(nothing) + ensure_transcribed = function () + transcribed_xk[] === nothing || return + InfiniteOpt.build_transformation_backend!(model) + transcribed_xk[] = _transcribe_linearization_point( + model, result.linearization_point) + transcribed_to_master[] = _transcribed_to_master_point( + model, master.variable_map) + return + end + for (F, S) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + DP._is_linear_F(F) && continue + for cref in JuMP.all_constraints(model, F, S) + cref in reform_set && continue + con = JuMP.constraint_object(cref) + con isa JuMP.ScalarConstraint || continue + ensure_transcribed() + transcribed_func = InfiniteOpt.transformation_expression( + con.func) + if transcribed_func isa AbstractArray + for tf in vec(transcribed_func) + lin = DP._linearize_at(tf, transcribed_xk[], + transcribed_to_master[]) + JuMP.@constraint(master.model, lin in con.set) + end + else + lin = DP._linearize_at(transcribed_func, + transcribed_xk[], transcribed_to_master[]) + JuMP.@constraint(master.model, lin in con.set) + end + end + end + return +end + # Override `add_disjunct_oa_cuts` for `InfiniteModel`. Same shape as # the base loop, but each constraint is checked for aggregate refs. # Aggregate constraints (e.g. those containing a `MeasureRef`) are @@ -791,7 +794,7 @@ function DP.add_disjunct_oa_cuts( JuMP.index(orig_constraint_ref)].constraint dual = get(result.duals, orig_constraint_ref, nothing) dual === nothing && continue - if DP.has_aggregate_ref(constraint.func) + if _has_aggregate_ref(constraint.func) ensure_transcribed() transcribed_func = InfiniteOpt.transformation_expression( @@ -824,35 +827,77 @@ function DP.add_disjunct_oa_cuts( end end -# Fix indicator binaries on the NLP model. Bool: fix the whole -# infinite var across all supports. Vector{Bool}: fix per-support via -# point equality constraints (refs stashed for `unfix_combination_binaries`). -function DP.fix_combination_binaries( +# Fix indicators for `combination`, run `f()`, then undo. Bool values +# are whole-var fixes via `JuMP.fix`; AbstractVector values are +# per-support point-equality constraints. Refs and fixed binaries are +# tracked in local state so cleanup runs from the same closure — no +# `model.ext` stash. +function DP.with_fixed_combination( + f, model::InfiniteOpt.InfiniteModel, combination::AbstractDict ) - fixing_constraint_refs = InfiniteOpt.InfOptConstraintRef[] + constraint_refs = InfiniteOpt.InfOptConstraintRef[] + fixed_binaries = InfiniteOpt.GeneralVariableRef[] + for (indicator, value) in combination + binary_ref = DP._indicator_to_binary(model)[indicator] + _apply_fix!( + constraint_refs, fixed_binaries, model, binary_ref, value) + end + try + return f() + finally + for ref in constraint_refs + JuMP.is_valid(model, ref) && JuMP.delete(model, ref) + end + for binary_ref in fixed_binaries + JuMP.is_fixed(binary_ref) && JuMP.unfix(binary_ref) + end + end +end + +# Finalize the LOA-best combination + warm start, leaving the model +# in a state the post-hook `optimize!` will accept. Mirrors +# `with_fixed_combination` for the fixing half but skips bookkeeping +# (these fixes stay). +function DP.commit_combination( + model::InfiniteOpt.InfiniteModel, + combination::AbstractDict, + linearization_point::AbstractDict + ) + constraint_refs = InfiniteOpt.InfOptConstraintRef[] + fixed_binaries = InfiniteOpt.GeneralVariableRef[] for (indicator, value) in combination binary_ref = DP._indicator_to_binary(model)[indicator] - _fix_binary_at_supports( - fixing_constraint_refs, model, binary_ref, value + _apply_fix!( + constraint_refs, fixed_binaries, model, binary_ref, value) + end + for (variable, values) in linearization_point + _set_starts_for_transcribed( + InfiniteOpt.transformation_variable(variable), + values ) end - model.ext[:_loa_fixing_constraint_refs] = fixing_constraint_refs + return end -function _fix_binary_at_supports( - _, - _, +# Bool: fix the whole infinite var across all supports. +function _apply_fix!( + ::AbstractVector, + fixed::AbstractVector, + ::InfiniteOpt.InfiniteModel, binary_ref::InfiniteOpt.GeneralVariableRef, value::Bool ) JuMP.fix(binary_ref, value ? 1.0 : 0.0; force = true) + push!(fixed, binary_ref) return end -function _fix_binary_at_supports( +# Vector{Bool}: per-support point-equality constraints. +function _apply_fix!( refs::AbstractVector, + ::AbstractVector, model::InfiniteOpt.InfiniteModel, binary_ref::InfiniteOpt.GeneralVariableRef, values::AbstractVector{Bool} @@ -864,18 +909,6 @@ function _fix_binary_at_supports( return end -function DP.set_start_values( - ::InfiniteOpt.InfiniteModel, - linearization_point::AbstractDict - ) - for (variable, values) in linearization_point - _set_starts_for_transcribed( - InfiniteOpt.transformation_variable(variable), - values - ) - end -end - # Infinite var: per-support transcribed array function _set_starts_for_transcribed( transcribed::AbstractArray, @@ -901,38 +934,6 @@ _set_starts_for_transcribed( ) = JuMP.set_start_value(transcribed, value) -function DP.unfix_combination_binaries( - model::InfiniteOpt.InfiniteModel, - combination::AbstractDict - ) - if haskey(model.ext, :_loa_fixing_constraint_refs) - for fixing_ref in model.ext[:_loa_fixing_constraint_refs] - JuMP.is_valid(model, fixing_ref) && - JuMP.delete(model, fixing_ref) - end - delete!(model.ext, :_loa_fixing_constraint_refs) - end - for (indicator, value) in combination - binary_ref = DP._indicator_to_binary(model)[indicator] - _unfix_binary_at_supports(binary_ref, value) - end -end - -# Bool: unfix the whole-var fix -function _unfix_binary_at_supports( - binary_ref::InfiniteOpt.GeneralVariableRef, - ::Bool - ) - JuMP.is_fixed(binary_ref) && JuMP.unfix(binary_ref) - return -end - -# Vector{Bool}: per-support fixing was via constraints already deleted above -_unfix_binary_at_supports( - ::InfiniteOpt.GeneralVariableRef, - ::AbstractVector{Bool} - ) = nothing - # Convert an InfiniteModel-var-keyed per-support point into a # transcribed-JuMP-var-keyed scalar point. Companion to # `_transcribed_to_master_point`: feeds the AD walker for the diff --git a/src/loa.jl b/src/loa.jl index a6c9e297..86dfb533 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -85,7 +85,7 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) for combination in combinations result = _solve_nlp(model, combination, method, reformulation_map) avoid_combination(master.model, combination, master.binary_map) - _add_oa_cuts(model, master, result, method) + add_oa_cuts(model, master, result, method) if result.feasible && is_better(result.objective) best_objective = result.objective best_result = result @@ -101,7 +101,7 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) combination = _extract_combination(model, master) result = _solve_nlp(model, combination, method, reformulation_map) avoid_combination(master.model, combination, master.binary_map) - _add_oa_cuts(model, master, result, method) + add_oa_cuts(model, master, result, method) if result.feasible && is_better(result.objective) best_objective = result.objective best_result = result @@ -109,8 +109,8 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) end if best_result !== nothing - fix_combination_binaries(model, best_result.combination) - set_start_values(model, best_result.linearization_point) + commit_combination(model, best_result.combination, + best_result.linearization_point) end _set_solution_method(model, method) _set_ready_to_optimize(model, true) @@ -239,42 +239,59 @@ end function _solve_nlp( model::M, combination, method::LOA, reformulation_map ) where {M <: JuMP.AbstractModel} - fix_combination_binaries(model, combination) - JuMP.optimize!(model, ignore_optimize_hook = true) - if JuMP.is_solved_and_feasible(model) - lin_point = extract_solution(model) - duals = _collect_nlp_duals(model, combination, reformulation_map) - objective_val = JuMP.objective_value(model) - unfix_combination_binaries(model, combination) + return with_fixed_combination(model, combination) do + JuMP.optimize!(model, ignore_optimize_hook = true) + if JuMP.is_solved_and_feasible(model) + lin_point = extract_solution(model) + duals = _collect_nlp_duals( + model, combination, reformulation_map) + objective_val = JuMP.objective_value(model) + return (combination = combination, + linearization_point = lin_point, duals = duals, + objective = objective_val, feasible = true) + end return (combination = combination, - linearization_point = lin_point, duals = duals, - objective = objective_val, feasible = true) + linearization_point = Dict{JuMP.AbstractVariableRef, Any}(), + duals = Dict{DisjunctConstraintRef{M}, Any}(), + objective = Inf, feasible = false) end - unfix_combination_binaries(model, combination) - return (combination = combination, - linearization_point = Dict{JuMP.AbstractVariableRef, Any}(), - duals = Dict{DisjunctConstraintRef{M}, Any}(), - objective = Inf, feasible = false) end -# OVERRIDABLE. Fix every indicator in `combination` on `model`. -function fix_combination_binaries( +# OVERRIDABLE. Fix the indicators in `combination`, run `f()`, unfix. +# Holds the fix/unfix lifecycle in one call so extensions can manage +# any per-support bookkeeping locally without `model.ext` stashing. +function with_fixed_combination( + f, model::JuMP.AbstractModel, combination::AbstractDict ) for (indicator, active) in combination fix_indicator(model, indicator, active) end + try + return f() + finally + for (indicator, _) in combination + unfix_indicator(model, indicator) + end + end end -# OVERRIDABLE. Inverse of `fix_combination_binaries`. -function unfix_combination_binaries( +# OVERRIDABLE. Finalize the model with the LOA-optimal combination: +# fix indicators (left fixed for the post-hook `optimize!`) and warm +# start from the linearization point. +function commit_combination( model::JuMP.AbstractModel, - combination::AbstractDict + combination::AbstractDict, + linearization_point::AbstractDict ) - for (indicator, _) in combination - unfix_indicator(model, indicator) + for (indicator, active) in combination + fix_indicator(model, indicator, active) + end + for (variable, value) in linearization_point + JuMP.set_start_value(variable, _unwrap_scalar(value)) end + return end ################################################################################ @@ -381,7 +398,7 @@ combination_val(binary_ref) = round(Bool, JuMP.value(binary_ref)) ################################################################################ # OA CUT EMISSION ################################################################################ -function _add_oa_cuts( +function add_oa_cuts( model::JuMP.AbstractModel, master::NamedTuple, result::NamedTuple, @@ -402,11 +419,6 @@ _add_objective_cut(::Val{_MOI.MIN_SENSE}, master, lin) = _add_objective_cut(::Val{_MOI.MAX_SENSE}, master, lin) = JuMP.@constraint(master.model, lin >= master.alpha_oa) -# WIP: finite-only. InfiniteOpt's `_add_oa_cuts(::InfiniteModel, ...)` -# override skips the call to this — globals over infinite vars yield -# per-support `Vector` values that the scalar-only `_linearize_at` AD -# path can't consume. A per-support-aware variant is the next step. -# # Add the OA cut `g(x^l) + ∇g(x^l)^T (x − x^l) in con.set` for every # nonlinear global constraint of `model` — the third cut class in # Türkay & Grossmann (1996, eq. 12) alongside the objective and the @@ -416,6 +428,10 @@ _add_objective_cut(::Val{_MOI.MAX_SENSE}, master, lin) = # `LessThan` and `GreaterThan` are supported; equalities and vector # constraints are passed through to `JuMP.@constraint` as-is — # valid for affine-after-linearization sets. +# +# Finite-only. InfiniteOpt's `add_oa_cuts(::InfiniteModel, ...)` +# override uses `_add_global_oa_cuts_infinite`, which routes through +# transcription to handle per-support / aggregate-ref globals. function _add_global_oa_cuts( model::JuMP.AbstractModel, master::NamedTuple, @@ -548,18 +564,3 @@ function _linearize_at( return result end -################################################################################ -# FINALIZATION -################################################################################ -# OVERRIDABLE. Set JuMP start values from a scalar-valued -# linearization-point dict. Warm-starts the post-hook `optimize!` -# that JuMP fires after `reformulate_model` — without this the final -# NLP solve restarts from wherever the last LOA iteration left the -# model. InfiniteOpt overrides for per-support Vector values. -function set_start_values( - ::JuMP.AbstractModel, linearization_point::AbstractDict - ) - for (variable, value) in linearization_point - JuMP.set_start_value(variable, _unwrap_scalar(value)) - end -end diff --git a/src/utilities.jl b/src/utilities.jl index 435ed27c..790aaffc 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -539,33 +539,6 @@ end # for outer approximation methods (LOA, future OA variants). ################################################################################ -################################################################################ -# AGGREGATE-REF DETECTION -################################################################################ -# Predicate: does the variable ref aggregate multiple decision -# variables behind a single leaf? An "aggregate" ref is one that AD -# cannot see inside — e.g. an InfiniteOpt `MeasureRef` (`∫ f(x,t) dt` -# is one ref but depends on `x(t_1), …, x(t_K)`) or a -# `ParameterFunctionRef`. Base returns false; the InfiniteOpt -# extension overrides for aggregate ref types. -# -# When `has_aggregate_ref(expr)` is true, MOI Nonlinear AD on `expr` -# would treat the aggregate as a single opaque variable and produce a -# meaningless gradient. The LOA pipeline falls back to transcription -# in that case (flat scalar expression, AD on the flat form, then map -# back to master refs). -# -# #suggestions for names are welcome -is_aggregate_ref(::JuMP.AbstractVariableRef) = false - -function has_aggregate_ref(expr) - found = Ref(false) - _interrogate_variables(expr) do v - found[] || (found[] = is_aggregate_ref(v)) - end - return found[] -end - ################################################################################ # MOI NONLINEAR EXPRESSION CONVERSION ################################################################################ From ca6118344853dfe85cb6f6e439fc03b835a76fa4 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Fri, 15 May 2026 15:34:28 -0400 Subject: [PATCH 41/72] Global slack bug fix --- Project.toml | 13 ++-- ext/InfiniteDisjunctiveProgramming.jl | 24 +++++- src/loa.jl | 75 +++++++++++++++++-- .../InfiniteDisjunctiveProgramming.jl | 73 ++++++++++++++++++ 4 files changed, 170 insertions(+), 15 deletions(-) diff --git a/Project.toml b/Project.toml index 3ca54b77..158e638c 100644 --- a/Project.toml +++ b/Project.toml @@ -10,25 +10,28 @@ Reexport = "189a3867-3050-52da-a836-e630ba90ab69" [weakdeps] InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" +[sources] +InfiniteOpt = {path = "..\\InfiniteOpt.jl"} + [extensions] InfiniteDisjunctiveProgramming = "InfiniteOpt" [compat] Aqua = "0.8" +InfiniteOpt = "0.6" +Ipopt = "1.9.0" JuMP = "1.18" +Juniper = "0.9.3" Reexport = "1" julia = "1.10" -Juniper = "0.9.3" -Ipopt = "1.9.0" -InfiniteOpt = "0.6" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" Juniper = "2ddba703-00a4-53a7-87a5-e8b9971dde84" -InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] test = ["Aqua", "HiGHS", "Test", "Juniper", "Ipopt", "InfiniteOpt"] diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 7c2024a2..99703e34 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -325,7 +325,10 @@ function DP.copy_and_reformulate( end sub = DP.GDPSubmodel(sub_copy, decision_vars, fwd_map) JuMP.set_optimizer(sub.model, method.optimizer) - JuMP.set_silent(sub.model) + # NOTE: previously called JuMP.set_silent(sub.model) here, but + # that hides the Gurobi B&B trace even when the caller passes + # OutputFlag=1 / LogToConsole=1 via optimizer_with_attributes. + # The caller is now responsible for silencing if desired. return sub end @@ -600,11 +603,20 @@ function DP.build_loa_master( # Strip nonlinear constraints; they re-enter via OA cuts. # Variable bounds (F=GeneralVariableRef) are kept by `copy_model`. + # An aggregate-wrapped constraint (e.g. `∫(x^2,t) ≤ c`) is + # structurally affine over a `MeasureRef` so `_is_linear_F` reads + # it as linear, but it expands to a nonlinear form on + # transcription — strip it too and let the OA cut re-add the + # linearized version. variable_type = InfiniteOpt.GeneralVariableRef for (F, S) in JuMP.list_of_constraint_types(master) F === variable_type && continue - DP._is_linear_F(F) && continue + is_linear = DP._is_linear_F(F) for cref in JuMP.all_constraints(master, F, S) + if is_linear + con = JuMP.constraint_object(cref) + _has_aggregate_ref(con.func) || continue + end JuMP.delete(master, cref) end end @@ -711,6 +723,8 @@ function _add_global_oa_cuts_infinite( result::NamedTuple, method::DP.LOA ) + _, penalty_sign = DP._disjunct_cut_coefficients( + Val(master.objective_sense)) variable_type = InfiniteOpt.GeneralVariableRef reform_set = DP.is_gdp_model(model) ? Set(DP._reformulation_constraints(model)) : Set() @@ -739,12 +753,14 @@ function _add_global_oa_cuts_infinite( for tf in vec(transcribed_func) lin = DP._linearize_at(tf, transcribed_xk[], transcribed_to_master[]) - JuMP.@constraint(master.model, lin in con.set) + DP._add_global_oa_row!(master, lin, con.set, + method, penalty_sign) end else lin = DP._linearize_at(transcribed_func, transcribed_xk[], transcribed_to_master[]) - JuMP.@constraint(master.model, lin in con.set) + DP._add_global_oa_row!(master, lin, con.set, + method, penalty_sign) end end end diff --git a/src/loa.jl b/src/loa.jl index 86dfb533..7bd59794 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -438,6 +438,8 @@ function _add_global_oa_cuts( result::NamedTuple, method::LOA ) + _, penalty_sign = _disjunct_cut_coefficients( + Val(master.objective_sense)) variable_type = JuMP.variable_ref_type(typeof(model)) reform_set = is_gdp_model(model) ? Set(_reformulation_constraints(model)) : Set() @@ -450,7 +452,8 @@ function _add_global_oa_cuts( con isa JuMP.ScalarConstraint || continue linearization = _linearize_at(con.func, result.linearization_point, master.variable_map) - JuMP.@constraint(master.model, linearization in con.set) + _add_global_oa_row!(master, linearization, con.set, + method, penalty_sign) end end return @@ -489,6 +492,22 @@ function add_disjunct_oa_cuts( end end +# Fresh nonnegative slack added to the master objective with the V&G +# 1990 penalty. Shared by the disjunct and global OA cuts so both get +# the same augmented-penalty treatment: a nonconvex linearization can +# be an invalid relaxation, and the penalized slack keeps the master +# feasible instead of letting accumulated cuts make it infeasible. +function _penalized_slack( + master::NamedTuple, method::LOA, penalty_sign::Int + ) + slack = JuMP.@variable(master.model, + lower_bound = 0.0, upper_bound = method.max_slack) + JuMP.set_objective_function(master.model, + JuMP.objective_function(master.model) + + penalty_sign * method.OA_penalty_factor * slack) + return slack +end + # Linearize constraint at `linearization_point`, append a fresh per-cut # slack with V&G penalty, gate by `M(1 − binary)`. Linear constraints # are exact via BigM and skipped. @@ -508,17 +527,61 @@ function _add_oa_cut_for_constraint( sign_value == 0 && return rhs = _set_rhs(constraint.set) linearization = _linearize_at(constraint.func, linearization_point, var_map) - slack = JuMP.@variable(master.model, - lower_bound = 0.0, upper_bound = method.max_slack) - JuMP.set_objective_function(master.model, - JuMP.objective_function(master.model) + - penalty_sign * method.OA_penalty_factor * slack) + slack = _penalized_slack(master, method, penalty_sign) JuMP.@constraint(master.model, sign_value * (linearization - rhs) - slack <= method.M_value * (1 - binary_ref)) return end +# Slacked global OA row(s) (V&G 1990). The nonconvex linearization may +# be an invalid relaxation, so each row carries a penalized slack +# rather than being a hard constraint — without this the accumulated +# global cuts make the master infeasible on nonconvex models. +# `EqualTo` / `Interval` get a two-sided pair sharing one slack. +# Unknown set types fall back to the prior hard cut. +function _add_global_oa_row!( + master::NamedTuple, lin, set::_MOI.LessThan, + method::LOA, penalty_sign::Int + ) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, lin - _MOI.constant(set) <= slack) + return +end +function _add_global_oa_row!( + master::NamedTuple, lin, set::_MOI.GreaterThan, + method::LOA, penalty_sign::Int + ) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, _MOI.constant(set) - lin <= slack) + return +end +function _add_global_oa_row!( + master::NamedTuple, lin, set::_MOI.EqualTo, + method::LOA, penalty_sign::Int + ) + slack = _penalized_slack(master, method, penalty_sign) + c = _MOI.constant(set) + JuMP.@constraint(master.model, lin - c <= slack) + JuMP.@constraint(master.model, c - lin <= slack) + return +end +function _add_global_oa_row!( + master::NamedTuple, lin, set::_MOI.Interval, + method::LOA, penalty_sign::Int + ) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, lin - set.upper <= slack) + JuMP.@constraint(master.model, set.lower - lin <= slack) + return +end +function _add_global_oa_row!( + master::NamedTuple, lin, set, ::LOA, ::Int + ) + JuMP.@constraint(master.model, lin in set) + return +end + # OVERRIDABLE. Yield `(binary_ref, lin_point, var_map, dual)` per OA # cut to emit. Scalar binary returns one tuple; InfiniteOpt overrides # for per-support `Vector` binaries to yield multiple sliced tuples. diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 47bb884b..bacf987c 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -791,6 +791,63 @@ function test_CuttingPlanes_multiparameter() [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] end +function test_loa_infinite_nonlinear_global() + # max ∫x dt s.t. x(t)^2 <= 25 (global, per-support nonlinear), + # (x <= 3) ∨ (x <= 8), 0 <= x <= 10 over t ∈ [0, 1]. + # Disjunct Y[2] permits x up to 8 but the global x^2 <= 25 caps + # x at 5. The per-support global transcribes to an `AbstractArray` + # of scalar constraints, so this exercises the array branch of + # `_add_global_oa_cuts_infinite`. Without the global cut the + # master would allow x = 8 and report 8.0; the binding optimum + # is ∫5 dt = 5. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + model = InfiniteGDPModel(ipopt) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) + @variable(model, 0 <= x <= 10, Infinite(t)) + @constraint(model, x^2 <= 25) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 8, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, ∫(x, t)) + optimize!(model, + gdp_method = LOA(ipopt; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in + (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 5.0 atol = 1e-2 +end + +function test_loa_infinite_aggregate_global() + # min ∫x dt s.t. ∫(x^2, t) <= 4 (aggregate global), x >= y, + # (y >= 1) ∨ (y >= 3), 0 <= x, y <= 10 over t ∈ [0, 1]. + # The aggregate global transcribes to a single scalar (the + # measure is flattened), exercising the non-array branch of + # `_add_global_oa_cuts_infinite`. Y[1] (y >= 1) is the cheaper + # disjunct: x = 1 satisfies x >= y and ∫x^2 = 1 <= 4, giving + # objective ∫1 dt = 1. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + model = InfiniteGDPModel(ipopt) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, 0 <= y <= 10) + @constraint(model, ∫(x^2, t) <= 4) + @constraint(model, x >= y) + @variable(model, Y[1:2], Logical) + @constraint(model, y >= 1, Disjunct(Y[1])) + @constraint(model, y >= 3, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Min, ∫(x, t)) + optimize!(model, + gdp_method = LOA(ipopt; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in + (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 1.0 atol = 1e-2 +end + @testset "InfiniteDisjunctiveProgramming" begin @testset "Model" begin @@ -855,4 +912,20 @@ end test_CuttingPlanes_multiparameter() end + # LOA on InfiniteOpt needs `JuMP.copy_model(::InfiniteModel)` + # (build_loa_master). That lives in the InfiniteOpt fork and is + # not in the registered release, so guard the suite: it runs + # (and validates the global-cut paths) when the fork is dev'd + # into the env, and skips cleanly otherwise. + @testset "LOA" begin + if hasmethod(JuMP.copy_model, Tuple{InfiniteModel}) + test_loa_infinite_nonlinear_global() + test_loa_infinite_aggregate_global() + else + @info "Skipping InfiniteOpt LOA tests: " * + "JuMP.copy_model(::InfiniteModel) unavailable " * + "(registered InfiniteOpt; dev the fork to run)." + end + end + end From cd9dd700a6cf5b978f2a7867ecdd0017b01f1ac7 Mon Sep 17 00:00:00 2001 From: dnguyen227 <82475321+dnguyen227@users.noreply.github.com> Date: Tue, 19 May 2026 11:34:24 -0400 Subject: [PATCH 42/72] Update InfiniteOpt version to 0.6.2 --- Project.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 3ca54b77..e1d401ae 100644 --- a/Project.toml +++ b/Project.toml @@ -20,7 +20,7 @@ Reexport = "1" julia = "1.10" Juniper = "0.9.3" Ipopt = "1.9.0" -InfiniteOpt = "0.6" +InfiniteOpt = "0.6.2" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" From bb0db434f6e721beec5d8fcf85cf6919ceea6d77 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Sun, 24 May 2026 16:37:13 -0400 Subject: [PATCH 43/72] Add filter_constraints workflow --- ext/InfiniteDisjunctiveProgramming.jl | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 6399c9c1..5f77dce7 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -182,12 +182,11 @@ function DP.copy_model_with_constraints( constraints::Vector{<:DP.DisjunctConstraintRef}, method::DP._MBM ) - mini, ref_map = JuMP.copy_model(model) - - # Drop global constraints. - for cref in JuMP.all_constraints(mini) - JuMP.delete(mini, cref) - end + # Filter out every source constraint at copy time instead of + # copying then deleting. Equivalent end state, fewer allocations. + mini, ref_map = JuMP.copy_model( + model; filter_constraints = cref -> false + ) for cref in constraints con = JuMP.constraint_object(cref) From 34b3ea877bcecf6a82d00054d1a110ad425a2545 Mon Sep 17 00:00:00 2001 From: dnguyen227 <82475321+dnguyen227@users.noreply.github.com> Date: Mon, 25 May 2026 19:08:26 -0400 Subject: [PATCH 44/72] Bump InfiniteOpt version to 0.6.3 Updated InfiniteOpt version from 0.6.2 to 0.6.3. --- Project.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Project.toml b/Project.toml index e1d401ae..f3fbb43e 100644 --- a/Project.toml +++ b/Project.toml @@ -20,7 +20,7 @@ Reexport = "1" julia = "1.10" Juniper = "0.9.3" Ipopt = "1.9.0" -InfiniteOpt = "0.6.2" +InfiniteOpt = "0.6.3" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" @@ -28,7 +28,6 @@ HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" Juniper = "2ddba703-00a4-53a7-87a5-e8b9971dde84" -InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" [targets] test = ["Aqua", "HiGHS", "Test", "Juniper", "Ipopt", "InfiniteOpt"] From 01d36dc33489e3ea1cf1c44d7c4027ea8cd3f5cf Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Tue, 26 May 2026 11:52:18 -0400 Subject: [PATCH 45/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 67 +++++++++++++++------------ 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 99703e34..f69f4793 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -182,12 +182,11 @@ function DP.copy_model_with_constraints( constraints::Vector{<:DP.DisjunctConstraintRef}, method::DP._MBM ) - mini, ref_map = JuMP.copy_model(model) - - # Drop global constraints. - for cref in JuMP.all_constraints(mini) - JuMP.delete(mini, cref) - end + # Skip every source constraint at copy time instead of + # copying-then-deleting. Equivalent end state, no churn. + mini, ref_map = JuMP.copy_model( + model; filter_constraints = cref -> false + ) for cref in constraints con = JuMP.constraint_object(cref) @@ -473,8 +472,14 @@ end function DP.cut_info( binary_ref::InfiniteOpt.GeneralVariableRef, active::Bool, - linearization_point, variable_map, dual + linearization_point::AbstractDict, + variable_map::AbstractDict, dual ) + # Finite binary (plain `Logical` indicator on an `InfiniteModel`) + # has no infinite parameters — no per-support breakdown to do, so + # emit a single site like the base scalar method. + isempty(InfiniteOpt.parameter_refs(binary_ref)) && + return ((binary_ref, linearization_point, variable_map, dual),) supports = _supports_of(binary_ref) return _cut_sites(binary_ref, supports, fill(true, length(supports)), @@ -484,7 +489,8 @@ end function DP.cut_info( binary_ref::InfiniteOpt.GeneralVariableRef, actives::AbstractVector, - linearization_point, variable_map, dual + linearization_point::AbstractDict, + variable_map::AbstractDict, dual ) return _cut_sites(binary_ref, _supports_of(binary_ref), actives, linearization_point, variable_map, dual) @@ -593,7 +599,26 @@ end function DP.build_loa_master( model::InfiniteOpt.InfiniteModel, method::DP.LOA ) - master, copy_ref_map = JuMP.copy_model(model) + # Linear, non-aggregate constraints stay on the master. Nonlinear + # constraints — and aggregate-wrapped affine ones like + # `∫(x^2,t) ≤ c`, which read linear via `_is_linear_F` over a + # `MeasureRef` but expand to a nonlinear form on transcription — + # re-enter as OA cuts after each NLP solve, so they are dropped + # at copy time instead of copied then deleted. Variable bounds + # proper live on VariableInfo and survive `copy_model` regardless; + # the `F === GeneralVariableRef` early-return covers + # variable-ref-as-constraint registrations. + variable_type = InfiniteOpt.GeneralVariableRef + master, copy_ref_map = JuMP.copy_model( + model; + filter_constraints = function (cref) + con = JuMP.constraint_object(cref) + F = typeof(con.func) + F === variable_type && return true + DP._is_linear_F(F) || return false + return !_has_aggregate_ref(con.func) + end + ) # `copy_model` copies the GDP optimize-hook; clear it so # `optimize!(master)` doesn't re-trigger reformulation on the # (empty-GDP-data) master copy. @@ -601,26 +626,6 @@ function DP.build_loa_master( JuMP.set_optimizer(master, method.mip_optimizer) JuMP.set_silent(master) - # Strip nonlinear constraints; they re-enter via OA cuts. - # Variable bounds (F=GeneralVariableRef) are kept by `copy_model`. - # An aggregate-wrapped constraint (e.g. `∫(x^2,t) ≤ c`) is - # structurally affine over a `MeasureRef` so `_is_linear_F` reads - # it as linear, but it expands to a nonlinear form on - # transcription — strip it too and let the OA cut re-add the - # linearized version. - variable_type = InfiniteOpt.GeneralVariableRef - for (F, S) in JuMP.list_of_constraint_types(master) - F === variable_type && continue - is_linear = DP._is_linear_F(F) - for cref in JuMP.all_constraints(master, F, S) - if is_linear - con = JuMP.constraint_object(cref) - _has_aggregate_ref(con.func) || continue - end - JuMP.delete(master, cref) - end - end - # InfiniteReferenceMap supports indexing but not iteration; build # a Dict so downstream LOA code can `haskey` / iterate over the # source-side refs LOA cares about. @@ -853,6 +858,9 @@ function DP.with_fixed_combination( model::InfiniteOpt.InfiniteModel, combination::AbstractDict ) + # Relax binaries so the NLP solver doesn't see ZeroOne (see the + # base `with_fixed_combination` for rationale). + relaxed_binaries = DP.relax_logical_vars(model) constraint_refs = InfiniteOpt.InfOptConstraintRef[] fixed_binaries = InfiniteOpt.GeneralVariableRef[] for (indicator, value) in combination @@ -869,6 +877,7 @@ function DP.with_fixed_combination( for binary_ref in fixed_binaries JuMP.is_fixed(binary_ref) && JuMP.unfix(binary_ref) end + DP.unrelax_logical_vars(relaxed_binaries) end end From 0ac6bd0d03d7a0ba6fb20d6cf52f410769789f97 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Tue, 26 May 2026 14:13:49 -0400 Subject: [PATCH 46/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 121 +++++++------------------- src/loa.jl | 97 +++++++++++++++------ test/constraints/loa.jl | 56 ++++++++++++ 3 files changed, 158 insertions(+), 116 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 8d549fb9..f7cfa416 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -848,117 +848,58 @@ function DP.add_disjunct_oa_cuts( end end -# Fix indicators for `combination`, run `f()`, then undo. Bool values -# are whole-var fixes via `JuMP.fix`; AbstractVector values are -# per-support point-equality constraints. Refs and fixed binaries are -# tracked in local state so cleanup runs from the same closure — no -# `model.ext` stash. -function DP.with_fixed_combination( - f, - model::InfiniteOpt.InfiniteModel, - combination::AbstractDict +# Apply per-indicator fixes for `combination` and return a closure +# that reverses them. Scalar (`Bool`) values fix the whole infinite +# var via `JuMP.fix`; per-support `AbstractVector{Bool}` values fix +# each support with a point-equality constraint. Refs and fixed +# binaries live in the closure — no `model.ext` stash. Used by base +# `with_fixed_combination` and `commit_combination`. +function DP._fix_combination( + model::InfiniteOpt.InfiniteModel, combination::AbstractDict ) - # Relax binaries so the NLP solver doesn't see ZeroOne (see the - # base `with_fixed_combination` for rationale). - relaxed_binaries = DP.relax_logical_vars(model) constraint_refs = InfiniteOpt.InfOptConstraintRef[] fixed_binaries = InfiniteOpt.GeneralVariableRef[] for (indicator, value) in combination binary_ref = DP._indicator_to_binary(model)[indicator] - _apply_fix!( - constraint_refs, fixed_binaries, model, binary_ref, value) + if value isa AbstractVector + for (k, support) in enumerate(_supports_of(binary_ref)) + push!(constraint_refs, JuMP.@constraint(model, + binary_ref(support) == (value[k] ? 1.0 : 0.0))) + end + else + JuMP.fix(binary_ref, value ? 1.0 : 0.0; force = true) + push!(fixed_binaries, binary_ref) + end end - try - return f() - finally + return function () for ref in constraint_refs JuMP.is_valid(model, ref) && JuMP.delete(model, ref) end for binary_ref in fixed_binaries JuMP.is_fixed(binary_ref) && JuMP.unfix(binary_ref) end - DP.unrelax_logical_vars(relaxed_binaries) - end -end - -# Finalize the LOA-best combination + warm start, leaving the model -# in a state the post-hook `optimize!` will accept. Mirrors -# `with_fixed_combination` for the fixing half but skips bookkeeping -# (these fixes stay). -function DP.commit_combination( - model::InfiniteOpt.InfiniteModel, - combination::AbstractDict, - linearization_point::AbstractDict - ) - constraint_refs = InfiniteOpt.InfOptConstraintRef[] - fixed_binaries = InfiniteOpt.GeneralVariableRef[] - for (indicator, value) in combination - binary_ref = DP._indicator_to_binary(model)[indicator] - _apply_fix!( - constraint_refs, fixed_binaries, model, binary_ref, value) - end - for (variable, values) in linearization_point - _set_starts_for_transcribed( - InfiniteOpt.transformation_variable(variable), - values - ) - end - return -end - -# Bool: fix the whole infinite var across all supports. -function _apply_fix!( - ::AbstractVector, - fixed::AbstractVector, - ::InfiniteOpt.InfiniteModel, - binary_ref::InfiniteOpt.GeneralVariableRef, - value::Bool - ) - JuMP.fix(binary_ref, value ? 1.0 : 0.0; force = true) - push!(fixed, binary_ref) - return -end - -# Vector{Bool}: per-support point-equality constraints. -function _apply_fix!( - refs::AbstractVector, - ::AbstractVector, - model::InfiniteOpt.InfiniteModel, - binary_ref::InfiniteOpt.GeneralVariableRef, - values::AbstractVector{Bool} - ) - for (k, support) in enumerate(_supports_of(binary_ref)) - push!(refs, JuMP.@constraint(model, - binary_ref(support) == (values[k] ? 1.0 : 0.0))) end - return end -# Infinite var: per-support transcribed array -function _set_starts_for_transcribed( - transcribed::AbstractArray, +# Broadcast a per-support warm start across an infinite var's +# transcribed instances; for a finite var on an InfiniteModel, +# `transcription_variable` returns a single ref and `values` is +# length-1. +function DP._set_warm_start!( + variable::InfiniteOpt.GeneralVariableRef, values::AbstractVector ) - for (k, ref) in enumerate(vec(transcribed)) - JuMP.set_start_value(ref, values[k]) + transcribed = InfiniteOpt.transformation_variable(variable) + if transcribed isa AbstractArray + for (k, ref) in enumerate(vec(transcribed)) + JuMP.set_start_value(ref, values[k]) + end + else + JuMP.set_start_value(transcribed, only(values)) end return end -# Finite var: single transcribed ref, `values` is 1-element -_set_starts_for_transcribed( - transcribed::JuMP.AbstractVariableRef, - values::AbstractVector - ) = - JuMP.set_start_value(transcribed, values[1]) - -# Finite var: single transcribed ref, scalar value (feas-side path) -_set_starts_for_transcribed( - transcribed::JuMP.AbstractVariableRef, - value::Real - ) = - JuMP.set_start_value(transcribed, value) - # Convert an InfiniteModel-var-keyed per-support point into a # transcribed-JuMP-var-keyed scalar point. Companion to # `_transcribed_to_master_point`: feeds the AD walker for the diff --git a/src/loa.jl b/src/loa.jl index 7bd59794..8afcd5f7 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -15,15 +15,21 @@ # METHOD TYPE ################################################################################ """ - LOA{O, P} <: AbstractReformulationMethod + LOA{O, P, R} <: AbstractReformulationMethod Logic-based Outer Approximation solver for GDP models. Uses two models: the -original (BigM-reformulated, binaries fixed per iteration as an NLP) and a -master MILP copy that accumulates OA and no-good cuts. +original (reformulated by `inner_method`, binaries fixed per iteration as an +NLP) and a master MILP copy that accumulates OA and no-good cuts. + +`inner_method` defaults to `BigM(M_value)`; pass `MBM(optimizer)` to use +tighter per-constraint Ms. Other reformulations (`Hull`, `PSplit`) are not +yet supported — they emit constraints whose shapes the LOA dual-collection +logic can't slice. """ -struct LOA{O, P} <: AbstractReformulationMethod +struct LOA{O, P, R} <: AbstractReformulationMethod nlp_optimizer::O mip_optimizer::P + inner_method::R max_iter::Int atol::Float64 rtol::Float64 @@ -38,10 +44,11 @@ struct LOA{O, P} <: AbstractReformulationMethod rtol::Float64 = 1e-4, M_value::Float64 = 1e9, max_slack::Float64 = 1000.0, - OA_penalty_factor::Float64 = 1000.0 - ) where {O, P} - new{O, P}(nlp_optimizer, mip_optimizer, max_iter, atol, rtol, - M_value, max_slack, OA_penalty_factor) + OA_penalty_factor::Float64 = 1000.0, + inner_method::R = BigM(M_value) + ) where {O, P, R <: AbstractReformulationMethod} + new{O, P, R}(nlp_optimizer, mip_optimizer, inner_method, max_iter, + atol, rtol, M_value, max_slack, OA_penalty_factor) end end @@ -66,7 +73,7 @@ _flip_sense(::Val{_MOI.MAX_SENSE}) = Val(_MOI.MIN_SENSE) function reformulate_model(model::JuMP.AbstractModel, method::LOA) _clear_reformulations(model) combinations = _set_covering_combinations(model) - reformulate_model(model, BigM(method.M_value)) + reformulate_model(model, method.inner_method) master = build_loa_master(model, method) reformulation_map = _build_reformulation_map(model) @@ -97,6 +104,8 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) JuMP.optimize!(master.model) JuMP.is_solved_and_feasible(master.model) || break master_bound = JuMP.objective_value(master.model) + @info "LOA iter $iter: LB=$master_bound UB=$best_objective " * + "gap=$(_gap(sense_token, best_objective, master_bound))" _loa_converged(best_objective, master_bound, sense_token, method) && break combination = _extract_combination(model, master) result = _solve_nlp(model, combination, method, reformulation_map) @@ -257,43 +266,70 @@ function _solve_nlp( end end -# OVERRIDABLE. Fix the indicators in `combination`, run `f()`, unfix. -# Holds the fix/unfix lifecycle in one call so extensions can manage -# any per-support bookkeeping locally without `model.ext` stashing. +# Fix `combination`, run `f()`, unfix. Logical binaries are relaxed +# from ZeroOne for the duration so a pure NLP solver (Ipopt) can +# handle the inner solve; restored on exit. Extensions override +# `_fix_combination` to handle per-support fixing without needing +# their own try/finally lifecycle. function with_fixed_combination( f, model::JuMP.AbstractModel, combination::AbstractDict ) - for (indicator, active) in combination - fix_indicator(model, indicator, active) - end + relaxed = relax_logical_vars(model) + undo = _fix_combination(model, combination) try return f() finally - for (indicator, _) in combination - unfix_indicator(model, indicator) - end + undo() + unrelax_logical_vars(relaxed) end end -# OVERRIDABLE. Finalize the model with the LOA-optimal combination: -# fix indicators (left fixed for the post-hook `optimize!`) and warm -# start from the linearization point. +# Finalize the model with the LOA-optimal combination: relax logical +# binaries (stripping ZeroOne so a pure NLP solver can handle the +# post-hook `JuMP.optimize!`), fix indicators at the committed values, +# and warm-start from the linearization point. After this, the model +# is no longer in a state suitable for re-running with a different +# `gdp_method`. function commit_combination( model::JuMP.AbstractModel, combination::AbstractDict, linearization_point::AbstractDict ) - for (indicator, active) in combination - fix_indicator(model, indicator, active) - end - for (variable, value) in linearization_point - JuMP.set_start_value(variable, _unwrap_scalar(value)) + relax_logical_vars(model) + _fix_combination(model, combination) + for (variable, values) in linearization_point + _set_warm_start!(variable, values) end return end +# OVERRIDABLE. Apply the combination's fixes and return a closure that +# undoes them. Base loops `fix_indicator`/`unfix_indicator`. The +# InfiniteOpt extension overrides this to handle per-support +# `Vector{Bool}` values via point-equality constraints, keeping all +# cleanup state captured in the returned closure. +function _fix_combination( + model::JuMP.AbstractModel, combination::AbstractDict + ) + for (indicator, value) in combination + fix_indicator(model, indicator, value) + end + return function () + for (indicator, _) in combination + unfix_indicator(model, indicator) + end + end +end + +# OVERRIDABLE. Write the LOA linearization point into a variable's +# warm start. `values` is always per-support shape (length-1 vector +# for finite vars); base unwraps. The InfiniteOpt extension overrides +# for `GeneralVariableRef` to broadcast across transcribed supports. +_set_warm_start!(variable, values::AbstractVector) = + JuMP.set_start_value(variable, only(values)) + ################################################################################ # BIGM DUAL COLLECTION ################################################################################ @@ -585,6 +621,8 @@ end # OVERRIDABLE. Yield `(binary_ref, lin_point, var_map, dual)` per OA # cut to emit. Scalar binary returns one tuple; InfiniteOpt overrides # for per-support `Vector` binaries to yield multiple sliced tuples. +# `GenericAffExpr` covers complement-form indicators (`1 - y`) stored +# in `binary_map` when a `Logical` is declared with `logical_complement`. cut_info( binary_ref::JuMP.AbstractVariableRef, active::Bool, @@ -592,6 +630,13 @@ cut_info( variable_map::AbstractDict, dual ) = ((binary_ref, linearization_point, variable_map, dual),) +cut_info( + binary_ref::JuMP.GenericAffExpr, + active::Bool, + linearization_point::AbstractDict, + variable_map::AbstractDict, + dual + ) = ((binary_ref, linearization_point, variable_map, dual),) # OVERRIDABLE. Truthiness of an active descriptor. InfiniteOpt # overrides for per-support `Vector{Bool}`. diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 712e48c1..b92db13d 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -10,6 +10,8 @@ function test_loa_datatype() @test method.M_value == 1e9 @test method.max_slack == 1000.0 @test method.OA_penalty_factor == 1000.0 + @test method.inner_method isa BigM + @test method.inner_method.value == 1e9 method = LOA(HiGHS.Optimizer; max_iter = 50, atol = 1e-8, rtol = 1e-6, M_value = 1e6, max_slack = 500.0, @@ -20,6 +22,11 @@ function test_loa_datatype() @test method.M_value == 1e6 @test method.max_slack == 500.0 @test method.OA_penalty_factor == 200.0 + @test method.inner_method isa BigM + @test method.inner_method.value == 1e6 + + method = LOA(HiGHS.Optimizer; inner_method = MBM(HiGHS.Optimizer)) + @test method.inner_method isa MBM end function test_set_covering_combos() @@ -110,6 +117,25 @@ function test_loa_solve_simple() @test objective_value(model) ≈ 7.0 atol=1e-4 end +function test_loa_solve_simple_with_mbm() + # Same GDP as test_loa_solve_simple, but inner_method = MBM so the + # NLP model is built with per-constraint tight Ms instead of BigM. + # Optimum unchanged: x = 7 via the second disjunct. + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + + optimize!(model, gdp_method = LOA(HiGHS.Optimizer; + inner_method = MBM(HiGHS.Optimizer))) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 7.0 atol=1e-4 +end + function test_loa_solve_two_disjunctions() # Two disjunctions: max x + z # D1: (x <= 3) OR (x <= 7) @@ -164,6 +190,34 @@ function test_loa_nonlinear_global() @test objective_value(model) ≈ 5.0 atol = 1e-3 end +function test_loa_complement_indicator_nonlinear_disjunct() + # Regression: complement-form indicators store `1 - y_base` (an + # AffExpr) in `binary_map`. When the complement disjunct has a + # nonlinear constraint, `add_disjunct_oa_cuts` calls `cut_info` + # on that AffExpr; the AffExpr method must dispatch. + # + # Setup: 0 <= x <= 10, Y2 ≡ ¬Y1. + # Disjunct(Y1): x <= 3 (linear) + # Disjunct(Y2): x^2 <= 64 (nonlinear, optimum: x = 8 with Y2) + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = GDPModel(juniper) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y1, Logical) + @variable(model, Y2, Logical, logical_complement = Y1) + @constraint(model, x <= 3, Disjunct(Y1)) + @constraint(model, x^2 <= 64, Disjunct(Y2)) + @disjunction(model, [Y1, Y2]) + @objective(model, Max, x) + optimize!(model, + gdp_method = LOA(juniper; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-3 +end + function test_linearize_nonlinear_exp() # exp(x) + y at (1, 2): # f = e + 2, ∇f = [e, 1] @@ -242,9 +296,11 @@ end test_loa_convergence_check() test_loa_reformulate_simple() test_loa_solve_simple() + test_loa_solve_simple_with_mbm() test_loa_solve_two_disjunctions() test_loa_error_fallback() test_loa_nonlinear_global() + test_loa_complement_indicator_nonlinear_disjunct() test_linearize_nonlinear_exp() test_linearize_nonlinear_sin() test_linearize_nonlinear_multivar() From 54c2bc5d7f625847110b61512e7f1517052ce6f0 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Tue, 26 May 2026 15:07:41 -0400 Subject: [PATCH 47/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 71 +++++++++++++------ .../InfiniteDisjunctiveProgramming.jl | 66 +++++++++++++++++ 2 files changed, 116 insertions(+), 21 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index f7cfa416..993fcddc 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -402,8 +402,21 @@ function DP.combination_val(v::InfiniteOpt.GeneralVariableRef) return val isa AbstractArray ? vec(round.(Bool, val)) : round(Bool, val) end -_supports_of(v::InfiniteOpt.GeneralVariableRef) = - vec(InfiniteOpt.supports(only(InfiniteOpt.parameter_refs(v)))) +# Supports of the infinite parameter group `v` depends on. For a 1-D +# parameter, `supports(p)` is a `Vector{Float64}`; for a dependent +# group of dimension k, it is a k × N_supports `Matrix`. Returns each +# joint support point as one element — scalar for 1-D, column view for +# multi-D — so callers can iterate uniformly. Pair with `_at_support`, +# which splats vector supports into the variable's call form. +function _supports_of(v::InfiniteOpt.GeneralVariableRef) + p = only(InfiniteOpt.parameter_refs(v)) + sup = InfiniteOpt.supports(p) + ndims(sup) == 1 && return sup + # Materialize each column as a concrete `Vector{Float64}`; + # `eachcol` yields `SubArray` views that InfiniteOpt's + # `VectorTuple` constructor refuses. + return [sup[:, k] for k in axes(sup, 2)] +end _is_point_var(v::InfiniteOpt.GeneralVariableRef) = InfiniteOpt.dispatch_variable_ref(v) isa InfiniteOpt.PointVariableRef @@ -452,7 +465,7 @@ function DP.add_no_good_terms( return invoke(DP.add_no_good_terms, Tuple{Any, Any, Bool}, cut, binary_ref, active) for support in _supports_of(binary_ref) - DP.add_no_good_terms(cut, binary_ref(support), active) + DP.add_no_good_terms(cut, _at_support(binary_ref, support), active) end return end @@ -465,7 +478,8 @@ function DP.add_no_good_terms( ) supports = _supports_of(binary_ref) for (k, support) in enumerate(supports) - DP.add_no_good_terms(cut, binary_ref(support), actives[k]) + DP.add_no_good_terms( + cut, _at_support(binary_ref, support), actives[k]) end return end @@ -519,7 +533,7 @@ function _cut_sites( point[variable] = _at(point_value, k) end push!(sites, ( - binary_ref(support), + _at_support(binary_ref, support), point, point_var_map, _at(dual, k) @@ -528,12 +542,19 @@ function _cut_sites( return sites end -# Slice a per-support container at index `k`; pass scalars through. -_at(values::AbstractArray, k::Integer) = values[k] +# Slice a per-support container at index `k`. Length-1 vectors are +# finite-shape (one value applies at every support, per +# `_per_support_values` wrapping `JuMP.value` of a finite var as +# `[scalar]`) — return the single value regardless of `k`. Scalars +# pass through. +_at(values::AbstractArray, k::Integer) = + length(values) == 1 ? values[1] : values[k] _at(scalar, ::Integer) = scalar # Point-evaluate an InfiniteOpt var at `support` if it's infinite; -# return the var as-is if it's finite. +# return the var as-is if it's finite. `support` is one joint support +# point — a scalar for 1-D parameters, a vector for a multi-D +# dependent group — and `v(support)` matches both call forms. _at_support(v::InfiniteOpt.GeneralVariableRef, support) = isempty(InfiniteOpt.parameter_refs(v)) ? v : v(support) @@ -849,34 +870,42 @@ function DP.add_disjunct_oa_cuts( end # Apply per-indicator fixes for `combination` and return a closure -# that reverses them. Scalar (`Bool`) values fix the whole infinite -# var via `JuMP.fix`; per-support `AbstractVector{Bool}` values fix -# each support with a point-equality constraint. Refs and fixed -# binaries live in the closure — no `model.ext` stash. Used by base -# `with_fixed_combination` and `commit_combination`. +# that reverses them. Scalar (`Bool`) values delegate to base +# `fix_indicator` (which unwraps complement-form `1 - y` AffExprs to +# the underlying binary and inverts the target). Per-support +# `AbstractVector{Bool}` values fix each support via a point-equality +# constraint on the underlying infinite var. State lives in the +# closure — no `model.ext` stash. Used by base `with_fixed_combination` +# and `commit_combination`. function DP._fix_combination( model::InfiniteOpt.InfiniteModel, combination::AbstractDict ) constraint_refs = InfiniteOpt.InfOptConstraintRef[] - fixed_binaries = InfiniteOpt.GeneralVariableRef[] + fixed_indicators = DP.LogicalVariableRef{ + InfiniteOpt.InfiniteModel}[] for (indicator, value) in combination - binary_ref = DP._indicator_to_binary(model)[indicator] if value isa AbstractVector - for (k, support) in enumerate(_supports_of(binary_ref)) + binary_ref = DP._indicator_to_binary(model)[indicator] + target, target_for_active = binary_ref isa JuMP.GenericAffExpr ? + (only(keys(binary_ref.terms)), 0.0) : + (binary_ref, 1.0) + target_for_inactive = 1.0 - target_for_active + for (k, support) in enumerate(_supports_of(target)) push!(constraint_refs, JuMP.@constraint(model, - binary_ref(support) == (value[k] ? 1.0 : 0.0))) + _at_support(target, support) == + (value[k] ? target_for_active : target_for_inactive))) end else - JuMP.fix(binary_ref, value ? 1.0 : 0.0; force = true) - push!(fixed_binaries, binary_ref) + DP.fix_indicator(model, indicator, value) + push!(fixed_indicators, indicator) end end return function () for ref in constraint_refs JuMP.is_valid(model, ref) && JuMP.delete(model, ref) end - for binary_ref in fixed_binaries - JuMP.is_fixed(binary_ref) && JuMP.unfix(binary_ref) + for indicator in fixed_indicators + DP.unfix_indicator(model, indicator) end end end diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index bacf987c..dc70b802 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -819,6 +819,70 @@ function test_loa_infinite_nonlinear_global() @test objective_value(model) ≈ 5.0 atol = 1e-2 end +function test_loa_infinite_complement_indicator() + # Regression: `_indicator_to_binary(model)[Y2]` returns the AffExpr + # `1 - binary(Y1)` for a logical-complement indicator. Earlier the + # extension's `_fix_combination` called `JuMP.fix` directly on that + # AffExpr and crashed; now it delegates to base `fix_indicator` + # which unwraps and inverts. + # + # Linear disjuncts only — a nonlinear disjunct constraint on an + # infinite variable would also hit a separate gap where + # `cut_info` for a finite indicator does not slice the per-support + # linearization point. Out of scope for this regression. + # + # max ∫ x dt with 0 ≤ x ≤ 10 over t ∈ [0,1]: + # Y1: x ≤ 3 Y2 ≡ ¬Y1: x ≤ 8 + # Y2 is the maximizer (x = 8 across t), giving ∫8 dt = 8. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + model = InfiniteGDPModel(ipopt) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y1, Logical) + @variable(model, Y2, Logical, logical_complement = Y1) + @constraint(model, x <= 3, Disjunct(Y1)) + @constraint(model, x <= 8, Disjunct(Y2)) + @disjunction(model, [Y1, Y2]) + @objective(model, Max, ∫(x, t)) + optimize!(model, + gdp_method = LOA(ipopt; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-2 +end + +function test_loa_infinite_multidim_parameter() + # Regression: multi-D dependent parameter group. `supports(ξ)` is + # a 2 × N matrix, not a vector. Earlier the extension called + # `vec(supports(ξ))`, flattened to 2·N scalars, then called + # `binary_ref(scalar)` on a 2-D infinite var — dim mismatch. + # Fixed by returning `eachcol(supports(ξ))` and splatting vector + # supports through `_at_support`. + # + # max w with 0 ≤ x ≤ 10 over ξ[1:2] ∈ [0,1]², w ≤ x at every joint + # support: + # Y[1]: x ≤ 3 Y[2]: x ≤ 8 + # Y[2] is the maximizer (x = 8 across ξ allows w = 8). + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + model = InfiniteGDPModel(ipopt) + set_silent(model) + @infinite_parameter(model, ξ[1:2] ∈ [0, 1], num_supports = 4) + @variable(model, 0 <= x <= 10, Infinite(ξ)) + @variable(model, 0 <= w <= 10) + @constraint(model, w <= x) + @variable(model, Y[1:2], InfiniteLogical(ξ)) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 8, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, w) + optimize!(model, + gdp_method = LOA(ipopt; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-2 +end + function test_loa_infinite_aggregate_global() # min ∫x dt s.t. ∫(x^2, t) <= 4 (aggregate global), x >= y, # (y >= 1) ∨ (y >= 3), 0 <= x, y <= 10 over t ∈ [0, 1]. @@ -921,6 +985,8 @@ end if hasmethod(JuMP.copy_model, Tuple{InfiniteModel}) test_loa_infinite_nonlinear_global() test_loa_infinite_aggregate_global() + test_loa_infinite_complement_indicator() + test_loa_infinite_multidim_parameter() else @info "Skipping InfiniteOpt LOA tests: " * "JuMP.copy_model(::InfiniteModel) unavailable " * From 23dede6630db3be20d44d3002a2f5717fe0bd408 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Fri, 29 May 2026 10:14:54 -0400 Subject: [PATCH 48/72] Intermediate --- Project.toml | 2 + ext/InfiniteDisjunctiveProgramming.jl | 351 +++++++++++- src/loa.jl | 521 +++++++++++++++--- src/utilities.jl | 90 --- test/constraints/loa.jl | 6 +- .../InfiniteDisjunctiveProgramming.jl | 36 +- 6 files changed, 798 insertions(+), 208 deletions(-) diff --git a/Project.toml b/Project.toml index dbff0ec1..db716b40 100644 --- a/Project.toml +++ b/Project.toml @@ -4,6 +4,7 @@ authors = ["hdavid16 "] version = "0.6.0" [deps] +Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" JuMP = "4076af6c-e467-56ae-b986-b466b2749572" Reexport = "189a3867-3050-52da-a836-e630ba90ab69" @@ -15,6 +16,7 @@ InfiniteDisjunctiveProgramming = "InfiniteOpt" [compat] Aqua = "0.8" +Distributions = "0.25.125" InfiniteOpt = "0.6.3" Ipopt = "1.9.0" JuMP = "1.18" diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 993fcddc..30022165 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -390,6 +390,190 @@ end DP.any_active(actives::AbstractVector{Bool}) = any(actives) +# Override `reformulate_model` for `InfiniteModel` so the LOA +# `supports_schedule` activates the multi-resolution loop. With +# `supports_schedule = nothing` this is identical to the base path. With +# a schedule + `coarse_builder = N -> InfiniteModel`, the wrapper: +# +# 1. For each N in the schedule, build a FRESH InfiniteModel via +# `coarse_builder(N)`. Apply warm-starts captured from the previous +# warmup level (indexed by variable name + parameter name, which +# survive across model rebuilds). Run LOA. Capture trajectory. +# Discard the warmup model. +# 2. Apply the final captured trajectory to the user's `model`. +# 3. Run a single-level LOA on the user's model. +# +# This sidesteps every accumulated-state failure mode we hit when +# mutating one InfiniteModel across resolutions (orphan point vars, +# stale parameter supports, transcription cache poisoning, etc.). +function DP.reformulate_model( + model::InfiniteOpt.InfiniteModel, method::DP.LOA + ) + schedule = method.supports_schedule + if schedule === nothing + DP._reformulate_loa_single_level(model, method) + DP._set_solution_method(model, method) + DP._set_ready_to_optimize(model, true) + return + end + + isempty(schedule) && error( + "LOA `supports_schedule` must be `nothing` or non-empty.") + builder = method.coarse_builder + builder === nothing && error( + "LOA `supports_schedule` requires `coarse_builder`.") + + trajectory = nothing + for (level, N) in enumerate(schedule) + method.verbose && println( + "LOA_MULTIRES warmup level=", level, " N=", N, + trajectory === nothing ? " (cold start)" : + " (warm-start from level $(level - 1))") + warmup_model = builder(N) + if trajectory !== nothing + _apply_trajectory_by_name!(warmup_model, trajectory) + end + warmup_method = _strip_schedule(method) + warmup_result = DP._reformulate_loa_single_level( + warmup_model, warmup_method) + if warmup_result === nothing + method.verbose && println("LOA_MULTIRES warmup level=", + level, " no commit; trajectory unchanged") + continue + end + trajectory = _capture_trajectory_by_name( + warmup_model, warmup_result) + method.verbose && println("LOA_MULTIRES warmup level=", + level, " obj=", warmup_result.objective) + end + + if trajectory !== nothing + method.verbose && println( + "LOA_MULTIRES applying warmup trajectory to user model") + _apply_trajectory_by_name!(model, trajectory) + end + DP._reformulate_loa_single_level(model, method) + DP._set_solution_method(model, method) + DP._set_ready_to_optimize(model, true) + return +end + +# Build a copy of `method` with `supports_schedule = nothing` so the +# warmup LOA runs as single-level (not recursively triggering its own +# multi-res). The `coarse_builder` is irrelevant once the schedule is +# stripped; nothing reads it. +function _strip_schedule(method::DP.LOA) + return DP.LOA(method.nlp_optimizer; + mip_optimizer = method.mip_optimizer, + inner_method = method.inner_method, + max_iter = method.max_iter, + atol = method.atol, rtol = method.rtol, + M_value = method.M_value, + max_slack = method.max_slack, + oa_penalty = method.oa_penalty, + verbose = method.verbose) +end + +# Capture the converged trajectory as a name-keyed dict so we can apply +# it to a freshly-built model whose `GeneralVariableRef`s are different +# objects. Also records each parameter's current supports (per name) so +# the apply step knows the source grid for interpolation. +function _capture_trajectory_by_name( + model::InfiniteOpt.InfiniteModel, result::NamedTuple + ) + name_to_values = Dict{String, Vector{Float64}}() + for (variable, values) in result.linearization_point + nm = JuMP.name(variable) + isempty(nm) && continue + name_to_values[nm] = collect(values) + end + name_to_supports = Dict{String, Vector{Float64}}() + for p in InfiniteOpt.all_parameters(model) + nm = JuMP.name(p) + isempty(nm) && continue + sup = InfiniteOpt.supports(p) + ndims(sup) == 1 || continue + name_to_supports[nm] = collect(sup) + end + return (values = name_to_values, supports = name_to_supports) +end + +# Apply a name-keyed trajectory to a fresh model: for each variable +# whose name appears in `trajectory.values`, interpolate its captured +# per-support values from the source parameter grid to this model's +# grid and write as `set_start_value` on the transcribed JuMP refs. +# Indicator binaries are skipped — they're not part of the primal +# warm-start. +function _apply_trajectory_by_name!( + target::InfiniteOpt.InfiniteModel, trajectory::NamedTuple + ) + indicator_binaries = Set{InfiniteOpt.GeneralVariableRef}() + for (_, bref) in DP._indicator_to_binary(target) + push!(indicator_binaries, bref isa JuMP.GenericAffExpr ? + only(keys(bref.terms)) : bref) + end + InfiniteOpt.build_transformation_backend!(target) + for v in JuMP.all_variables(target) + v in indicator_binaries && continue + nm = JuMP.name(v) + haskey(trajectory.values, nm) || continue + values = trajectory.values[nm] + _warm_start_by_name(target, v, values, trajectory.supports) + end + return +end + +# Variable-side warm-start: finite vars get the scalar value directly; +# infinite (single-parameter) vars get linearly interpolated from the +# captured source grid to this model's transcribed supports. +function _warm_start_by_name( + ::InfiniteOpt.InfiniteModel, + variable::InfiniteOpt.GeneralVariableRef, + values::AbstractVector, + captured_supports::AbstractDict + ) + prefs = InfiniteOpt.parameter_refs(variable) + if isempty(prefs) + length(values) == 1 || return + transcribed = InfiniteOpt.transformation_variable(variable) + transcribed isa JuMP.AbstractVariableRef || return + JuMP.set_start_value(transcribed, only(values)) + return + end + length(prefs) == 1 || return + pname = JuMP.name(first(prefs)) + haskey(captured_supports, pname) || return + old = captured_supports[pname] + length(values) == length(old) || return + new = InfiniteOpt.supports(first(prefs)) + ndims(new) == 1 || return + transcribed = InfiniteOpt.transformation_variable(variable) + transcribed isa AbstractArray || return + refs = vec(transcribed) + length(refs) == length(new) || return + for (k, s) in enumerate(new) + JuMP.set_start_value(refs[k], _linear_interp(old, values, s)) + end + return +end + +# Piecewise-linear interpolation of `ys` defined on the increasing +# nodes `xs`, evaluated at `q`. Clamps at the endpoints. Cheap and +# stable enough for warm-starts; only neighborhood-correctness matters. +function _linear_interp( + xs::AbstractVector{<:Real}, ys::AbstractVector{<:Real}, q::Real + ) + n = length(xs) + q <= xs[1] && return ys[1] + q >= xs[n] && return ys[n] + i = searchsortedlast(xs, q) + i == n && return ys[n] + x0, x1 = xs[i], xs[i + 1] + x1 == x0 && return ys[i] + t = (q - x0) / (x1 - x0) + return (1 - t) * ys[i] + t * ys[i + 1] +end + # `JuMP.value` returns a per-support `Array` for infinite vars and a # scalar for finite vars. The `> 0.5` cutoff handles solver-side # integer-feasibility slack (e.g. HiGHS can return 2.75e-40 for a @@ -402,6 +586,17 @@ function DP.combination_val(v::InfiniteOpt.GeneralVariableRef) return val isa AbstractArray ? vec(round.(Bool, val)) : round(Bool, val) end +# Complement-form binary (`1 - y_underlying`) stored in `binary_map` +# for indicators declared `logical_complement`. `JuMP.value` on the +# AffExpr returns a per-support `Vector{Float64}` when the underlying +# is infinite, or a scalar when it's finite. +function DP.combination_val( + v::JuMP.GenericAffExpr{C, <:InfiniteOpt.GeneralVariableRef} + ) where {C} + val = JuMP.value(v) + return val isa AbstractArray ? vec(round.(Bool, val)) : round(Bool, val) +end + # Supports of the infinite parameter group `v` depends on. For a 1-D # parameter, `supports(p)` is a `Vector{Float64}`; for a dependent # group of dimension k, it is a k × N_supports `Matrix`. Returns each @@ -484,34 +679,131 @@ function DP.add_no_good_terms( return end +# Complement-form AffExpr (`1 - y_underlying`) with per-support active +# descriptor — same fan-out as the variable-ref form, but the +# underlying var lives inside the AffExpr's terms. Use the underlying +# var's supports to drive the loop. +function DP.add_no_good_terms( + cut, binary_ref::JuMP.GenericAffExpr, + actives::AbstractVector + ) + underlying = only(keys(binary_ref.terms)) + underlying isa InfiniteOpt.GeneralVariableRef || return invoke( + DP.add_no_good_terms, + Tuple{Any, Any, AbstractVector}, cut, binary_ref, actives) + supports = _supports_of(underlying) + for (k, support) in enumerate(supports) + DP.add_no_good_terms( + cut, _at_support(binary_ref, support), actives[k]) + end + return +end + function DP.cut_info( - binary_ref::InfiniteOpt.GeneralVariableRef, active::Bool, + binary_ref::InfiniteOpt.GeneralVariableRef, + active::Bool, + constraint::JuMP.AbstractConstraint, linearization_point::AbstractDict, variable_map::AbstractDict, dual ) - # Finite binary (plain `Logical` indicator on an `InfiniteModel`) - # has no infinite parameters — no per-support breakdown to do, so - # emit a single site like the base scalar method. - isempty(InfiniteOpt.parameter_refs(binary_ref)) && - return ((binary_ref, linearization_point, variable_map, dual),) - supports = _supports_of(binary_ref) - return _cut_sites(binary_ref, supports, - fill(true, length(supports)), + return _infinite_cut_info(binary_ref, active, constraint.func, linearization_point, variable_map, dual) end function DP.cut_info( binary_ref::InfiniteOpt.GeneralVariableRef, actives::AbstractVector, + constraint::JuMP.AbstractConstraint, + linearization_point::AbstractDict, + variable_map::AbstractDict, dual + ) + return _infinite_cut_info(binary_ref, actives, constraint.func, + linearization_point, variable_map, dual) +end + +# Complement-form binary (`1 - y_underlying`). Same fan-out logic as +# the variable-ref form; the per-support `_at_support` rebuilds the +# AffExpr with its variable point-evaluated. +function DP.cut_info( + binary_ref::JuMP.GenericAffExpr, + active::Bool, + constraint::JuMP.AbstractConstraint, linearization_point::AbstractDict, variable_map::AbstractDict, dual ) - return _cut_sites(binary_ref, _supports_of(binary_ref), actives, + return _infinite_cut_info(binary_ref, active, constraint.func, linearization_point, variable_map, dual) end +# Complement-form binary with per-support active descriptor (e.g., +# `BitVector` from `_extract_combination` on an InfiniteOpt master +# where the indicator was declared `logical_complement`). +function DP.cut_info( + binary_ref::JuMP.GenericAffExpr, + actives::AbstractVector, + constraint::JuMP.AbstractConstraint, + linearization_point::AbstractDict, + variable_map::AbstractDict, dual + ) + return _infinite_cut_info(binary_ref, actives, constraint.func, + linearization_point, variable_map, dual) +end + +# Fan out across supports if either the binary or the constraint +# expression involves an infinite variable; otherwise emit one +# un-sliced site. The chosen supports come from the first infinite +# variable found in either expression. +function _infinite_cut_info( + binary_ref, active, constraint_func, + linearization_point, variable_map, dual + ) + supports = _relevant_supports(binary_ref, constraint_func) + supports === nothing && + return ((binary_ref, linearization_point, variable_map, dual),) + actives = active isa AbstractVector ? active : + fill(active, length(supports)) + return _cut_sites(binary_ref, supports, actives, + linearization_point, variable_map, dual) +end + +# Supports governing per-support fan-out — pick the first infinite +# variable found in the binary expression, falling back to the +# constraint expression. `nothing` means everything is finite, so the +# caller emits a single un-sliced site. +function _relevant_supports(binary_ref, constraint_func) + var = _find_infinite_var(binary_ref) + var === nothing && (var = _find_infinite_var(constraint_func)) + var === nothing && return nothing + return _supports_of(var) +end + +_find_infinite_var(v::InfiniteOpt.GeneralVariableRef) = + isempty(InfiniteOpt.parameter_refs(v)) ? nothing : v + +function _find_infinite_var(expr::JuMP.GenericAffExpr) + for v in keys(expr.terms) + v isa InfiniteOpt.GeneralVariableRef || continue + result = _find_infinite_var(v) + result === nothing || return result + end + return nothing +end + +function _find_infinite_var(expr) + found = Ref{Any}(nothing) + DP._interrogate_variables(expr) do v + found[] === nothing || return + v isa InfiniteOpt.GeneralVariableRef || return + isempty(InfiniteOpt.parameter_refs(v)) && return + found[] = v + end + return found[] +end + +_find_infinite_var(::Any) = nothing + function _cut_sites( - binary_ref::InfiniteOpt.GeneralVariableRef, + binary_ref, supports::AbstractVector, actives::AbstractVector, linearization_point::AbstractDict, @@ -558,6 +850,19 @@ _at(scalar, ::Integer) = scalar _at_support(v::InfiniteOpt.GeneralVariableRef, support) = isempty(InfiniteOpt.parameter_refs(v)) ? v : v(support) +# Rebuild a complement-form AffExpr (`1 - y_underlying`) with its +# variables point-evaluated, so per-support fan-out can use the +# AffExpr directly in the gating term `M(1 - binary_at_support)`. +function _at_support( + expr::JuMP.GenericAffExpr{C, V}, support + ) where {C, V <: InfiniteOpt.GeneralVariableRef} + result = JuMP.GenericAffExpr{C, V}(expr.constant) + for (var, coef) in expr.terms + JuMP.add_to_expression!(result, coef, _at_support(var, support)) + end + return result +end + # Build map: transcribed input JuMP var → master point variable. # For an infinite input var v, every transcribed support `v_k` # maps to `ref_map[v](d_k)` (master point variable). Used as @@ -619,7 +924,7 @@ end # the flat scalar objective with a transcribed-to-master point map. function DP.build_loa_master( model::InfiniteOpt.InfiniteModel, method::DP.LOA - ) + )::DP._LOAMaster # Linear, non-aggregate constraints stay on the master. Nonlinear # constraints — and aggregate-wrapped affine ones like # `∫(x^2,t) ≤ c`, which read linear via `_is_linear_F` over a @@ -691,12 +996,9 @@ function DP.build_loa_master( variable_map[v] = ref_map[v] end - return (model = master, binary_map = binary_map, - variable_map = variable_map, - objective_sense = objective_sense, - original_objective = original_objective, - alpha_oa = alpha_oa, - objective_ref_map = objective_ref_map) + return DP._LOAMaster(master, binary_map, variable_map, + objective_sense, original_objective, alpha_oa, + objective_ref_map) end # Override the disjunct-cut loop for `InfiniteModel`. Same shape as @@ -712,7 +1014,7 @@ end # produces whichever shape `_linearize_at` needs. function DP.add_oa_cuts( model::InfiniteOpt.InfiniteModel, - master::NamedTuple, + master::DP._LOAMaster, result::NamedTuple, method::DP.LOA ) @@ -745,7 +1047,7 @@ end # of scalar expressions — one OA cut is emitted per support. function _add_global_oa_cuts_infinite( model::InfiniteOpt.InfiniteModel, - master::NamedTuple, + master::DP._LOAMaster, result::NamedTuple, method::DP.LOA ) @@ -806,7 +1108,7 @@ end # constraints in the iteration. function DP.add_disjunct_oa_cuts( model::InfiniteOpt.InfiniteModel, - master::NamedTuple, + master::DP._LOAMaster, result::NamedTuple, method::DP.LOA ) @@ -845,6 +1147,7 @@ function DP.add_disjunct_oa_cuts( transcribed_func, constraint.set) for (binary_ref, _, _, dual_value) in DP.cut_info( master.binary_map[indicator], active, + transcribed_constraint, result.linearization_point, master.variable_map, dual) DP._add_oa_cut_for_constraint( @@ -857,7 +1160,7 @@ function DP.add_disjunct_oa_cuts( end for (binary_ref, linearization_point, var_map, dual_value) in DP.cut_info( - master.binary_map[indicator], active, + master.binary_map[indicator], active, constraint, result.linearization_point, master.variable_map, dual) DP._add_oa_cut_for_constraint( @@ -877,7 +1180,7 @@ end # constraint on the underlying infinite var. State lives in the # closure — no `model.ext` stash. Used by base `with_fixed_combination` # and `commit_combination`. -function DP._fix_combination( +function DP.fix_combination( model::InfiniteOpt.InfiniteModel, combination::AbstractDict ) constraint_refs = InfiniteOpt.InfOptConstraintRef[] @@ -914,7 +1217,7 @@ end # transcribed instances; for a finite var on an InfiniteModel, # `transcription_variable` returns a single ref and `values` is # length-1. -function DP._set_warm_start!( +function DP.set_warm_start!( variable::InfiniteOpt.GeneralVariableRef, values::AbstractVector ) diff --git a/src/loa.jl b/src/loa.jl index 8afcd5f7..aa429ca7 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -15,43 +15,93 @@ # METHOD TYPE ################################################################################ """ - LOA{O, P, R} <: AbstractReformulationMethod - -Logic-based Outer Approximation solver for GDP models. Uses two models: the -original (reformulated by `inner_method`, binaries fixed per iteration as an -NLP) and a master MILP copy that accumulates OA and no-good cuts. - -`inner_method` defaults to `BigM(M_value)`; pass `MBM(optimizer)` to use -tighter per-constraint Ms. Other reformulations (`Hull`, `PSplit`) are not -yet supported — they emit constraints whose shapes the LOA dual-collection -logic can't slice. + LOA{O, P, R, T} <: AbstractReformulationMethod + +Logic-based Outer Approximation solver for GDP models. Iterates between a +primary NLP (the original model reformulated by `inner_method` with +indicator binaries fixed per iteration) and a master MILP that +accumulates outer-approximation and no-good cuts. + +`inner_method` defaults to `BigM(M_value)`. `MBM(optimizer)` is also +supported. Other reformulations (`Hull`, `PSplit`) are not yet supported. + +## Fields +- `nlp_optimizer::O`: Solver used for the primary NLP. +- `mip_optimizer::P`: Solver used for the master MILP (defaults to + `nlp_optimizer`). +- `inner_method::R`: Reformulation applied to the primary NLP — `BigM` + or `MBM`. +- `max_iter::Int`: Maximum LOA iterations after set-covering seeding. +- `atol::T`, `rtol::T`: Absolute / relative gap tolerances for + convergence. +- `M_value::T`: Big-M used in the disjunct OA cut gating term. +- `max_slack::T`: Upper bound for each per-cut slack variable. +- `oa_penalty::T`: V&G 1990 penalty coefficient applied to slacks in + the master objective. """ -struct LOA{O, P, R} <: AbstractReformulationMethod +struct LOA{O, P, R, T} <: AbstractReformulationMethod nlp_optimizer::O mip_optimizer::P inner_method::R max_iter::Int - atol::Float64 - rtol::Float64 - M_value::Float64 - max_slack::Float64 - OA_penalty_factor::Float64 + atol::T + rtol::T + M_value::T + max_slack::T + oa_penalty::T + verbose::Bool + supports_schedule::Union{Nothing, Vector{Int}} + coarse_builder::Union{Nothing, Function} function LOA( nlp_optimizer::O; mip_optimizer::P = nlp_optimizer, max_iter::Int = 10, - atol::Float64 = 1e-6, - rtol::Float64 = 1e-4, - M_value::Float64 = 1e9, - max_slack::Float64 = 1000.0, - OA_penalty_factor::Float64 = 1000.0, - inner_method::R = BigM(M_value) - ) where {O, P, R <: AbstractReformulationMethod} - new{O, P, R}(nlp_optimizer, mip_optimizer, inner_method, max_iter, - atol, rtol, M_value, max_slack, OA_penalty_factor) + atol::T = 1e-6, + rtol::T = 1e-4, + M_value::T = 1e9, + max_slack::T = 1e3, + oa_penalty::T = 1e3, + inner_method::R = BigM(M_value), + verbose::Bool = false, + supports_schedule::Union{Nothing, Vector{Int}} = nothing, + coarse_builder::Union{Nothing, Function} = nothing + ) where {O, P, R <: AbstractReformulationMethod, T} + R <: Union{BigM, MBM} || error( + "LOA inner_method must be BigM or MBM (got $R). " * + "Hull and PSplit are not yet supported.") + supports_schedule === nothing || coarse_builder !== nothing || error( + "LOA `supports_schedule` requires a `coarse_builder = N -> " * + "InfiniteModel` that constructs a fresh model at each " * + "warmup resolution.") + new{O, P, R, T}(nlp_optimizer, mip_optimizer, inner_method, + max_iter, atol, rtol, M_value, max_slack, oa_penalty, verbose, + supports_schedule, coarse_builder) end end +################################################################################ +# LOA MASTER +################################################################################ +# TODO: Move `_LOAMaster` to `src/datatypes.jl` alongside `GDPSubmodel` +# once the LOA API stabilizes. Keeping it co-located with the algorithm +# while the field set is still in flux. +# +# Bundles the LOA master MILP and the maps the algorithm needs to +# translate original-model refs into master-model refs. +# `objective_ref_map` is split from `variable_map` because the +# InfiniteOpt aggregate-objective path uses a transcribed- +# `JuMP.VariableRef`-keyed map for the objective only, while constraint +# linearization uses the InfiniteOpt-keyed `variable_map`. +mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM} + model::M + binary_map::BM + variable_map::VM + objective_sense::_MOI.OptimizationSense + original_objective::OF + alpha_oa::AO + objective_ref_map::RM +end + ################################################################################ # SENSE PRIMITIVES ################################################################################ @@ -71,6 +121,25 @@ _flip_sense(::Val{_MOI.MAX_SENSE}) = Val(_MOI.MIN_SENSE) # MAIN ALGORITHM ################################################################################ function reformulate_model(model::JuMP.AbstractModel, method::LOA) + method.supports_schedule === nothing || + error("`supports_schedule` is only meaningful for InfiniteOpt " * + "models; load InfiniteOpt to enable the multi-resolution path.") + _reformulate_loa_single_level(model, method) + _set_solution_method(model, method) + _set_ready_to_optimize(model, true) + return +end + +# Single-resolution LOA. Always uses set-covering for the +# initialization seeds; the InfiniteOpt multi-resolution wrapper +# carries trajectory information across levels via primal warm starts +# on transcribed JuMP variables (set before this is called), not by +# overriding the seed-combination loop. Returns `best_result` +# (NamedTuple with `combination` / `linearization_point` / etc.) or +# `nothing` if every NLP was infeasible. +function _reformulate_loa_single_level( + model::JuMP.AbstractModel, method::LOA + ) _clear_reformulations(model) combinations = _set_covering_combinations(model) reformulate_model(model, method.inner_method) @@ -79,51 +148,81 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) reformulation_map = _build_reformulation_map(model) JuMP.set_optimizer(model, method.nlp_optimizer) JuMP.set_silent(model) - # Cached for repeated use in `_worst_objective`, `_is_better`, - # `_flip_sense`, and `_loa_converged`. sense_token = Val(JuMP.objective_sense(model)) best_objective = _worst_objective(sense_token) - is_better(candidate) = _is_better(sense_token, candidate, best_objective) best_result = nothing - # Initialization Procedure (Türkay & Grossmann 1996, sec. 2.2): solve - # the set-covering NLPs to seed the master with at least one OA cut - # per disjunct before the main iteration. - for combination in combinations - result = _solve_nlp(model, combination, method, reformulation_map) + # Initialization Procedure (Türkay & Grossmann 1996, sec. 2.2): + # solve K set-covering NLPs with cycling indicator combinations to + # seed the master with at least one OA cut per disjunct. + for (i, combination) in enumerate(combinations) + t_seed = @elapsed result = _solve_nlp( + model, combination, method, reformulation_map) avoid_combination(master.model, combination, master.binary_map) add_oa_cuts(model, master, result, method) - if result.feasible && is_better(result.objective) + if result.feasible && _is_better(sense_token, result.objective, + best_objective) best_objective = result.objective best_result = result end + method.verbose && _log_seed(i, t_seed, result, best_objective) end - master_bound = _worst_objective(_flip_sense(sense_token)) for iter in 1:method.max_iter - JuMP.optimize!(master.model) - JuMP.is_solved_and_feasible(master.model) || break + t_master = @elapsed JuMP.optimize!(master.model) + feasible = JuMP.is_solved_and_feasible(master.model) + method.verbose && _log_master(iter, master.model, t_master) + feasible || break master_bound = JuMP.objective_value(master.model) - @info "LOA iter $iter: LB=$master_bound UB=$best_objective " * - "gap=$(_gap(sense_token, best_objective, master_bound))" - _loa_converged(best_objective, master_bound, sense_token, method) && break + method.verbose && + _log_iter(iter, master_bound, best_objective, sense_token) + _loa_converged(best_objective, master_bound, sense_token, method) && + break combination = _extract_combination(model, master) - result = _solve_nlp(model, combination, method, reformulation_map) + t_nlp = @elapsed result = _solve_nlp( + model, combination, method, reformulation_map) avoid_combination(master.model, combination, master.binary_map) add_oa_cuts(model, master, result, method) - if result.feasible && is_better(result.objective) + if result.feasible && _is_better(sense_token, result.objective, + best_objective) best_objective = result.objective best_result = result end + method.verbose && _log_nlp(iter, t_nlp, result, best_objective) end if best_result !== nothing commit_combination(model, best_result.combination, best_result.linearization_point) end - _set_solution_method(model, method) - _set_ready_to_optimize(model, true) - return + return best_result +end + +# `verbose = true` trace helpers. Print to stdout so a `tee`-d log +# captures them; structured key=value so they're easy to grep. +function _log_seed(i::Int, t::Real, result::NamedTuple, best::Real) + println("LOA_SEED ", i, ": time=", round(t, digits = 3), + "s feasible=", result.feasible, " obj=", result.objective, + " best=", best) + flush(stdout) +end +function _log_master(iter::Int, master::JuMP.AbstractModel, t::Real) + println("LOA_MASTER iter=", iter, + " status=", JuMP.termination_status(master), + " feasible=", JuMP.is_solved_and_feasible(master), + " time=", round(t, digits = 3), "s") + flush(stdout) +end +function _log_iter(iter::Int, lb::Real, ub::Real, sense_token::Val) + println("LOA_ITER ", iter, ": LB=", lb, " UB=", ub, + " gap=", _gap(sense_token, ub, lb)) + flush(stdout) +end +function _log_nlp(iter::Int, t::Real, result::NamedTuple, best::Real) + println("LOA_NLP iter=", iter, + " time=", round(t, digits = 3), "s feasible=", result.feasible, + " obj=", result.objective, " best=", best) + flush(stdout) end # Convergence: absolute gap ≤ atol or relative gap ≤ rtol. Distinct @@ -155,18 +254,13 @@ end # combinations that activates every indicator at least once, so the # master starts with an OA cut per disjunct. Nested disjunctions are # enumerated alongside top-level ones — inconsistent combinations -# (nested active under inactive parent) are handled by feasibility -# restoration at NLP-solve time. +# (nested active under inactive parent) are handled by the no-good +# cut emitted from the infeasible NLP solve. # # `K = max(disjunction sizes)` combinations suffice: combination `k` # activates the `k`-th indicator of each disjunction, cycling via # `mod1` for disjunctions shorter than `K`. Every indicator gets -# activated at least once over k=1..K. -# -# Note: keeps the less-specific `model::JuMP.AbstractModel` -# signature instead of `model::M where {M <: JuMP.AbstractModel}` to -# avoid ambiguity with InfiniteOpt overrides; this function is -# internal so no public-API impact. +# activated at least once over k = 1..K. function _set_covering_combinations(model::JuMP.AbstractModel) LogicalRef = LogicalVariableRef{typeof(model)} indicator_lists = [collect(d.constraint.indicators) @@ -199,8 +293,7 @@ _is_linear_F(::Type) = false # 1996, eq. 12): copy decision variables and only the linear # constraints, install `alpha_oa` as the objective auxiliary. Nonlinear # objective and disjunct constraints enter as OA cuts after each NLP -# solve. Returns a NamedTuple of (model, binary_map, variable_map, -# objective_sense, original_objective, alpha_oa, objective_ref_map). +# solve. Returns an `_LOAMaster`. function build_loa_master(model::JuMP.AbstractModel, method::LOA) original_objective = JuMP.objective_function(model) objective_sense = JuMP.objective_sense(model) @@ -232,23 +325,24 @@ function build_loa_master(model::JuMP.AbstractModel, method::LOA) alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") JuMP.@objective(master, objective_sense, alpha_oa) - return (model = master, binary_map = binary_map, - variable_map = variable_map, objective_sense = objective_sense, - original_objective = original_objective, alpha_oa = alpha_oa, - objective_ref_map = variable_map) + return _LOAMaster(master, binary_map, variable_map, objective_sense, + original_objective, alpha_oa, variable_map) end ################################################################################ # NLP SUBPROBLEM ################################################################################ # Solve the primary NLP for a fixed combination. If feasible, read the -# primal point, duals, and objective. If infeasible, return an empty -# result — the master's augmented penalty (slacks `σ_ik` on disjunct OA -# cuts) absorbs infeasibility and a no-good cut forbids the combination. +# primal point, duals, and objective. If infeasible, fall through to +# the NLPF (V&G 1990 eq. 8) approximation: a slacked version of the +# same problem that always solves, whose primal becomes the +# linearization site for OA cuts. The master still learns shape +# information from the failed combination instead of only adding a +# no-good cut. function _solve_nlp( model::M, combination, method::LOA, reformulation_map ) where {M <: JuMP.AbstractModel} - return with_fixed_combination(model, combination) do + primary = with_fixed_combination(model, combination) do JuMP.optimize!(model, ignore_optimize_hook = true) if JuMP.is_solved_and_feasible(model) lin_point = extract_solution(model) @@ -259,17 +353,169 @@ function _solve_nlp( linearization_point = lin_point, duals = duals, objective = objective_val, feasible = true) end - return (combination = combination, - linearization_point = Dict{JuMP.AbstractVariableRef, Any}(), - duals = Dict{DisjunctConstraintRef{M}, Any}(), - objective = Inf, feasible = false) + return nothing + end + primary === nothing || return primary + + # Primary NLP infeasible — try NLPF. + nlpf = _solve_nlpf(model, combination, method) + nlpf === nothing || return nlpf + + return (combination = combination, + linearization_point = Dict{JuMP.AbstractVariableRef, Any}(), + duals = Dict{DisjunctConstraintRef{M}, Any}(), + objective = Inf, feasible = false) +end + +################################################################################ +# NLPF (FEASIBILITY SUBPROBLEM) +################################################################################ +# V&G 1990 eq. 8: when the primary NLP is infeasible at a fixed +# combination, copy the model, slack every scalar inequality with a +# single nonnegative slack `u`, minimize `u`, and return the resulting +# primal point as an approximate linearization site. The point +# satisfies equalities and variable bounds exactly; inequalities can +# be violated by at most `u`. The "duals" returned are sign-only — the +# OA cut emitter uses `sign(dual)` to pick a direction, and for a +# slacked feasibility solve we know each active constraint's +# linearization is informative in the standard direction. +function _solve_nlpf( + model::M, combination, method::LOA + ) where {M <: JuMP.AbstractModel} + # NLPF needs scalar Bool combination values to fix binaries + # directly. Per-support `Vector{Bool}` from the InfiniteOpt master + # is out of scope for v1. + all(v -> v isa Bool, values(combination)) || return nothing + + copy, ref_map = JuMP.copy_model(model) + JuMP.set_optimizer(copy, method.nlp_optimizer) + JuMP.set_silent(copy) + + var_type = JuMP.variable_ref_type(typeof(copy)) + u = JuMP.@variable(copy, lower_bound = 0.0, base_name = "_nlpf_u") + + for (F, S) in JuMP.list_of_constraint_types(copy) + F === var_type && continue + _nlpf_should_slack(S) || continue + for cref in JuMP.all_constraints(copy, F, S) + con = JuMP.constraint_object(cref) + JuMP.delete(copy, cref) + new_func = _nlpf_slacked_func(con.func, u, con.set) + JuMP.@constraint(copy, new_func in con.set) + end + end + + JuMP.@objective(copy, Min, u) + + # Plain `JuMP.fix` (no `force = true`) — `force` triggers + # `delete_upper_bound` which fails on InfiniteOpt copies whose + # ZeroOne constraint refs didn't survive `JuMP.copy_model`. We + # leave the binary attribute intact on the copy; the fix pins + # the value to 0/1 which satisfies the ZeroOne anyway. Solvers + # that can't handle binaries (Ipopt) will error out — we catch + # and return `nothing` so the caller falls back to the existing + # no-good-only behavior. + for (indicator, value) in combination + original_binary = _indicator_to_binary(model)[indicator] + _nlpf_fix_on_copy(original_binary, value, ref_map) + end + + try + JuMP.optimize!(copy, ignore_optimize_hook = true) + catch + return nothing + end + JuMP.has_values(copy) || return nothing + + linearization_point = _nlpf_extract_primal(model, ref_map) + duals = _nlpf_sign_duals(model, combination) + return (combination = combination, + linearization_point = linearization_point, + duals = duals, + objective = Inf, feasible = false) +end + +function _nlpf_fix_on_copy( + binary::JuMP.AbstractVariableRef, value::Bool, ref_map + ) + target = ref_map[binary] + JuMP.is_fixed(target) && JuMP.unfix(target) + JuMP.fix(target, value ? 1.0 : 0.0) + return +end +function _nlpf_fix_on_copy( + binary::JuMP.GenericAffExpr, value::Bool, ref_map + ) + # complement form `1 - y_underlying`: indicator=true means + # underlying=0; indicator=false means underlying=1. + underlying = only(keys(binary.terms)) + _nlpf_fix_on_copy(underlying, !value, ref_map) + return +end + +_nlpf_should_slack(::Type{<:_MOI.LessThan}) = true +_nlpf_should_slack(::Type{<:_MOI.GreaterThan}) = true +_nlpf_should_slack(::Type) = false + +_nlpf_slacked_func(func, u, ::_MOI.LessThan) = func - u +_nlpf_slacked_func(func, u, ::_MOI.GreaterThan) = func + u + +# Read primal values from `copy` keyed by the original model's +# variables, in the same per-support shape `extract_solution` would +# have produced on the original. +function _nlpf_extract_primal( + model::JuMP.AbstractModel, ref_map + ) + V = JuMP.variable_ref_type(typeof(model)) + T = JuMP.value_type(typeof(model)) + result = Dict{V, Vector{T}}() + for v in collect_all_vars(model) + JuMP.is_fixed(v) && continue + target = try + ref_map[v] + catch + continue # var not in the copy's ref_map (e.g., parameter only) + end + val = JuMP.value(target) + result[v] = val isa AbstractArray ? vec(val) : [val] + end + return result +end + +# Sign-only "duals" on active disjunct constraints — the OA cut +# emitter only needs `sign(dual)` to pick the linearization direction, +# and for a slacked-feasibility solve each active constraint's +# linearization is meaningful in its standard direction. +function _nlpf_sign_duals( + model::M, combination::AbstractDict + ) where {M <: JuMP.AbstractModel} + duals = Dict{DisjunctConstraintRef{M}, Any}() + for (indicator, active) in combination + any_active(active) || continue + haskey(_indicator_to_constraints(model), indicator) || continue + for cref in _indicator_to_constraints(model)[indicator] + cref isa DisjunctConstraintRef || continue + con = _disjunct_constraints(model)[ + JuMP.index(cref)].constraint + duals[cref] = _nlpf_dual_sign(con.set) + end end + return duals end +_nlpf_dual_sign(::_MOI.LessThan) = 1.0 +_nlpf_dual_sign(::_MOI.GreaterThan) = -1.0 +_nlpf_dual_sign(::_MOI.EqualTo) = [1.0, 1.0] +_nlpf_dual_sign(::_MOI.Interval) = [1.0, 1.0] +_nlpf_dual_sign(s::_MOI.Nonpositives) = ones(_MOI.dimension(s)) +_nlpf_dual_sign(s::_MOI.Nonnegatives) = -ones(_MOI.dimension(s)) +_nlpf_dual_sign(s::_MOI.Zeros) = ones(_MOI.dimension(s)) +_nlpf_dual_sign(::Any) = 0.0 + # Fix `combination`, run `f()`, unfix. Logical binaries are relaxed # from ZeroOne for the duration so a pure NLP solver (Ipopt) can # handle the inner solve; restored on exit. Extensions override -# `_fix_combination` to handle per-support fixing without needing +# `fix_combination` to handle per-support fixing without needing # their own try/finally lifecycle. function with_fixed_combination( f, @@ -277,7 +523,7 @@ function with_fixed_combination( combination::AbstractDict ) relaxed = relax_logical_vars(model) - undo = _fix_combination(model, combination) + undo = fix_combination(model, combination) try return f() finally @@ -298,9 +544,9 @@ function commit_combination( linearization_point::AbstractDict ) relax_logical_vars(model) - _fix_combination(model, combination) + fix_combination(model, combination) for (variable, values) in linearization_point - _set_warm_start!(variable, values) + set_warm_start!(variable, values) end return end @@ -310,7 +556,7 @@ end # InfiniteOpt extension overrides this to handle per-support # `Vector{Bool}` values via point-equality constraints, keeping all # cleanup state captured in the returned closure. -function _fix_combination( +function fix_combination( model::JuMP.AbstractModel, combination::AbstractDict ) for (indicator, value) in combination @@ -327,7 +573,7 @@ end # warm start. `values` is always per-support shape (length-1 vector # for finite vars); base unwraps. The InfiniteOpt extension overrides # for `GeneralVariableRef` to broadcast across transcribed supports. -_set_warm_start!(variable, values::AbstractVector) = +set_warm_start!(variable, values::AbstractVector) = JuMP.set_start_value(variable, only(values)) ################################################################################ @@ -416,7 +662,7 @@ end # extensions add per-support). function _extract_combination( model::M, - master::NamedTuple + master::_LOAMaster ) where {M <: JuMP.AbstractModel} combination = Dict{LogicalVariableRef{M}, Any}() for (_, disjunction_data) in _disjunctions(model) @@ -436,7 +682,7 @@ combination_val(binary_ref) = round(Bool, JuMP.value(binary_ref)) ################################################################################ function add_oa_cuts( model::JuMP.AbstractModel, - master::NamedTuple, + master::_LOAMaster, result::NamedTuple, method::LOA ) @@ -470,7 +716,7 @@ _add_objective_cut(::Val{_MOI.MAX_SENSE}, master, lin) = # transcription to handle per-support / aggregate-ref globals. function _add_global_oa_cuts( model::JuMP.AbstractModel, - master::NamedTuple, + master::_LOAMaster, result::NamedTuple, method::LOA ) @@ -501,7 +747,7 @@ end # `s (lin − rhs) − σ ≤ M(1 − y)`. function add_disjunct_oa_cuts( model::JuMP.AbstractModel, - master::NamedTuple, + master::_LOAMaster, result::NamedTuple, method::LOA ) @@ -518,7 +764,7 @@ function add_disjunct_oa_cuts( dual = get(result.duals, cref, nothing) dual === nothing && continue for (binary_ref, lin_point, var_map, dual_value) in cut_info( - master.binary_map[indicator], active, + master.binary_map[indicator], active, constraint, result.linearization_point, master.variable_map, dual) _add_oa_cut_for_constraint( constraint, master, binary_ref, lin_point, @@ -534,13 +780,13 @@ end # be an invalid relaxation, and the penalized slack keeps the master # feasible instead of letting accumulated cuts make it infeasible. function _penalized_slack( - master::NamedTuple, method::LOA, penalty_sign::Int + master::_LOAMaster, method::LOA, penalty_sign::Int ) slack = JuMP.@variable(master.model, lower_bound = 0.0, upper_bound = method.max_slack) JuMP.set_objective_function(master.model, JuMP.objective_function(master.model) + - penalty_sign * method.OA_penalty_factor * slack) + penalty_sign * method.oa_penalty * slack) return slack end @@ -549,7 +795,7 @@ end # are exact via BigM and skipped. function _add_oa_cut_for_constraint( constraint::JuMP.AbstractConstraint, - master::NamedTuple, + master::_LOAMaster, binary_ref, linearization_point::AbstractDict, var_map::AbstractDict, @@ -577,7 +823,7 @@ end # `EqualTo` / `Interval` get a two-sided pair sharing one slack. # Unknown set types fall back to the prior hard cut. function _add_global_oa_row!( - master::NamedTuple, lin, set::_MOI.LessThan, + master::_LOAMaster, lin, set::_MOI.LessThan, method::LOA, penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) @@ -585,7 +831,7 @@ function _add_global_oa_row!( return end function _add_global_oa_row!( - master::NamedTuple, lin, set::_MOI.GreaterThan, + master::_LOAMaster, lin, set::_MOI.GreaterThan, method::LOA, penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) @@ -593,7 +839,7 @@ function _add_global_oa_row!( return end function _add_global_oa_row!( - master::NamedTuple, lin, set::_MOI.EqualTo, + master::_LOAMaster, lin, set::_MOI.EqualTo, method::LOA, penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) @@ -603,7 +849,7 @@ function _add_global_oa_row!( return end function _add_global_oa_row!( - master::NamedTuple, lin, set::_MOI.Interval, + master::_LOAMaster, lin, set::_MOI.Interval, method::LOA, penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) @@ -612,20 +858,27 @@ function _add_global_oa_row!( return end function _add_global_oa_row!( - master::NamedTuple, lin, set, ::LOA, ::Int + master::_LOAMaster, lin, set, ::LOA, ::Int ) JuMP.@constraint(master.model, lin in set) return end # OVERRIDABLE. Yield `(binary_ref, lin_point, var_map, dual)` per OA -# cut to emit. Scalar binary returns one tuple; InfiniteOpt overrides -# for per-support `Vector` binaries to yield multiple sliced tuples. +# cut to emit for `constraint`. Scalar binary returns one tuple; the +# InfiniteOpt extension overrides to fan out across supports of any +# infinite variable found in `binary_ref` OR `constraint.func`. The +# constraint must factor in because a finite indicator on an +# InfiniteModel can still gate a constraint that depends on an +# infinite var — one cut per support is needed, even though the +# indicator is scalar. +# # `GenericAffExpr` covers complement-form indicators (`1 - y`) stored # in `binary_map` when a `Logical` is declared with `logical_complement`. cut_info( binary_ref::JuMP.AbstractVariableRef, active::Bool, + constraint::JuMP.AbstractConstraint, linearization_point::AbstractDict, variable_map::AbstractDict, dual @@ -633,6 +886,7 @@ cut_info( cut_info( binary_ref::JuMP.GenericAffExpr, active::Bool, + constraint::JuMP.AbstractConstraint, linearization_point::AbstractDict, variable_map::AbstractDict, dual @@ -647,9 +901,16 @@ any_active(active::Bool) = active _collapse_dual(dual::Number) = dual _collapse_dual(dual) = sum(dual) +################################################################################ +# LINEARIZATION & EXPRESSION CONVERSION +################################################################################ +# TODO: Move this section out of `loa.jl` (likely back to +# `src/utilities.jl`) when a second OA-style method lands and these +# helpers earn their generality. Currently only LOA uses them, so +# they live next to the algorithm that consumes them. + # First-order Taylor for a single var or affine expression mapped into -# master space. (Quad/nonlinear `_linearize_at` lives in `utilities.jl` -# and uses MOI Nonlinear AD.) +# master space. function _linearize_at( variable::JuMP.AbstractVariableRef, ::AbstractDict, @@ -672,3 +933,83 @@ function _linearize_at( return result end +# Convert JuMP expression trees to Julia Expr with +# MOI.VariableIndex leaves for MOI.Nonlinear evaluation. +function _to_nlp_expr(expr::JuMP.GenericNonlinearExpr, idx::Dict) + args = Any[_to_nlp_expr(a, idx) for a in expr.args] + return Expr(:call, expr.head, args...) +end +function _to_nlp_expr(expr::JuMP.GenericAffExpr, idx::Dict) + parts = Any[expr.constant] + for (var, coef) in expr.terms + push!(parts, Expr(:call, :*, coef, _MOI.VariableIndex(idx[var]))) + end + length(parts) == 1 && return parts[1] + return Expr(:call, :+, parts...) +end +function _to_nlp_expr(expr::JuMP.GenericQuadExpr, idx::Dict) + parts = Any[_to_nlp_expr(expr.aff, idx)] + for (pair, coef) in expr.terms + push!(parts, Expr(:call, :*, coef, + _MOI.VariableIndex(idx[pair.a]), + _MOI.VariableIndex(idx[pair.b]))) + end + length(parts) == 1 && return parts[1] + return Expr(:call, :+, parts...) +end +function _to_nlp_expr(var::JuMP.AbstractVariableRef, idx::Dict) + return _MOI.VariableIndex(idx[var]) +end +_to_nlp_expr(x::Number, ::Dict) = x + +# First-order Taylor linearization of a quadratic or nonlinear +# expression at point xk via MOI.Nonlinear reverse-mode AD. +function _linearize_at( + func::Union{JuMP.GenericQuadExpr, JuMP.GenericNonlinearExpr}, + xk::Dict, ref_map + ) + vars = JuMP.AbstractVariableRef[] + _interrogate_variables(v -> push!(vars, v), func) + unique!(vars) + isempty(vars) && return JuMP.AffExpr(JuMP.value(v -> 0.0, func)) + + n = length(vars) + T = JuMP.value_type(typeof(JuMP.owner_model(vars[1]))) + idx = Dict(vars[i] => i for i in 1:n) + nlp = _MOI.Nonlinear.Model() + _MOI.Nonlinear.set_objective(nlp, _to_nlp_expr(func, idx)) + ord = [_MOI.VariableIndex(i) for i in 1:n] + evaluator = _MOI.Nonlinear.Evaluator( + nlp, _MOI.Nonlinear.SparseReverseMode(), ord) + _MOI.initialize(evaluator, [:Grad]) + + xk_vec = [_unwrap_scalar(get(xk, v, zero(T))) for v in vars] + f_xk = _MOI.eval_objective(evaluator, xk_vec) + grad = zeros(T, n) + _MOI.eval_objective_gradient(evaluator, grad, xk_vec) + + constant = T(f_xk) + for i in 1:n + constant -= grad[i] * xk_vec[i] + end + V = typeof(ref_map[vars[1]]) + result = JuMP.GenericAffExpr{T, V}(constant) + for i in 1:n + iszero(grad[i]) && continue + JuMP.add_to_expression!(result, grad[i], ref_map[vars[i]]) + end + return result +end + +# Extract RHS from an MOI set. +_set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = + _MOI.constant(s) +_set_rhs(::Any) = 0.0 + +# Unwrap a 1-element per-support `Vector` to its scalar value; +# scalars pass through. `extract_solution` returns per-support +# `Vector`s uniformly (length-1 for finite, length-K for InfiniteOpt). +# AD pipelines and `set_start_value` need a scalar in the finite +# case; per-support consumers slice out a scalar themselves. +_unwrap_scalar(v::Real) = v +_unwrap_scalar(v::AbstractVector) = only(v) diff --git a/src/utilities.jl b/src/utilities.jl index 790aaffc..e5686af5 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -532,93 +532,3 @@ function _remap_constraint_to_indicator( ) where {M <: JuMP.AbstractModel} return disj_map[con_ref] end -################################################################################ -# LINEARIZATION & EXPRESSION CONVERSION -################################################################################ -# First-order Taylor approximation and MOI expression building -# for outer approximation methods (LOA, future OA variants). -################################################################################ - -################################################################################ -# MOI NONLINEAR EXPRESSION CONVERSION -################################################################################ -# Convert JuMP expression trees to Julia Expr with -# MOI.VariableIndex leaves for MOI.Nonlinear evaluation. -function _to_nlp_expr(expr::JuMP.GenericNonlinearExpr, idx::Dict) - args = Any[_to_nlp_expr(a, idx) for a in expr.args] - return Expr(:call, expr.head, args...) -end -function _to_nlp_expr(expr::JuMP.GenericAffExpr, idx::Dict) - parts = Any[expr.constant] - for (var, coef) in expr.terms - push!(parts, Expr(:call, :*, coef, _MOI.VariableIndex(idx[var]))) - end - length(parts) == 1 && return parts[1] - return Expr(:call, :+, parts...) -end -function _to_nlp_expr(expr::JuMP.GenericQuadExpr, idx::Dict) - parts = Any[_to_nlp_expr(expr.aff, idx)] - for (pair, coef) in expr.terms - push!(parts, Expr(:call, :*, coef, - _MOI.VariableIndex(idx[pair.a]), - _MOI.VariableIndex(idx[pair.b]))) - end - length(parts) == 1 && return parts[1] - return Expr(:call, :+, parts...) -end -function _to_nlp_expr(var::JuMP.AbstractVariableRef, idx::Dict) - return _MOI.VariableIndex(idx[var]) -end -_to_nlp_expr(x::Number, ::Dict) = x - -# First-order Taylor linearization of a quadratic or nonlinear -# expression at point xk via MOI.Nonlinear reverse-mode AD. -function _linearize_at( - func::Union{JuMP.GenericQuadExpr, JuMP.GenericNonlinearExpr}, - xk::Dict, ref_map - ) - vars = JuMP.AbstractVariableRef[] - _interrogate_variables(v -> push!(vars, v), func) - unique!(vars) - isempty(vars) && return JuMP.AffExpr(JuMP.value(v -> 0.0, func)) - - n = length(vars) - T = JuMP.value_type(typeof(JuMP.owner_model(vars[1]))) - idx = Dict(vars[i] => i for i in 1:n) - nlp = _MOI.Nonlinear.Model() - _MOI.Nonlinear.set_objective(nlp, _to_nlp_expr(func, idx)) - ord = [_MOI.VariableIndex(i) for i in 1:n] - evaluator = _MOI.Nonlinear.Evaluator( - nlp, _MOI.Nonlinear.SparseReverseMode(), ord) - _MOI.initialize(evaluator, [:Grad]) - - xk_vec = [_unwrap_scalar(get(xk, v, zero(T))) for v in vars] - f_xk = _MOI.eval_objective(evaluator, xk_vec) - grad = zeros(T, n) - _MOI.eval_objective_gradient(evaluator, grad, xk_vec) - - constant = T(f_xk) - for i in 1:n - constant -= grad[i] * xk_vec[i] - end - V = typeof(ref_map[vars[1]]) - result = JuMP.GenericAffExpr{T, V}(constant) - for i in 1:n - iszero(grad[i]) && continue - JuMP.add_to_expression!(result, grad[i], ref_map[vars[i]]) - end - return result -end - -# Extract RHS from an MOI set. -_set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = - _MOI.constant(s) -_set_rhs(::Any) = 0.0 - -# Unwrap a 1-element per-support `Vector` to its scalar value; -# scalars pass through. `extract_solution` returns per-support -# `Vector`s uniformly (length-1 for finite, length-K for InfiniteOpt). -# AD pipelines and `set_start_value` need a scalar in the finite -# case; per-support consumers slice out a scalar themselves. -_unwrap_scalar(v::Real) = v -_unwrap_scalar(v::AbstractVector) = only(v) diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index b92db13d..732be28a 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -9,19 +9,19 @@ function test_loa_datatype() @test method.rtol == 1e-4 @test method.M_value == 1e9 @test method.max_slack == 1000.0 - @test method.OA_penalty_factor == 1000.0 + @test method.oa_penalty == 1000.0 @test method.inner_method isa BigM @test method.inner_method.value == 1e9 method = LOA(HiGHS.Optimizer; max_iter = 50, atol = 1e-8, rtol = 1e-6, M_value = 1e6, max_slack = 500.0, - OA_penalty_factor = 200.0) + oa_penalty = 200.0) @test method.max_iter == 50 @test method.atol == 1e-8 @test method.rtol == 1e-6 @test method.M_value == 1e6 @test method.max_slack == 500.0 - @test method.OA_penalty_factor == 200.0 + @test method.oa_penalty == 200.0 @test method.inner_method isa BigM @test method.inner_method.value == 1e6 diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index dc70b802..088cb10a 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -822,7 +822,7 @@ end function test_loa_infinite_complement_indicator() # Regression: `_indicator_to_binary(model)[Y2]` returns the AffExpr # `1 - binary(Y1)` for a logical-complement indicator. Earlier the - # extension's `_fix_combination` called `JuMP.fix` directly on that + # extension's `fix_combination` called `JuMP.fix` directly on that # AffExpr and crashed; now it delegates to base `fix_indicator` # which unwraps and inverts. # @@ -852,6 +852,39 @@ function test_loa_infinite_complement_indicator() @test objective_value(model) ≈ 8.0 atol = 1e-2 end +function test_loa_infinite_complement_nonlinear_disjunct() + # Regression: a finite (or complement-form AffExpr) indicator + # gating a NONLINEAR constraint on an infinite variable. Earlier + # `cut_info` decided fan-out from the indicator only — finite + # indicator → single un-sliced site → `_linearize_at` received + # per-support `Vector{Float64}` values and tripped on + # `_unwrap_scalar`'s `only(v)`. Fixed by inspecting the constraint + # expression too: any infinite var found there governs the fan-out + # supports, even when the indicator is scalar. + # + # max ∫ x dt with 0 ≤ x ≤ 10 over t ∈ [0,1]: + # Y1: x ≤ 3 Y2 ≡ ¬Y1: x² ≤ 64 + # Y2 (complement) is the maximizer (x = 8 across t), giving 8. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = InfiniteGDPModel(juniper) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y1, Logical) + @variable(model, Y2, Logical, logical_complement = Y1) + @constraint(model, x <= 3, Disjunct(Y1)) + @constraint(model, x^2 <= 64, Disjunct(Y2)) + @disjunction(model, [Y1, Y2]) + @objective(model, Max, ∫(x, t)) + optimize!(model, + gdp_method = LOA(juniper; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-2 +end + function test_loa_infinite_multidim_parameter() # Regression: multi-D dependent parameter group. `supports(ξ)` is # a 2 × N matrix, not a vector. Earlier the extension called @@ -986,6 +1019,7 @@ end test_loa_infinite_nonlinear_global() test_loa_infinite_aggregate_global() test_loa_infinite_complement_indicator() + test_loa_infinite_complement_nonlinear_disjunct() test_loa_infinite_multidim_parameter() else @info "Skipping InfiniteOpt LOA tests: " * From af043e04abe5dccb9cdecaaa85ab68649f20443a Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Thu, 11 Jun 2026 16:09:55 -0400 Subject: [PATCH 49/72] . --- src/loa.jl | 10 ++++++---- test/constraints/loa.jl | 31 +++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/loa.jl b/src/loa.jl index aa429ca7..ae6a1c80 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -5,10 +5,12 @@ # With augmented-penalty OA master from: # Viswanathan & Grossmann (1990), Comp. & Chem. Eng. 14(7), 769-782 # -# Infeasible primary NLPs are handled solely via the master's augmented -# penalty: the combination is forbidden by a no-good cut and no OA cut -# is emitted from that iteration. No separate feasibility-restoration -# (NLPF) subproblem is built. +# Infeasible primary NLPs fall through to NLPF (V&G 1990 eq. 8): a +# slacked feasibility version of the same problem whose primal becomes +# the linearization site for OA cuts. A no-good cut still forbids that +# combination on the master. NLPF is bypassed for per-support +# Vector{Bool} combinations (InfiniteOpt multi-resolution master) — +# those fall back to no-good-only. ################################################################################ ################################################################################ diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 732be28a..7a202264 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -289,6 +289,36 @@ function test_to_nlp_expr() @test DP._to_nlp_expr(42, idx) == 42 end +function test_loa_nlpf_infeasible_disjunct() + # Y1 disjunct constraint x^2 >= 200 is NLP-infeasible against the + # variable bound x in [0, 10] (max x^2 = 100). The primary NLP at + # the Y1=true seed therefore fails and NLPF (V&G 1990 eq. 8) must + # kick in: slack `u` on the GreaterThan, minimize u, return the + # x = 10 / u = 100 primal as the linearization site for the OA cut + # on x^2 >= 200. With the master's per-cut penalized slack + # absorbing the resulting (invalid in isolation) linearization and + # the no-good cut forbidding Y1, LOA should still converge to the + # Y2=true optimum x = 5. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = GDPModel(juniper) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x^2 >= 200, Disjunct(Y[1])) + @constraint(model, x <= 5, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + optimize!(model, + gdp_method = LOA(juniper; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 5.0 atol = 1e-3 + @test value(Y[2]) ≈ 1.0 atol = 1e-6 + @test value(Y[1]) ≈ 0.0 atol = 1e-6 +end + @testset "LOA" begin test_loa_datatype() test_set_covering_combos() @@ -301,6 +331,7 @@ end test_loa_error_fallback() test_loa_nonlinear_global() test_loa_complement_indicator_nonlinear_disjunct() + test_loa_nlpf_infeasible_disjunct() test_linearize_nonlinear_exp() test_linearize_nonlinear_sin() test_linearize_nonlinear_multivar() From f2dd80494a1a55c0d3f96483b886fc10434ad9a7 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Thu, 11 Jun 2026 16:34:36 -0400 Subject: [PATCH 50/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 20 ++++++ src/loa.jl | 62 ++++++++++++------- .../InfiniteDisjunctiveProgramming.jl | 30 +++++++++ 3 files changed, 89 insertions(+), 23 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 30022165..62a69b61 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -1213,6 +1213,26 @@ function DP.fix_combination( end end +# Per-support binary pin on an NLPF copy of an `InfiniteModel`. +# Triggered when the combination value is `AbstractVector{Bool}` — +# i.e., the indicator is itself infinite, so each support k must be +# pinned independently via a point-equality `binary(t_k) == value[k]`. +# Finite indicators on an `InfiniteModel` dispatch to the base scalar +# `JuMP.fix` path because `combination_val` returns a scalar `Bool`. +# Complement-form binaries are handled by base recursion before this +# dispatch fires. +function DP._nlpf_fix_on_copy( + copy::InfiniteOpt.InfiniteModel, + binary::InfiniteOpt.GeneralVariableRef, + value::AbstractVector{Bool} + ) + for (k, support) in enumerate(_supports_of(binary)) + JuMP.@constraint(copy, + _at_support(binary, support) == (value[k] ? 1.0 : 0.0)) + end + return +end + # Broadcast a per-support warm start across an infinite var's # transcribed instances; for a finite var on an InfiniteModel, # `transcription_variable` returns a single ref and `values` is diff --git a/src/loa.jl b/src/loa.jl index ae6a1c80..45669253 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -384,11 +384,6 @@ end function _solve_nlpf( model::M, combination, method::LOA ) where {M <: JuMP.AbstractModel} - # NLPF needs scalar Bool combination values to fix binaries - # directly. Per-support `Vector{Bool}` from the InfiniteOpt master - # is out of scope for v1. - all(v -> v isa Bool, values(combination)) || return nothing - copy, ref_map = JuMP.copy_model(model) JuMP.set_optimizer(copy, method.nlp_optimizer) JuMP.set_silent(copy) @@ -409,17 +404,16 @@ function _solve_nlpf( JuMP.@objective(copy, Min, u) - # Plain `JuMP.fix` (no `force = true`) — `force` triggers - # `delete_upper_bound` which fails on InfiniteOpt copies whose - # ZeroOne constraint refs didn't survive `JuMP.copy_model`. We - # leave the binary attribute intact on the copy; the fix pins - # the value to 0/1 which satisfies the ZeroOne anyway. Solvers - # that can't handle binaries (Ipopt) will error out — we catch - # and return `nothing` so the caller falls back to the existing - # no-good-only behavior. + # Translate each original-model indicator binary to its + # counterpart on the copy, then pin it to the combination value. + # `_nlpf_fix_on_copy` dispatches on value type: scalar `Bool` + # paths use `JuMP.fix`; per-support `AbstractVector{Bool}` paths + # require an `InfiniteModel`-side override (per-support point- + # equality) — see `ext/InfiniteDisjunctiveProgramming.jl`. for (indicator, value) in combination - original_binary = _indicator_to_binary(model)[indicator] - _nlpf_fix_on_copy(original_binary, value, ref_map) + binary = _binary_on_copy( + _indicator_to_binary(model)[indicator], ref_map) + _nlpf_fix_on_copy(copy, binary, value) end try @@ -437,24 +431,46 @@ function _solve_nlpf( objective = Inf, feasible = false) end +# Translate a binary reference from the original model to its +# counterpart on the copy. Direct refs go through `ref_map`; +# complement-form `1 - y_orig` rebuilds as `1 - ref_map[y_orig]`. +_binary_on_copy(binary::JuMP.AbstractVariableRef, ref_map) = + ref_map[binary] +function _binary_on_copy( + binary::JuMP.GenericAffExpr, ref_map + ) + underlying = only(keys(binary.terms)) + return 1.0 - ref_map[underlying] +end + +# Pin a copy-side binary to a combination value. Plain `JuMP.fix` +# (no `force = true`) — `force` triggers `delete_upper_bound` which +# fails on InfiniteOpt copies whose ZeroOne constraint refs didn't +# survive `JuMP.copy_model`. The fix pins the value to 0/1 which +# satisfies the ZeroOne anyway. Solvers that can't handle binaries +# (Ipopt) will error out — the caller catches and returns `nothing` +# so the iteration falls back to no-good-only behavior. function _nlpf_fix_on_copy( - binary::JuMP.AbstractVariableRef, value::Bool, ref_map + copy, binary::JuMP.AbstractVariableRef, value::Bool ) - target = ref_map[binary] - JuMP.is_fixed(target) && JuMP.unfix(target) - JuMP.fix(target, value ? 1.0 : 0.0) + JuMP.is_fixed(binary) && JuMP.unfix(binary) + JuMP.fix(binary, value ? 1.0 : 0.0) return end +# Complement form `1 - y_underlying`: indicator=value means +# underlying=!value. Recursion routes both scalar `Bool` and per- +# support `AbstractVector{Bool}` to the underlying-binary dispatch. function _nlpf_fix_on_copy( - binary::JuMP.GenericAffExpr, value::Bool, ref_map + copy, binary::JuMP.GenericAffExpr, value ) - # complement form `1 - y_underlying`: indicator=true means - # underlying=0; indicator=false means underlying=1. underlying = only(keys(binary.terms)) - _nlpf_fix_on_copy(underlying, !value, ref_map) + _nlpf_fix_on_copy(copy, underlying, _flip(value)) return end +_flip(v::Bool) = !v +_flip(v::AbstractVector{Bool}) = .!v + _nlpf_should_slack(::Type{<:_MOI.LessThan}) = true _nlpf_should_slack(::Type{<:_MOI.GreaterThan}) = true _nlpf_should_slack(::Type) = false diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 088cb10a..c5df7165 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -916,6 +916,35 @@ function test_loa_infinite_multidim_parameter() @test objective_value(model) ≈ 8.0 atol = 1e-2 end +function test_loa_infinite_nlpf_infeasible_disjunct() + # InfiniteOpt LOA: Y[1]'s per-support constraint x^2 >= 200 is + # NLP-infeasible against the bound x in [0, 10] (max x^2 = 100). + # With infinite indicators `Y[1:2], InfiniteLogical(t)`, the + # combination value is `Vector{Bool}` — exercising the extension + # override `_nlpf_fix_on_copy(::InfiniteModel, + # ::GeneralVariableRef, ::AbstractVector{Bool})` that pins each + # support via point-equality. LOA must still converge to Y[2] + # active everywhere (x = 5), giving objective ∫x dt = 5. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = InfiniteGDPModel(juniper) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 3) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x^2 >= 200, Disjunct(Y[1])) + @constraint(model, x <= 5, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, ∫(x, t)) + optimize!(model, + gdp_method = LOA(juniper; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in + (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 5.0 atol = 1e-2 +end + function test_loa_infinite_aggregate_global() # min ∫x dt s.t. ∫(x^2, t) <= 4 (aggregate global), x >= y, # (y >= 1) ∨ (y >= 3), 0 <= x, y <= 10 over t ∈ [0, 1]. @@ -1021,6 +1050,7 @@ end test_loa_infinite_complement_indicator() test_loa_infinite_complement_nonlinear_disjunct() test_loa_infinite_multidim_parameter() + test_loa_infinite_nlpf_infeasible_disjunct() else @info "Skipping InfiniteOpt LOA tests: " * "JuMP.copy_model(::InfiniteModel) unavailable " * From c674594025506820662de0bb9b9674fda33a96cb Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Mon, 15 Jun 2026 17:05:31 -0400 Subject: [PATCH 51/72] . --- ext/InfiniteDisjunctiveProgramming.jl | 110 ++++++++++++- src/loa.jl | 220 +++++++++++++++++++++++--- 2 files changed, 305 insertions(+), 25 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 62a69b61..d0de8f4c 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -898,9 +898,15 @@ function _transcribed_to_master_point( if isempty(prefs) result[transcribed] = master_var else - supports = vec(InfiniteOpt.supports(only(prefs))) - for (k, ref) in enumerate(vec(transcribed)) - result[ref] = master_var(supports[k]) + # `_supports_of` returns scalars for 1-D parameters and + # vectors for multi-D dependent groups; `_at_support` + # routes both shapes through `v(support)`. Using + # `vec(InfiniteOpt.supports(...))` here would silently + # flatten a multi-D matrix into scalars and break point + # evaluation on dependent parameter groups. + for (k, support) in enumerate(_supports_of(v)) + result[vec(transcribed)[k]] = + _at_support(master_var, support) end end end @@ -996,9 +1002,98 @@ function DP.build_loa_master( variable_map[v] = ref_map[v] end + # Aggregate-wrapped LINEAR constraints (e.g. `𝔼(W, ξ) ≥ α`) + # were filtered out at `copy_model` time because `copy_model` + # cannot transfer MeasureRefs across InfiniteModels. The + # nonlinear-aggregate path re-adds them as OA cuts after each + # NLP solve, but `_add_global_oa_cuts_infinite` short-circuits + # on linear `F` — so without this step a linear chance + # constraint is missing from the master entirely, and the + # master will pick combinations that violate it. Transcribe + # each such constraint once and add the flat scalar form to + # the master directly. + _add_aggregate_linear_constraints!( + master, model, ref_map, variable_type) + return DP._LOAMaster(master, binary_map, variable_map, objective_sense, original_objective, alpha_oa, - objective_ref_map) + objective_ref_map, Any[]) +end + +# Walk the original model's linear-`F` constraints, transcribe those +# containing a `MeasureRef` / `ParameterFunctionRef`, and append the +# resulting flat scalar constraint to the master. No linearization +# needed (the constraint is already affine post-transcription) — +# `_linearize_at` on an `AffExpr` just substitutes variables via +# `transcribed_to_master`. Reuses the transformation backend that +# `_add_global_oa_cuts_infinite` will rebuild later; building it +# now is cheap and idempotent. +function _add_aggregate_linear_constraints!( + master::InfiniteOpt.InfiniteModel, + model::InfiniteOpt.InfiniteModel, + ref_map::AbstractDict, + variable_type::Type + ) + has_any = false + for (F, S) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + DP._is_linear_F(F) || continue + for cref in JuMP.all_constraints(model, F, S) + con = JuMP.constraint_object(cref) + _has_aggregate_ref(con.func) || continue + has_any = true + break + end + has_any && break + end + has_any || return + + InfiniteOpt.build_transformation_backend!(model) + transcribed_to_master = _transcribed_to_master_point(model, ref_map) + # `_transcribed_to_master_point` only walks decision vars. Finite + # parameters (`@finite_parameter`) survive transcription as + # scalar JuMP variables and can appear in transcribed + # constraints (e.g. the `α` on the RHS of an event constraint). + # Map each transcribed-parameter JuMP var to the master's + # corresponding parameter so the constraint stays + # parameter-relative (the master then honors `set_value(α, ...)` + # without rebuild). + for p in InfiniteOpt.all_parameters(model) + transcribed_p = try + InfiniteOpt.transformation_variable(p) + catch + continue + end + transcribed_p isa JuMP.VariableRef || continue + haskey(ref_map, p) || continue + transcribed_to_master[transcribed_p] = ref_map[p] + end + # `_linearize_at(::GenericAffExpr, ...)` ignores its + # `linearization_point` arg; pass any empty dict. + empty_point = Dict{JuMP.VariableRef, Float64}() + for (F, S) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + DP._is_linear_F(F) || continue + for cref in JuMP.all_constraints(model, F, S) + con = JuMP.constraint_object(cref) + _has_aggregate_ref(con.func) || continue + con isa JuMP.ScalarConstraint || continue + transcribed_func = InfiniteOpt.transformation_expression( + con.func) + if transcribed_func isa AbstractArray + for tf in vec(transcribed_func) + master_expr = DP._linearize_at( + tf, empty_point, transcribed_to_master) + JuMP.@constraint(master, master_expr in con.set) + end + else + master_expr = DP._linearize_at( + transcribed_func, empty_point, transcribed_to_master) + JuMP.@constraint(master, master_expr in con.set) + end + end + end + return end # Override the disjunct-cut loop for `InfiniteModel`. Same shape as @@ -1032,7 +1127,7 @@ function DP.add_oa_cuts( linearization = DP._linearize_at(master.original_objective, obj_point, master.objective_ref_map) DP._add_objective_cut( - Val(master.objective_sense), master, linearization) + Val(master.objective_sense), master, linearization, method) _add_global_oa_cuts_infinite(model, master, result, method) DP.add_disjunct_oa_cuts(model, master, result, method) return @@ -1154,7 +1249,7 @@ function DP.add_disjunct_oa_cuts( transcribed_constraint, master, binary_ref, transcribed_xk[], transcribed_to_master[], dual_value, method, sign_factor, - penalty_sign) + penalty_sign, result.feasible) end continue end @@ -1166,7 +1261,8 @@ function DP.add_disjunct_oa_cuts( DP._add_oa_cut_for_constraint( constraint, master, binary_ref, linearization_point, var_map, dual_value, - method, sign_factor, penalty_sign) + method, sign_factor, penalty_sign, + result.feasible) end end end diff --git a/src/loa.jl b/src/loa.jl index 45669253..b05712a3 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -102,6 +102,11 @@ mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM} original_objective::OF alpha_oa::AO objective_ref_map::RM + # All penalized slacks ever appended to the master, in emission + # order — disjunct, global, and objective cuts. Iterated by + # `_check_slacks` after each master solve to diagnose whether + # `max_slack` is binding. + slacks::Vector{Any} end ################################################################################ @@ -157,16 +162,23 @@ function _reformulate_loa_single_level( # Initialization Procedure (Türkay & Grossmann 1996, sec. 2.2): # solve K set-covering NLPs with cycling indicator combinations to # seed the master with at least one OA cut per disjunct. + # `previous_result` carries the most recent FEASIBLE NLP primal + # forward as the warm start for the next NLP (T&G §2.3); NLPF + # solutions are skipped since their primal is slack-distorted. + previous_result = nothing for (i, combination) in enumerate(combinations) + _set_nlp_warm_start!(previous_result) t_seed = @elapsed result = _solve_nlp( model, combination, method, reformulation_map) avoid_combination(master.model, combination, master.binary_map) add_oa_cuts(model, master, result, method) + _check_penalty(result, method) if result.feasible && _is_better(sense_token, result.objective, best_objective) best_objective = result.objective best_result = result end + result.feasible && (previous_result = result) method.verbose && _log_seed(i, t_seed, result, best_objective) end @@ -175,21 +187,25 @@ function _reformulate_loa_single_level( feasible = JuMP.is_solved_and_feasible(master.model) method.verbose && _log_master(iter, master.model, t_master) feasible || break + _check_slacks(master, method) master_bound = JuMP.objective_value(master.model) method.verbose && _log_iter(iter, master_bound, best_objective, sense_token) _loa_converged(best_objective, master_bound, sense_token, method) && break combination = _extract_combination(model, master) + _set_nlp_warm_start!(previous_result) t_nlp = @elapsed result = _solve_nlp( model, combination, method, reformulation_map) avoid_combination(master.model, combination, master.binary_map) add_oa_cuts(model, master, result, method) + _check_penalty(result, method) if result.feasible && _is_better(sense_token, result.objective, best_objective) best_objective = result.objective best_result = result end + result.feasible && (previous_result = result) method.verbose && _log_nlp(iter, t_nlp, result, best_objective) end @@ -328,7 +344,7 @@ function build_loa_master(model::JuMP.AbstractModel, method::LOA) JuMP.@objective(master, objective_sense, alpha_oa) return _LOAMaster(master, binary_map, variable_map, objective_sense, - original_objective, alpha_oa, variable_map) + original_objective, alpha_oa, variable_map, Any[]) end ################################################################################ @@ -550,6 +566,22 @@ function with_fixed_combination( end end +# Iter-to-iter NLP warm start (T&G 1996 §2.3): seed the next primary +# NLP solve from the most recent FEASIBLE solution's primal point. +# No-op on the first seed iteration when `previous` is `nothing`, and +# no-op on iterations following an NLPF fall-through (caller does not +# update `previous_result` then), since NLPF's primal is slack- +# distorted and a worse start than the prior real NLP. Routes through +# the `set_warm_start!` dispatch so the InfiniteOpt extension's +# per-support broadcast handles `GeneralVariableRef` correctly. +function _set_nlp_warm_start!(previous) + previous === nothing && return + for (variable, values) in previous.linearization_point + set_warm_start!(variable, values) + end + return +end + # Finalize the model with the LOA-optimal combination: relax logical # binaries (stripping ZeroOne so a pure NLP solver can handle the # post-hook `JuMP.optimize!`), fix indicators at the committed values, @@ -672,6 +704,57 @@ function _collect_nlp_duals( return duals end +################################################################################ +# DIAGNOSTICS +################################################################################ +# V&G 1990 requires ρ > ‖λ*‖_∞ for the penalty term to dominate any +# admissible Lagrange multiplier — otherwise a slack can absorb a real +# violation and the OA cut becomes an invalid relaxation. Check after +# every feasible primary NLP and warn if the bound is at risk. NLPF +# returns sign-only duals (`±1`) so this is a no-op when `!feasible`. +function _check_penalty(result::NamedTuple, method::LOA) + result.feasible || return + isempty(result.duals) && return + max_dual = 0.0 + for (_, d) in result.duals + max_dual = max(max_dual, _max_abs_dual(d)) + end + if max_dual >= method.oa_penalty + @warn "LOA: oa_penalty = $(method.oa_penalty) ≤ max |dual| " * + "= $max_dual. V&G 1990 requires ρ > ‖λ‖_∞; slacks may " * + "absorb real OA-cut violations. Raise `oa_penalty`." + end + return +end +_max_abs_dual(d::Number) = abs(d) +_max_abs_dual(d::AbstractArray) = isempty(d) ? 0.0 : maximum(abs, d) +_max_abs_dual(::Nothing) = 0.0 + +# `max_slack` is a numerical guard (V&G 1990 leaves slacks unbounded; +# in practice an unbounded slack lets the master trivially feasible- +# itself out of the OA constraints). When a slack is at the cap, the +# master is effectively solving a stricter problem than V&G's master. +# Diagnose so the user can raise `max_slack` if the cap binds often. +function _check_slacks(master::_LOAMaster, method::LOA) + JuMP.has_values(master.model) || return + isempty(master.slacks) && return + cap = method.max_slack + threshold = 0.99 * cap + max_value = 0.0 + binding = 0 + for slack in master.slacks + val = JuMP.value(slack) + max_value = max(max_value, val) + val >= threshold && (binding += 1) + end + if binding > 0 + @warn "LOA: $binding slack(s) at max_slack = $cap (max " * + "value $max_value). Master is solving a stricter problem " * + "than V&G's; raise `max_slack` if this persists." + end + return +end + ################################################################################ # COMBO EXTRACTION (master → NLP) ################################################################################ @@ -707,17 +790,31 @@ function add_oa_cuts( isempty(result.linearization_point) && return linearization = _linearize_at(master.original_objective, result.linearization_point, master.objective_ref_map) - _add_objective_cut(Val(master.objective_sense), master, linearization) + _add_objective_cut( + Val(master.objective_sense), master, linearization, method) _add_global_oa_cuts(model, master, result, method) add_disjunct_oa_cuts(model, master, result, method) return end -# Bounding cut `lin ≤ α` (min) or `lin ≥ α` (max) on the master. -_add_objective_cut(::Val{_MOI.MIN_SENSE}, master, lin) = - JuMP.@constraint(master.model, lin <= master.alpha_oa) -_add_objective_cut(::Val{_MOI.MAX_SENSE}, master, lin) = - JuMP.@constraint(master.model, lin >= master.alpha_oa) +# Slacked objective cut (V&G 1990 eq. 6). For MIN, `lin ≤ α + σ` with +# `σ ≥ 0` penalized in the master objective so the slack drives to +# zero at convergence; the linearization is then `α ≥ lin` (standard +# OA bound). MAX is symmetric: `lin ≥ α − σ`. Without the slack, an +# accumulated bad linearization on a nonconvex objective can make the +# master infeasible — the disjunct and global cuts already carry +# slacks but the objective cut did not. +function _add_objective_cut( + sense_token::Val, master::_LOAMaster, lin, method::LOA + ) + _, penalty_sign = _disjunct_cut_coefficients(sense_token) + slack = _penalized_slack(master, method, penalty_sign) + _add_objective_cut_body(sense_token, master, lin, slack) +end +_add_objective_cut_body(::Val{_MOI.MIN_SENSE}, master, lin, slack) = + JuMP.@constraint(master.model, lin <= master.alpha_oa + slack) +_add_objective_cut_body(::Val{_MOI.MAX_SENSE}, master, lin, slack) = + JuMP.@constraint(master.model, lin >= master.alpha_oa - slack) # Add the OA cut `g(x^l) + ∇g(x^l)^T (x − x^l) in con.set` for every # nonlinear global constraint of `model` — the third cut class in @@ -786,31 +883,37 @@ function add_disjunct_oa_cuts( result.linearization_point, master.variable_map, dual) _add_oa_cut_for_constraint( constraint, master, binary_ref, lin_point, - var_map, dual_value, method, sign_factor, penalty_sign) + var_map, dual_value, method, sign_factor, + penalty_sign, result.feasible) end end end end # Fresh nonnegative slack added to the master objective with the V&G -# 1990 penalty. Shared by the disjunct and global OA cuts so both get -# the same augmented-penalty treatment: a nonconvex linearization can -# be an invalid relaxation, and the penalized slack keeps the master -# feasible instead of letting accumulated cuts make it infeasible. +# 1990 penalty. Shared by the disjunct, global, and objective OA cuts +# so all three carry the same augmented-penalty treatment: a nonconvex +# linearization can be an invalid relaxation, and the penalized slack +# keeps the master feasible instead of letting accumulated cuts make +# it infeasible. The slack is recorded on `master.slacks` so the +# `_check_slacks` diagnostic can see whether `max_slack` is binding. function _penalized_slack( master::_LOAMaster, method::LOA, penalty_sign::Int ) slack = JuMP.@variable(master.model, lower_bound = 0.0, upper_bound = method.max_slack) + push!(master.slacks, slack) JuMP.set_objective_function(master.model, JuMP.objective_function(master.model) + penalty_sign * method.oa_penalty * slack) return slack end -# Linearize constraint at `linearization_point`, append a fresh per-cut -# slack with V&G penalty, gate by `M(1 − binary)`. Linear constraints -# are exact via BigM and skipped. +# Linearize constraint at `linearization_point`, then dispatch on the +# constraint's set to emit slacked OA cut(s) gated by `M(1 − binary)`. +# Linear constraints are exact via BigM and skipped. `feasible` flags +# whether the linearization point came from a real NLP (true) or from +# NLPF (false); equality cuts behave differently in those two regimes. function _add_oa_cut_for_constraint( constraint::JuMP.AbstractConstraint, master::_LOAMaster, @@ -820,13 +923,94 @@ function _add_oa_cut_for_constraint( dual_value, method::LOA, sign_factor::Int, - penalty_sign::Int + penalty_sign::Int, + feasible::Bool ) _is_linear_F(typeof(constraint.func)) && return + linearization = _linearize_at( + constraint.func, linearization_point, var_map) + _emit_disjunct_oa_cut(constraint.set, master, binary_ref, + linearization, dual_value, method, sign_factor, + penalty_sign, feasible) + return +end + +# Inequality sets — one signed cut per Türkay-Grossmann 1996 / Duran- +# Grossmann 1986 OA convention. Primary NLP gives the real Lagrange +# multiplier (whose sign picks the active side); NLPF returns ±1 from +# `_nlpf_dual_sign(set)`. +function _emit_disjunct_oa_cut( + set::Union{_MOI.LessThan, _MOI.GreaterThan}, + master::_LOAMaster, binary_ref, linearization, dual_value, + method::LOA, sign_factor::Int, penalty_sign::Int, ::Bool + ) + sign_value = sign(sign_factor * _collapse_dual(dual_value)) + sign_value == 0 && return + rhs = _set_rhs(set) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, + sign_value * (linearization - rhs) - slack <= + method.M_value * (1 - binary_ref)) + return +end + +# Equality — feasible NLP: the summed BigM duals give the OA/ER +# multiplier sign (Duran-Grossmann 1986), so one signed cut suffices. +# Infeasible (NLPF): sign is uninformative, so emit both directions +# sharing one slack — mirrors the global-OA equality treatment in +# `_add_global_oa_row!(::EqualTo)`. +function _emit_disjunct_oa_cut( + set::_MOI.EqualTo, + master::_LOAMaster, binary_ref, linearization, dual_value, + method::LOA, sign_factor::Int, penalty_sign::Int, feasible::Bool + ) + c = _MOI.constant(set) + slack = _penalized_slack(master, method, penalty_sign) + if feasible + sign_value = sign(sign_factor * _collapse_dual(dual_value)) + sign_value == 0 && return + JuMP.@constraint(master.model, + sign_value * (linearization - c) - slack <= + method.M_value * (1 - binary_ref)) + return + end + JuMP.@constraint(master.model, + (linearization - c) - slack <= + method.M_value * (1 - binary_ref)) + JuMP.@constraint(master.model, + (c - linearization) - slack <= + method.M_value * (1 - binary_ref)) + return +end + +# Interval — always two-sided regardless of dual: there is no single +# rhs to sign against (`_set_rhs(::Interval) = 0` is wrong here), and +# any active boundary needs its linearization to gate the right side. +# Both rows share one slack so the V&G penalty is not double-counted. +function _emit_disjunct_oa_cut( + set::_MOI.Interval, + master::_LOAMaster, binary_ref, linearization, ::Any, + method::LOA, ::Int, penalty_sign::Int, ::Bool + ) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, + (linearization - set.upper) - slack <= + method.M_value * (1 - binary_ref)) + JuMP.@constraint(master.model, + (set.lower - linearization) - slack <= + method.M_value * (1 - binary_ref)) + return +end + +# Fallback for vector sets and any future set types — preserve the +# original single-signed behavior with `_collapse_dual` for the sign. +function _emit_disjunct_oa_cut( + set, master::_LOAMaster, binary_ref, linearization, dual_value, + method::LOA, sign_factor::Int, penalty_sign::Int, ::Bool + ) sign_value = sign(sign_factor * _collapse_dual(dual_value)) sign_value == 0 && return - rhs = _set_rhs(constraint.set) - linearization = _linearize_at(constraint.func, linearization_point, var_map) + rhs = _set_rhs(set) slack = _penalized_slack(master, method, penalty_sign) JuMP.@constraint(master.model, sign_value * (linearization - rhs) - slack <= From 3a8fa5b90717163beb9adbaa7a4b3f63c6f2ddee Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Tue, 16 Jun 2026 09:25:27 -0400 Subject: [PATCH 52/72] Fix LOA BoundsError on equality cuts --- ext/InfiniteDisjunctiveProgramming.jl | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index d0de8f4c..7667c711 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -811,6 +811,7 @@ function _cut_sites( dual ) sites = Any[] + dual = _support_dual(dual, length(supports)) for (k, support) in enumerate(supports) actives[k] || continue point_var_map = Dict{ @@ -843,6 +844,15 @@ _at(values::AbstractArray, k::Integer) = length(values) == 1 ? values[1] : values[k] _at(scalar, ::Integer) = scalar +# A feasible NLP yields one dual per support (length == nsupp). A +# feasibility-restoration solve yields a set-shaped *sign* dual (e.g. +# EqualTo → [1, 1]) carrying only a direction, identical at every +# support. Collapse the latter to a scalar so per-support slicing +# broadcasts the sign instead of indexing past its length. +_support_dual(dual, nsupp::Integer) = + dual isa AbstractArray && length(dual) != nsupp && + length(dual) != 1 ? DP._collapse_dual(dual) : dual + # Point-evaluate an InfiniteOpt var at `support` if it's infinite; # return the var as-is if it's finite. `support` is one joint support # point — a scalar for 1-D parameters, a vector for a multi-D From c1f4bd3896b37f61bc28b799306e1d9b7793b9ef Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Tue, 16 Jun 2026 11:23:04 -0400 Subject: [PATCH 53/72] LOA termination message for nonconvex --- src/loa.jl | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/src/loa.jl b/src/loa.jl index b05712a3..fc91d06d 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -182,17 +182,53 @@ function _reformulate_loa_single_level( method.verbose && _log_seed(i, t_seed, result, best_objective) end + # The master objective is a rigorous bound only for a convex (here, + # linear) inner problem. With nonlinear constraints the bound may be + # invalid, so terminate on V&G (1990) criterion 5 — stop once a + # feasible NLP no longer improves on the previous feasible NLP — + # rather than on the untrustworthy master gap. + rigorous = !_has_nonlinear_constraints(model) + warned = false + produced_bound = false + prev_nlp_objective = previous_result === nothing ? nothing : + previous_result.objective for iter in 1:method.max_iter t_master = @elapsed JuMP.optimize!(master.model) feasible = JuMP.is_solved_and_feasible(master.model) method.verbose && _log_master(iter, master.model, t_master) - feasible || break + if !feasible + # An infeasible/unbounded master before any bound is a + # degenerate exit, not convergence: LOA falls back to the + # best set-covering seed without ever iterating. Common + # when most NLP subproblems are infeasible, so the master + # lacks the OA cuts to bound `alpha_oa`. + produced_bound || @warn "LOA: master returned status " * + "$(JuMP.termination_status(master.model)) before any " * + "bound was produced; exiting with the best seed NLP " * + "incumbent ($best_objective). This is NOT a converged " * + "or certified optimum — often a sign the NLP " * + "subproblems are mostly infeasible." + break + end _check_slacks(master, method) master_bound = JuMP.objective_value(master.model) + produced_bound = true method.verbose && _log_iter(iter, master_bound, best_objective, sense_token) - _loa_converged(best_objective, master_bound, sense_token, method) && - break + if rigorous + _loa_converged( + best_objective, master_bound, sense_token, method) && + break + elseif !warned && _bound_crossed( + best_objective, master_bound, sense_token, method) + @warn "LOA: master bound ($master_bound) crossed the " * + "incumbent ($best_objective). The model appears " * + "nonconvex, so the OA cuts are not valid supports and " * + "the dual bound is NOT rigorous. Returning the best " * + "NLP incumbent as a heuristic (Viswanathan & " * + "Grossmann 1990)." + warned = true + end combination = _extract_combination(model, master) _set_nlp_warm_start!(previous_result) t_nlp = @elapsed result = _solve_nlp( @@ -205,8 +241,15 @@ function _reformulate_loa_single_level( best_objective = result.objective best_result = result end - result.feasible && (previous_result = result) method.verbose && _log_nlp(iter, t_nlp, result, best_objective) + # V&G (1990) criterion 5: without a rigorous bound, stop once a + # feasible NLP fails to improve on the previous feasible NLP. + if !rigorous && result.feasible + prev_nlp_objective !== nothing && !_is_better(sense_token, + result.objective, prev_nlp_objective) && break + prev_nlp_objective = result.objective + end + result.feasible && (previous_result = result) end if best_result !== nothing @@ -260,6 +303,24 @@ function _loa_converged( return false end +# A genuine bound crossing: the master "bound" is worse than the +# incumbent by more than the convergence tolerance (gap strongly +# negative). In valid convex OA, LB ≤ UB always, so this can only +# arise from invalid OA cuts on a nonconvex problem — V&G (1990) report +# the master objective exceeding the integer NLP optimum on every +# nonconvex test problem. The tolerance band separates a real crossing +# from floating-point / solver noise. +function _bound_crossed( + best_objective::Real, + master_bound::Real, + sense_token::Val, + method::LOA + ) + (isinf(best_objective) || isinf(master_bound)) && return false + gap = _gap(sense_token, best_objective, master_bound) + return gap < -(method.atol + method.rtol * abs(best_objective)) +end + # Error fallback for unsupported model types. function reformulate_model(::M, ::LOA) where {M} error("reformulate_model not implemented for model type `$(M)` with LOA.") @@ -307,6 +368,22 @@ _is_linear_F(::Type{<:AbstractVector{<:JuMP.AbstractVariableRef}}) = true _is_linear_F(::Type{<:AbstractVector{<:JuMP.GenericAffExpr}}) = true _is_linear_F(::Type) = false +# True if any constraint carries a non-affine function. LOA's master +# objective is a rigorous lower bound only when every constraint is +# linear: the inner problem is then a (linear, hence convex) GDP and +# the OA cuts are exact supporting hyperplanes. With a nonlinear +# constraint the problem may be nonconvex, the OA linearizations need +# not support the feasible set, and the master bound is not rigorous — +# so LOA terminates on V&G (1990) subproblem-improvement instead. +function _has_nonlinear_constraints(model::JuMP.AbstractModel) + variable_type = JuMP.variable_ref_type(typeof(model)) + for (F, _) in JuMP.list_of_constraint_types(model) + F === variable_type && continue + _is_linear_F(F) || return true + end + return false +end + # OVERRIDABLE. Build the LOA master MILP `M^b_{LA}` (Türkay & Grossmann # 1996, eq. 12): copy decision variables and only the linear # constraints, install `alpha_oa` as the objective auxiliary. Nonlinear From 128491f745809643848bfb1f7b3213b38fe77322 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Wed, 17 Jun 2026 13:48:58 -0400 Subject: [PATCH 54/72] Refactor LOA, drop multi-resolution, add iteration and total time limits --- ext/InfiniteDisjunctiveProgramming.jl | 194 +----------------------- src/loa.jl | 209 ++++++++++++++++---------- 2 files changed, 133 insertions(+), 270 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 7667c711..e96b04e5 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -390,190 +390,6 @@ end DP.any_active(actives::AbstractVector{Bool}) = any(actives) -# Override `reformulate_model` for `InfiniteModel` so the LOA -# `supports_schedule` activates the multi-resolution loop. With -# `supports_schedule = nothing` this is identical to the base path. With -# a schedule + `coarse_builder = N -> InfiniteModel`, the wrapper: -# -# 1. For each N in the schedule, build a FRESH InfiniteModel via -# `coarse_builder(N)`. Apply warm-starts captured from the previous -# warmup level (indexed by variable name + parameter name, which -# survive across model rebuilds). Run LOA. Capture trajectory. -# Discard the warmup model. -# 2. Apply the final captured trajectory to the user's `model`. -# 3. Run a single-level LOA on the user's model. -# -# This sidesteps every accumulated-state failure mode we hit when -# mutating one InfiniteModel across resolutions (orphan point vars, -# stale parameter supports, transcription cache poisoning, etc.). -function DP.reformulate_model( - model::InfiniteOpt.InfiniteModel, method::DP.LOA - ) - schedule = method.supports_schedule - if schedule === nothing - DP._reformulate_loa_single_level(model, method) - DP._set_solution_method(model, method) - DP._set_ready_to_optimize(model, true) - return - end - - isempty(schedule) && error( - "LOA `supports_schedule` must be `nothing` or non-empty.") - builder = method.coarse_builder - builder === nothing && error( - "LOA `supports_schedule` requires `coarse_builder`.") - - trajectory = nothing - for (level, N) in enumerate(schedule) - method.verbose && println( - "LOA_MULTIRES warmup level=", level, " N=", N, - trajectory === nothing ? " (cold start)" : - " (warm-start from level $(level - 1))") - warmup_model = builder(N) - if trajectory !== nothing - _apply_trajectory_by_name!(warmup_model, trajectory) - end - warmup_method = _strip_schedule(method) - warmup_result = DP._reformulate_loa_single_level( - warmup_model, warmup_method) - if warmup_result === nothing - method.verbose && println("LOA_MULTIRES warmup level=", - level, " no commit; trajectory unchanged") - continue - end - trajectory = _capture_trajectory_by_name( - warmup_model, warmup_result) - method.verbose && println("LOA_MULTIRES warmup level=", - level, " obj=", warmup_result.objective) - end - - if trajectory !== nothing - method.verbose && println( - "LOA_MULTIRES applying warmup trajectory to user model") - _apply_trajectory_by_name!(model, trajectory) - end - DP._reformulate_loa_single_level(model, method) - DP._set_solution_method(model, method) - DP._set_ready_to_optimize(model, true) - return -end - -# Build a copy of `method` with `supports_schedule = nothing` so the -# warmup LOA runs as single-level (not recursively triggering its own -# multi-res). The `coarse_builder` is irrelevant once the schedule is -# stripped; nothing reads it. -function _strip_schedule(method::DP.LOA) - return DP.LOA(method.nlp_optimizer; - mip_optimizer = method.mip_optimizer, - inner_method = method.inner_method, - max_iter = method.max_iter, - atol = method.atol, rtol = method.rtol, - M_value = method.M_value, - max_slack = method.max_slack, - oa_penalty = method.oa_penalty, - verbose = method.verbose) -end - -# Capture the converged trajectory as a name-keyed dict so we can apply -# it to a freshly-built model whose `GeneralVariableRef`s are different -# objects. Also records each parameter's current supports (per name) so -# the apply step knows the source grid for interpolation. -function _capture_trajectory_by_name( - model::InfiniteOpt.InfiniteModel, result::NamedTuple - ) - name_to_values = Dict{String, Vector{Float64}}() - for (variable, values) in result.linearization_point - nm = JuMP.name(variable) - isempty(nm) && continue - name_to_values[nm] = collect(values) - end - name_to_supports = Dict{String, Vector{Float64}}() - for p in InfiniteOpt.all_parameters(model) - nm = JuMP.name(p) - isempty(nm) && continue - sup = InfiniteOpt.supports(p) - ndims(sup) == 1 || continue - name_to_supports[nm] = collect(sup) - end - return (values = name_to_values, supports = name_to_supports) -end - -# Apply a name-keyed trajectory to a fresh model: for each variable -# whose name appears in `trajectory.values`, interpolate its captured -# per-support values from the source parameter grid to this model's -# grid and write as `set_start_value` on the transcribed JuMP refs. -# Indicator binaries are skipped — they're not part of the primal -# warm-start. -function _apply_trajectory_by_name!( - target::InfiniteOpt.InfiniteModel, trajectory::NamedTuple - ) - indicator_binaries = Set{InfiniteOpt.GeneralVariableRef}() - for (_, bref) in DP._indicator_to_binary(target) - push!(indicator_binaries, bref isa JuMP.GenericAffExpr ? - only(keys(bref.terms)) : bref) - end - InfiniteOpt.build_transformation_backend!(target) - for v in JuMP.all_variables(target) - v in indicator_binaries && continue - nm = JuMP.name(v) - haskey(trajectory.values, nm) || continue - values = trajectory.values[nm] - _warm_start_by_name(target, v, values, trajectory.supports) - end - return -end - -# Variable-side warm-start: finite vars get the scalar value directly; -# infinite (single-parameter) vars get linearly interpolated from the -# captured source grid to this model's transcribed supports. -function _warm_start_by_name( - ::InfiniteOpt.InfiniteModel, - variable::InfiniteOpt.GeneralVariableRef, - values::AbstractVector, - captured_supports::AbstractDict - ) - prefs = InfiniteOpt.parameter_refs(variable) - if isempty(prefs) - length(values) == 1 || return - transcribed = InfiniteOpt.transformation_variable(variable) - transcribed isa JuMP.AbstractVariableRef || return - JuMP.set_start_value(transcribed, only(values)) - return - end - length(prefs) == 1 || return - pname = JuMP.name(first(prefs)) - haskey(captured_supports, pname) || return - old = captured_supports[pname] - length(values) == length(old) || return - new = InfiniteOpt.supports(first(prefs)) - ndims(new) == 1 || return - transcribed = InfiniteOpt.transformation_variable(variable) - transcribed isa AbstractArray || return - refs = vec(transcribed) - length(refs) == length(new) || return - for (k, s) in enumerate(new) - JuMP.set_start_value(refs[k], _linear_interp(old, values, s)) - end - return -end - -# Piecewise-linear interpolation of `ys` defined on the increasing -# nodes `xs`, evaluated at `q`. Clamps at the endpoints. Cheap and -# stable enough for warm-starts; only neighborhood-correctness matters. -function _linear_interp( - xs::AbstractVector{<:Real}, ys::AbstractVector{<:Real}, q::Real - ) - n = length(xs) - q <= xs[1] && return ys[1] - q >= xs[n] && return ys[n] - i = searchsortedlast(xs, q) - i == n && return ys[n] - x0, x1 = xs[i], xs[i + 1] - x1 == x0 && return ys[i] - t = (q - x0) / (x1 - x0) - return (1 - t) * ys[i] + t * ys[i + 1] -end - # `JuMP.value` returns a per-support `Array` for infinite vars and a # scalar for finite vars. The `> 0.5` cutoff handles solver-side # integer-feasibility slack (e.g. HiGHS can return 2.75e-40 for a @@ -1022,7 +838,7 @@ function DP.build_loa_master( # master will pick combinations that violate it. Transcribe # each such constraint once and add the flat scalar form to # the master directly. - _add_aggregate_linear_constraints!( + _add_aggregate_linear_constraints( master, model, ref_map, variable_type) return DP._LOAMaster(master, binary_map, variable_map, @@ -1038,7 +854,7 @@ end # `transcribed_to_master`. Reuses the transformation backend that # `_add_global_oa_cuts_infinite` will rebuild later; building it # now is cheap and idempotent. -function _add_aggregate_linear_constraints!( +function _add_aggregate_linear_constraints( master::InfiniteOpt.InfiniteModel, model::InfiniteOpt.InfiniteModel, ref_map::AbstractDict, @@ -1186,13 +1002,13 @@ function _add_global_oa_cuts_infinite( for tf in vec(transcribed_func) lin = DP._linearize_at(tf, transcribed_xk[], transcribed_to_master[]) - DP._add_global_oa_row!(master, lin, con.set, + DP._add_global_oa_row(master, lin, con.set, method, penalty_sign) end else lin = DP._linearize_at(transcribed_func, transcribed_xk[], transcribed_to_master[]) - DP._add_global_oa_row!(master, lin, con.set, + DP._add_global_oa_row(master, lin, con.set, method, penalty_sign) end end @@ -1343,7 +1159,7 @@ end # transcribed instances; for a finite var on an InfiniteModel, # `transcription_variable` returns a single ref and `values` is # length-1. -function DP.set_warm_start!( +function DP.set_linearization_start( variable::InfiniteOpt.GeneralVariableRef, values::AbstractVector ) diff --git a/src/loa.jl b/src/loa.jl index fc91d06d..e1ce3c96 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -9,8 +9,8 @@ # slacked feasibility version of the same problem whose primal becomes # the linearization site for OA cuts. A no-good cut still forbids that # combination on the master. NLPF is bypassed for per-support -# Vector{Bool} combinations (InfiniteOpt multi-resolution master) — -# those fall back to no-good-only. +# Vector{Bool} combinations (InfiniteOpt per-support master) — those +# fall back to no-good-only. ################################################################################ ################################################################################ @@ -40,6 +40,13 @@ supported. Other reformulations (`Hull`, `PSplit`) are not yet supported. - `max_slack::T`: Upper bound for each per-cut slack variable. - `oa_penalty::T`: V&G 1990 penalty coefficient applied to slacks in the master objective. +- `iteration_time_limit::Float64`: Wall-clock budget (seconds) for the + LOA iteration loop (seeds + main loop). `Inf` (default) is no limit; + each subproblem solve is capped to the budget left so one solve can't + overrun it. +- `time_limit::Float64`: Overall wall-clock cap (seconds) covering the + iteration loop AND the final committed solve. `Inf` (default) is no + cap; when set, the final solve gets only the budget left under it. """ struct LOA{O, P, R, T} <: AbstractReformulationMethod nlp_optimizer::O @@ -52,8 +59,8 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod max_slack::T oa_penalty::T verbose::Bool - supports_schedule::Union{Nothing, Vector{Int}} - coarse_builder::Union{Nothing, Function} + iteration_time_limit::Float64 + time_limit::Float64 function LOA( nlp_optimizer::O; mip_optimizer::P = nlp_optimizer, @@ -65,19 +72,15 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod oa_penalty::T = 1e3, inner_method::R = BigM(M_value), verbose::Bool = false, - supports_schedule::Union{Nothing, Vector{Int}} = nothing, - coarse_builder::Union{Nothing, Function} = nothing + iteration_time_limit::Float64 = Inf, + time_limit::Float64 = Inf ) where {O, P, R <: AbstractReformulationMethod, T} R <: Union{BigM, MBM} || error( "LOA inner_method must be BigM or MBM (got $R). " * "Hull and PSplit are not yet supported.") - supports_schedule === nothing || coarse_builder !== nothing || error( - "LOA `supports_schedule` requires a `coarse_builder = N -> " * - "InfiniteModel` that constructs a fresh model at each " * - "warmup resolution.") new{O, P, R, T}(nlp_optimizer, mip_optimizer, inner_method, max_iter, atol, rtol, M_value, max_slack, oa_penalty, verbose, - supports_schedule, coarse_builder) + iteration_time_limit, time_limit) end end @@ -127,26 +130,12 @@ _flip_sense(::Val{_MOI.MAX_SENSE}) = Val(_MOI.MIN_SENSE) ################################################################################ # MAIN ALGORITHM ################################################################################ +# LOA reformulation entry point: set-covering seeds, the master/NLP +# iteration loop, commit the best combination, and mark the model ready. +# Plain `LOA` on an `InfiniteModel` dispatches here too — the InfiniteOpt +# overrides act on the inner solve steps (master build, NLP, cuts), not +# on this driver. function reformulate_model(model::JuMP.AbstractModel, method::LOA) - method.supports_schedule === nothing || - error("`supports_schedule` is only meaningful for InfiniteOpt " * - "models; load InfiniteOpt to enable the multi-resolution path.") - _reformulate_loa_single_level(model, method) - _set_solution_method(model, method) - _set_ready_to_optimize(model, true) - return -end - -# Single-resolution LOA. Always uses set-covering for the -# initialization seeds; the InfiniteOpt multi-resolution wrapper -# carries trajectory information across levels via primal warm starts -# on transcribed JuMP variables (set before this is called), not by -# overriding the seed-combination loop. Returns `best_result` -# (NamedTuple with `combination` / `linearization_point` / etc.) or -# `nothing` if every NLP was infeasible. -function _reformulate_loa_single_level( - model::JuMP.AbstractModel, method::LOA - ) _clear_reformulations(model) combinations = _set_covering_combinations(model) reformulate_model(model, method.inner_method) @@ -155,6 +144,11 @@ function _reformulate_loa_single_level( reformulation_map = _build_reformulation_map(model) JuMP.set_optimizer(model, method.nlp_optimizer) JuMP.set_silent(model) + t_start = time() + overall_deadline = t_start + method.time_limit + loop_deadline = + min(t_start + method.iteration_time_limit, overall_deadline) + original_time_limit = JuMP.time_limit_sec(model) sense_token = Val(JuMP.objective_sense(model)) best_objective = _worst_objective(sense_token) best_result = nothing @@ -167,9 +161,11 @@ function _reformulate_loa_single_level( # solutions are skipped since their primal is slack-distorted. previous_result = nothing for (i, combination) in enumerate(combinations) - _set_nlp_warm_start!(previous_result) + time() < loop_deadline || break + _set_nlp_warm_start(previous_result) t_seed = @elapsed result = _solve_nlp( - model, combination, method, reformulation_map) + model, combination, method, reformulation_map; + deadline = loop_deadline) avoid_combination(master.model, combination, master.binary_map) add_oa_cuts(model, master, result, method) _check_penalty(result, method) @@ -182,32 +178,22 @@ function _reformulate_loa_single_level( method.verbose && _log_seed(i, t_seed, result, best_objective) end - # The master objective is a rigorous bound only for a convex (here, - # linear) inner problem. With nonlinear constraints the bound may be - # invalid, so terminate on V&G (1990) criterion 5 — stop once a - # feasible NLP no longer improves on the previous feasible NLP — - # rather than on the untrustworthy master gap. + # Master bound is a rigorous dual bound only for a convex (linear) + # inner problem; otherwise stop on V&G (1990) criterion 5 + # (`_nlp_stalled`), not the untrustworthy gap. rigorous = !_has_nonlinear_constraints(model) - warned = false produced_bound = false prev_nlp_objective = previous_result === nothing ? nothing : previous_result.objective for iter in 1:method.max_iter + time() < loop_deadline || break + _cap_remaining_time(master.model, loop_deadline) t_master = @elapsed JuMP.optimize!(master.model) feasible = JuMP.is_solved_and_feasible(master.model) method.verbose && _log_master(iter, master.model, t_master) if !feasible - # An infeasible/unbounded master before any bound is a - # degenerate exit, not convergence: LOA falls back to the - # best set-covering seed without ever iterating. Common - # when most NLP subproblems are infeasible, so the master - # lacks the OA cuts to bound `alpha_oa`. - produced_bound || @warn "LOA: master returned status " * - "$(JuMP.termination_status(master.model)) before any " * - "bound was produced; exiting with the best seed NLP " * - "incumbent ($best_objective). This is NOT a converged " * - "or certified optimum — often a sign the NLP " * - "subproblems are mostly infeasible." + _check_degenerate_master(master, produced_bound, + best_objective) break end _check_slacks(master, method) @@ -217,22 +203,16 @@ function _reformulate_loa_single_level( _log_iter(iter, master_bound, best_objective, sense_token) if rigorous _loa_converged( - best_objective, master_bound, sense_token, method) && - break - elseif !warned && _bound_crossed( + best_objective, master_bound, sense_token, method) && break + else + _check_nonrigorous_bound( best_objective, master_bound, sense_token, method) - @warn "LOA: master bound ($master_bound) crossed the " * - "incumbent ($best_objective). The model appears " * - "nonconvex, so the OA cuts are not valid supports and " * - "the dual bound is NOT rigorous. Returning the best " * - "NLP incumbent as a heuristic (Viswanathan & " * - "Grossmann 1990)." - warned = true end combination = _extract_combination(model, master) - _set_nlp_warm_start!(previous_result) + _set_nlp_warm_start(previous_result) t_nlp = @elapsed result = _solve_nlp( - model, combination, method, reformulation_map) + model, combination, method, reformulation_map; + deadline = loop_deadline) avoid_combination(master.model, combination, master.binary_map) add_oa_cuts(model, master, result, method) _check_penalty(result, method) @@ -242,11 +222,11 @@ function _reformulate_loa_single_level( best_result = result end method.verbose && _log_nlp(iter, t_nlp, result, best_objective) - # V&G (1990) criterion 5: without a rigorous bound, stop once a - # feasible NLP fails to improve on the previous feasible NLP. + # V&G (1990) criterion 5: with no rigorous bound, stop once a + # feasible NLP stops improving on the previous feasible NLP. if !rigorous && result.feasible - prev_nlp_objective !== nothing && !_is_better(sense_token, - result.objective, prev_nlp_objective) && break + _nlp_stalled(sense_token, result.objective, + prev_nlp_objective) && break prev_nlp_objective = result.objective end result.feasible && (previous_result = result) @@ -256,7 +236,16 @@ function _reformulate_loa_single_level( commit_combination(model, best_result.combination, best_result.linearization_point) end - return best_result + if isfinite(method.time_limit) + # Overall cap: the final committed solve gets the budget left. + JuMP.set_time_limit_sec(model, max(0.0, overall_deadline - time())) + elseif isfinite(method.iteration_time_limit) + # Loop-only budget: restore so the final solve isn't crippled. + _restore_time_limit(model, original_time_limit) + end + _set_solution_method(model, method) + _set_ready_to_optimize(model, true) + return end # `verbose = true` trace helpers. Print to stdout so a `tee`-d log @@ -321,6 +310,13 @@ function _bound_crossed( return gap < -(method.atol + method.rtol * abs(best_objective)) end +# V&G (1990) criterion 5: a feasible NLP that does not improve on the +# previous feasible NLP signals the heuristic has converged. The first +# feasible NLP (`nothing` previous) is never stalled. +_nlp_stalled(sense_token::Val, objective::Real, previous::Real) = + !_is_better(sense_token, objective, previous) +_nlp_stalled(::Val, ::Real, ::Nothing) = false + # Error fallback for unsupported model types. function reformulate_model(::M, ::LOA) where {M} error("reformulate_model not implemented for model type `$(M)` with LOA.") @@ -427,6 +423,23 @@ end ################################################################################ # NLP SUBPROBLEM ################################################################################ +# Cap `target`'s solver to the wall-clock budget left before `deadline` +# (seconds, `time()` clock). No-op when `deadline` is `Inf` (no LOA time +# limit). Keeps one in-flight solve from overrunning the whole-loop +# budget; the loop also breaks once `deadline` passes. +function _cap_remaining_time(target::JuMP.AbstractModel, deadline::Float64) + isfinite(deadline) || return + JuMP.set_time_limit_sec(target, max(0.0, deadline - time())) + return +end + +# Restore the model's solver time limit after the loop — the factory +# value captured before capping, or unset if the factory set none. +_restore_time_limit(model::JuMP.AbstractModel, ::Nothing) = + JuMP.unset_time_limit_sec(model) +_restore_time_limit(model::JuMP.AbstractModel, seconds::Real) = + JuMP.set_time_limit_sec(model, seconds) + # Solve the primary NLP for a fixed combination. If feasible, read the # primal point, duals, and objective. If infeasible, fall through to # the NLPF (V&G 1990 eq. 8) approximation: a slacked version of the @@ -435,8 +448,10 @@ end # information from the failed combination instead of only adding a # no-good cut. function _solve_nlp( - model::M, combination, method::LOA, reformulation_map + model::M, combination, method::LOA, reformulation_map; + deadline::Float64 = Inf ) where {M <: JuMP.AbstractModel} + _cap_remaining_time(model, deadline) primary = with_fixed_combination(model, combination) do JuMP.optimize!(model, ignore_optimize_hook = true) if JuMP.is_solved_and_feasible(model) @@ -453,7 +468,7 @@ function _solve_nlp( primary === nothing || return primary # Primary NLP infeasible — try NLPF. - nlpf = _solve_nlpf(model, combination, method) + nlpf = _solve_nlpf(model, combination, method; deadline = deadline) nlpf === nothing || return nlpf return (combination = combination, @@ -475,11 +490,12 @@ end # slacked feasibility solve we know each active constraint's # linearization is informative in the standard direction. function _solve_nlpf( - model::M, combination, method::LOA + model::M, combination, method::LOA; deadline::Float64 = Inf ) where {M <: JuMP.AbstractModel} copy, ref_map = JuMP.copy_model(model) JuMP.set_optimizer(copy, method.nlp_optimizer) JuMP.set_silent(copy) + _cap_remaining_time(copy, deadline) var_type = JuMP.variable_ref_type(typeof(copy)) u = JuMP.@variable(copy, lower_bound = 0.0, base_name = "_nlpf_u") @@ -649,12 +665,12 @@ end # no-op on iterations following an NLPF fall-through (caller does not # update `previous_result` then), since NLPF's primal is slack- # distorted and a worse start than the prior real NLP. Routes through -# the `set_warm_start!` dispatch so the InfiniteOpt extension's +# the `set_linearization_start` dispatch so the InfiniteOpt extension's # per-support broadcast handles `GeneralVariableRef` correctly. -function _set_nlp_warm_start!(previous) +function _set_nlp_warm_start(previous) previous === nothing && return for (variable, values) in previous.linearization_point - set_warm_start!(variable, values) + set_linearization_start(variable, values) end return end @@ -673,7 +689,7 @@ function commit_combination( relax_logical_vars(model) fix_combination(model, combination) for (variable, values) in linearization_point - set_warm_start!(variable, values) + set_linearization_start(variable, values) end return end @@ -700,7 +716,7 @@ end # warm start. `values` is always per-support shape (length-1 vector # for finite vars); base unwraps. The InfiniteOpt extension overrides # for `GeneralVariableRef` to broadcast across transcribed supports. -set_warm_start!(variable, values::AbstractVector) = +set_linearization_start(variable, values::AbstractVector) = JuMP.set_start_value(variable, only(values)) ################################################################################ @@ -832,6 +848,37 @@ function _check_slacks(master::_LOAMaster, method::LOA) return end +# A master that is infeasible/unbounded before producing any bound is a +# degenerate exit, not convergence: LOA falls back to the best +# set-covering seed without iterating, usually because most NLP +# subproblems are infeasible and the master lacks the OA cuts to bound +# `alpha_oa`. No-op once a bound has been produced. +function _check_degenerate_master( + master::_LOAMaster, produced_bound::Bool, best_objective::Real + ) + produced_bound && return + @warn "LOA: master returned " * + "$(JuMP.termination_status(master.model)) before any bound; " * + "returning the best seed incumbent ($best_objective). Not a " * + "certified optimum — usually most NLP subproblems are infeasible." + return +end + +# A master bound that crosses the incumbent (`_bound_crossed`) proves the +# OA cuts are not valid supports: the model is nonconvex and the master +# gives no rigorous dual bound. LOA continues as a V&G (1990) heuristic +# and returns the best NLP incumbent. +function _check_nonrigorous_bound( + best_objective::Real, master_bound::Real, sense_token::Val, method::LOA + ) + _bound_crossed(best_objective, master_bound, sense_token, method) || + return + @warn "LOA: master bound ($master_bound) crossed the incumbent " * + "($best_objective); model is nonconvex, dual bound is not " * + "rigorous. Returning the best NLP incumbent (heuristic, V&G 1990)." + return +end + ################################################################################ # COMBO EXTRACTION (master → NLP) ################################################################################ @@ -926,7 +973,7 @@ function _add_global_oa_cuts( con isa JuMP.ScalarConstraint || continue linearization = _linearize_at(con.func, result.linearization_point, master.variable_map) - _add_global_oa_row!(master, linearization, con.set, + _add_global_oa_row(master, linearization, con.set, method, penalty_sign) end end @@ -1035,7 +1082,7 @@ end # multiplier sign (Duran-Grossmann 1986), so one signed cut suffices. # Infeasible (NLPF): sign is uninformative, so emit both directions # sharing one slack — mirrors the global-OA equality treatment in -# `_add_global_oa_row!(::EqualTo)`. +# `_add_global_oa_row(::EqualTo)`. function _emit_disjunct_oa_cut( set::_MOI.EqualTo, master::_LOAMaster, binary_ref, linearization, dual_value, @@ -1101,7 +1148,7 @@ end # global cuts make the master infeasible on nonconvex models. # `EqualTo` / `Interval` get a two-sided pair sharing one slack. # Unknown set types fall back to the prior hard cut. -function _add_global_oa_row!( +function _add_global_oa_row( master::_LOAMaster, lin, set::_MOI.LessThan, method::LOA, penalty_sign::Int ) @@ -1109,7 +1156,7 @@ function _add_global_oa_row!( JuMP.@constraint(master.model, lin - _MOI.constant(set) <= slack) return end -function _add_global_oa_row!( +function _add_global_oa_row( master::_LOAMaster, lin, set::_MOI.GreaterThan, method::LOA, penalty_sign::Int ) @@ -1117,7 +1164,7 @@ function _add_global_oa_row!( JuMP.@constraint(master.model, _MOI.constant(set) - lin <= slack) return end -function _add_global_oa_row!( +function _add_global_oa_row( master::_LOAMaster, lin, set::_MOI.EqualTo, method::LOA, penalty_sign::Int ) @@ -1127,7 +1174,7 @@ function _add_global_oa_row!( JuMP.@constraint(master.model, c - lin <= slack) return end -function _add_global_oa_row!( +function _add_global_oa_row( master::_LOAMaster, lin, set::_MOI.Interval, method::LOA, penalty_sign::Int ) @@ -1136,7 +1183,7 @@ function _add_global_oa_row!( JuMP.@constraint(master.model, set.lower - lin <= slack) return end -function _add_global_oa_row!( +function _add_global_oa_row( master::_LOAMaster, lin, set, ::LOA, ::Int ) JuMP.@constraint(master.model, lin in set) From 396b35c3585de90db0780eda0fa72c1b514fe9c6 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Sun, 21 Jun 2026 18:19:54 -0400 Subject: [PATCH 55/72] Removing leftover checks --- ext/InfiniteDisjunctiveProgramming.jl | 224 ++--- src/loa.jl | 882 ++++++------------ test/constraints/loa.jl | 143 ++- .../InfiniteDisjunctiveProgramming.jl | 61 +- 4 files changed, 534 insertions(+), 776 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index e96b04e5..260cbf3e 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -388,31 +388,6 @@ end # submodel are themselves InfiniteModels, with per-support handling # via point evaluation on infinite `GeneralVariableRef`s. -DP.any_active(actives::AbstractVector{Bool}) = any(actives) - -# `JuMP.value` returns a per-support `Array` for infinite vars and a -# scalar for finite vars. The `> 0.5` cutoff handles solver-side -# integer-feasibility slack (e.g. HiGHS can return 2.75e-40 for a -# "0" binary), where direct `Bool(val)` would `InexactError`. -# `JuMP.value` returns a per-support `Array` for infinite vars and a -# scalar for finite vars; `round(Bool, ·)` handles both via broadcast -# and absorbs solver integer-feasibility slack. -function DP.combination_val(v::InfiniteOpt.GeneralVariableRef) - val = JuMP.value(v) - return val isa AbstractArray ? vec(round.(Bool, val)) : round(Bool, val) -end - -# Complement-form binary (`1 - y_underlying`) stored in `binary_map` -# for indicators declared `logical_complement`. `JuMP.value` on the -# AffExpr returns a per-support `Vector{Float64}` when the underlying -# is infinite, or a scalar when it's finite. -function DP.combination_val( - v::JuMP.GenericAffExpr{C, <:InfiniteOpt.GeneralVariableRef} - ) where {C} - val = JuMP.value(v) - return val isa AbstractArray ? vec(round.(Bool, val)) : round(Bool, val) -end - # Supports of the infinite parameter group `v` depends on. For a 1-D # parameter, `supports(p)` is a `Vector{Float64}`; for a dependent # group of dimension k, it is a k × N_supports `Matrix`. Returns each @@ -515,71 +490,24 @@ function DP.add_no_good_terms( return end -function DP.cut_info( - binary_ref::InfiniteOpt.GeneralVariableRef, - active::Bool, - constraint::JuMP.AbstractConstraint, - linearization_point::AbstractDict, - variable_map::AbstractDict, dual - ) - return _infinite_cut_info(binary_ref, active, constraint.func, - linearization_point, variable_map, dual) -end - -function DP.cut_info( - binary_ref::InfiniteOpt.GeneralVariableRef, - actives::AbstractVector, - constraint::JuMP.AbstractConstraint, - linearization_point::AbstractDict, - variable_map::AbstractDict, dual - ) - return _infinite_cut_info(binary_ref, actives, constraint.func, - linearization_point, variable_map, dual) -end - -# Complement-form binary (`1 - y_underlying`). Same fan-out logic as -# the variable-ref form; the per-support `_at_support` rebuilds the -# AffExpr with its variable point-evaluated. -function DP.cut_info( - binary_ref::JuMP.GenericAffExpr, - active::Bool, - constraint::JuMP.AbstractConstraint, - linearization_point::AbstractDict, - variable_map::AbstractDict, dual - ) - return _infinite_cut_info(binary_ref, active, constraint.func, - linearization_point, variable_map, dual) -end - -# Complement-form binary with per-support active descriptor (e.g., -# `BitVector` from `_extract_combination` on an InfiniteOpt master -# where the indicator was declared `logical_complement`). -function DP.cut_info( - binary_ref::JuMP.GenericAffExpr, - actives::AbstractVector, - constraint::JuMP.AbstractConstraint, - linearization_point::AbstractDict, - variable_map::AbstractDict, dual - ) - return _infinite_cut_info(binary_ref, actives, constraint.func, - linearization_point, variable_map, dual) -end - # Fan out across supports if either the binary or the constraint # expression involves an infinite variable; otherwise emit one # un-sliced site. The chosen supports come from the first infinite # variable found in either expression. function _infinite_cut_info( - binary_ref, active, constraint_func, - linearization_point, variable_map, dual + binary_ref, + active, + constraint_func, + linearization_point, + variable_map ) supports = _relevant_supports(binary_ref, constraint_func) supports === nothing && - return ((binary_ref, linearization_point, variable_map, dual),) + return ((binary_ref, linearization_point, variable_map),) actives = active isa AbstractVector ? active : fill(active, length(supports)) return _cut_sites(binary_ref, supports, actives, - linearization_point, variable_map, dual) + linearization_point, variable_map) end # Supports governing per-support fan-out — pick the first infinite @@ -616,18 +544,14 @@ function _find_infinite_var(expr) return found[] end -_find_infinite_var(::Any) = nothing - function _cut_sites( binary_ref, supports::AbstractVector, actives::AbstractVector, linearization_point::AbstractDict, - variable_map::AbstractDict, - dual + variable_map::AbstractDict ) sites = Any[] - dual = _support_dual(dual, length(supports)) for (k, support) in enumerate(supports) actives[k] || continue point_var_map = Dict{ @@ -644,8 +568,7 @@ function _cut_sites( push!(sites, ( _at_support(binary_ref, support), point, - point_var_map, - _at(dual, k) + point_var_map )) end return sites @@ -660,15 +583,6 @@ _at(values::AbstractArray, k::Integer) = length(values) == 1 ? values[1] : values[k] _at(scalar, ::Integer) = scalar -# A feasible NLP yields one dual per support (length == nsupp). A -# feasibility-restoration solve yields a set-shaped *sign* dual (e.g. -# EqualTo → [1, 1]) carrying only a direction, identical at every -# support. Collapse the latter to a scalar so per-support slicing -# broadcasts the sign instead of indexing past its length. -_support_dual(dual, nsupp::Integer) = - dual isa AbstractArray && length(dual) != nsupp && - length(dual) != 1 ? DP._collapse_dual(dual) : dual - # Point-evaluate an InfiniteOpt var at `support` if it's infinite; # return the var as-is if it's finite. `support` is one joint support # point — a scalar for 1-D parameters, a vector for a multi-D @@ -703,7 +617,7 @@ end # resulting flat scalar objective, and uses this map to translate # the gradient back into master point variables. Per-support # DISJUNCT cuts do NOT need this — they linearize natively in -# InfiniteModel space via `cut_info`. If the objective contains no +# InfiniteModel space via `_infinite_cut_info`. If the objective has no # measures, this whole layer is dead weight; killing it would # require either banning measure objectives or hand-writing a # `_linearize_at` that walks `MeasureRef` symbolically. @@ -843,7 +757,7 @@ function DP.build_loa_master( return DP._LOAMaster(master, binary_map, variable_map, objective_sense, original_objective, alpha_oa, - objective_ref_map, Any[]) + objective_ref_map) end # Walk the original model's linear-`F` constraints, transcribe those @@ -885,11 +799,9 @@ function _add_aggregate_linear_constraints( # parameter-relative (the master then honors `set_value(α, ...)` # without rebuild). for p in InfiniteOpt.all_parameters(model) - transcribed_p = try - InfiniteOpt.transformation_variable(p) - catch - continue - end + InfiniteOpt.dispatch_variable_ref(p) isa + InfiniteOpt.FiniteParameterRef || continue + transcribed_p = InfiniteOpt.transformation_variable(p) transcribed_p isa JuMP.VariableRef || continue haskey(ref_map, p) || continue transcribed_to_master[transcribed_p] = ref_map[p] @@ -922,10 +834,9 @@ function _add_aggregate_linear_constraints( return end -# Override the disjunct-cut loop for `InfiniteModel`. Same shape as -# Override `add_oa_cuts` for `InfiniteModel` to translate the +# Override `add_oa_cuts` for `InfiniteModel`: translate the # linearization point into the form the master's `original_objective` -# expects, and to route global OA cuts through transcription so they +# expects, and route global OA cuts through transcription so they # work over infinite vars and aggregate refs. The base # `result.linearization_point` has per-support `Vector` values keyed # on InfiniteOpt vars; the master's objective is either the raw @@ -972,8 +883,7 @@ function _add_global_oa_cuts_infinite( result::NamedTuple, method::DP.LOA ) - _, penalty_sign = DP._disjunct_cut_coefficients( - Val(master.objective_sense)) + penalty_sign = DP._penalty_sign(Val(master.objective_sense)) variable_type = InfiniteOpt.GeneralVariableRef reform_set = DP.is_gdp_model(model) ? Set(DP._reformulation_constraints(model)) : Set() @@ -1033,8 +943,7 @@ function DP.add_disjunct_oa_cuts( result::NamedTuple, method::DP.LOA ) - sign_factor, penalty_sign = DP._disjunct_cut_coefficients( - Val(master.objective_sense)) + penalty_sign = DP._penalty_sign(Val(master.objective_sense)) transcribed_to_master = Ref{Any}(nothing) transcribed_xk = Ref{Any}(nothing) ensure_transcribed = function () @@ -1047,8 +956,7 @@ function DP.add_disjunct_oa_cuts( return end for (indicator, active) in result.combination - DP.any_active(active) || continue - haskey(master.binary_map, indicator) || continue + DP.is_active(active) || continue haskey(DP._indicator_to_constraints(model), indicator) || continue for orig_constraint_ref in @@ -1057,8 +965,6 @@ function DP.add_disjunct_oa_cuts( continue constraint = DP._disjunct_constraints(model)[ JuMP.index(orig_constraint_ref)].constraint - dual = get(result.duals, orig_constraint_ref, nothing) - dual === nothing && continue if _has_aggregate_ref(constraint.func) ensure_transcribed() transcribed_func = @@ -1066,42 +972,40 @@ function DP.add_disjunct_oa_cuts( constraint.func) transcribed_constraint = JuMP.ScalarConstraint( transcribed_func, constraint.set) - for (binary_ref, _, _, dual_value) in DP.cut_info( + for (binary_ref, _, _) in _infinite_cut_info( master.binary_map[indicator], active, - transcribed_constraint, - result.linearization_point, - master.variable_map, dual) + transcribed_func, result.linearization_point, + master.variable_map) DP._add_oa_cut_for_constraint( transcribed_constraint, master, binary_ref, transcribed_xk[], transcribed_to_master[], - dual_value, method, sign_factor, - penalty_sign, result.feasible) + method, penalty_sign) end continue end - for (binary_ref, linearization_point, var_map, - dual_value) in DP.cut_info( - master.binary_map[indicator], active, constraint, - result.linearization_point, - master.variable_map, dual) + for (binary_ref, linearization_point, var_map) in + _infinite_cut_info( + master.binary_map[indicator], active, + constraint.func, result.linearization_point, + master.variable_map) DP._add_oa_cut_for_constraint( constraint, master, binary_ref, - linearization_point, var_map, dual_value, - method, sign_factor, penalty_sign, - result.feasible) + linearization_point, var_map, method, penalty_sign) end end end end -# Apply per-indicator fixes for `combination` and return a closure -# that reverses them. Scalar (`Bool`) values delegate to base -# `fix_indicator` (which unwraps complement-form `1 - y` AffExprs to -# the underlying binary and inverts the target). Per-support -# `AbstractVector{Bool}` values fix each support via a point-equality -# constraint on the underlying infinite var. State lives in the -# closure — no `model.ext` stash. Used by base `with_fixed_combination` -# and `commit_combination`. +# Apply per-indicator fixes for `combination` and return a closure that +# reverses them. A scalar (`Bool`) value delegates to base +# `fix_indicator` (force-fix, which unwraps complement-form `1 - y` +# AffExprs). A per-support `AbstractVector{Bool}` value pins each support +# of the underlying infinite binary with a point-equality constraint. +# The returned closure deletes those constraints and unfixes, so the +# extension's `with_fixed_combination` can tear the fix down each +# iteration — a stale per-support pin would clash with the next +# combination. `commit_combination` ignores the closure, persisting the +# committed fix. function DP.fix_combination( model::InfiniteOpt.InfiniteModel, combination::AbstractDict ) @@ -1135,20 +1039,44 @@ function DP.fix_combination( end end -# Per-support binary pin on an NLPF copy of an `InfiniteModel`. -# Triggered when the combination value is `AbstractVector{Bool}` — -# i.e., the indicator is itself infinite, so each support k must be -# pinned independently via a point-equality `binary(t_k) == value[k]`. -# Finite indicators on an `InfiniteModel` dispatch to the base scalar -# `JuMP.fix` path because `combination_val` returns a scalar `Bool`. -# Complement-form binaries are handled by base recursion before this -# dispatch fires. -function DP._nlpf_fix_on_copy( - copy::InfiniteOpt.InfiniteModel, - binary::InfiniteOpt.GeneralVariableRef, - value::AbstractVector{Bool} +# OVERRIDE for InfiniteModel: fix the combination, run `f()`, then +# always undo. The finite base fixes in place and needs no teardown, but +# the infinite path pins per-support combinations with point-equality +# constraints (and scalar seeds with `JuMP.fix`) that must be cleared +# between iterations — a stale per-support pin would clash with the next +# combination — so this lifecycle needs the `try/finally` the base +# omits. The model is relaxed once in `reformulate_model`, so there is +# no relax/unrelax here. +function DP.with_fixed_combination( + f, model::InfiniteOpt.InfiniteModel, combination::AbstractDict + ) + undo = DP.fix_combination(model, combination) + try + return f() + finally + undo() + end +end + +# Pin a copy-side binary on an NLPF copy of an `InfiniteModel`. The +# infinite `with_fixed_combination` undoes the original's fix before the +# NLPF fall-through, so the copy arrives unfixed and is pinned here. The +# copy is discarded after the solve, so a point-equality constraint +# needs no teardown; using constraints rather than `JuMP.fix` avoids +# force-deleting bounds on the relaxed copy (whose bound refs may not +# survive `copy_model`). +function DP.nlpf_fix_on_copy( + copy::InfiniteOpt.InfiniteModel, binary, value::Bool + ) + JuMP.@constraint(copy, binary == (value ? 1.0 : 0.0)) + return +end +function DP.nlpf_fix_on_copy( + copy::InfiniteOpt.InfiniteModel, binary, value::AbstractVector{Bool} ) - for (k, support) in enumerate(_supports_of(binary)) + underlying = binary isa JuMP.GenericAffExpr ? + only(keys(binary.terms)) : binary + for (k, support) in enumerate(_supports_of(underlying)) JuMP.@constraint(copy, _at_support(binary, support) == (value[k] ? 1.0 : 0.0)) end diff --git a/src/loa.jl b/src/loa.jl index e1ce3c96..f03dcdb7 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -8,8 +8,8 @@ # Infeasible primary NLPs fall through to NLPF (V&G 1990 eq. 8): a # slacked feasibility version of the same problem whose primal becomes # the linearization site for OA cuts. A no-good cut still forbids that -# combination on the master. NLPF is bypassed for per-support -# Vector{Bool} combinations (InfiniteOpt per-support master) — those +# combination on the master. NLPF is bypassed for vector-valued +# `Vector{Bool}` combinations (which an extension may supply) — those # fall back to no-good-only. ################################################################################ @@ -34,12 +34,19 @@ supported. Other reformulations (`Hull`, `PSplit`) are not yet supported. - `inner_method::R`: Reformulation applied to the primary NLP — `BigM` or `MBM`. - `max_iter::Int`: Maximum LOA iterations after set-covering seeding. -- `atol::T`, `rtol::T`: Absolute / relative gap tolerances for - convergence. - `M_value::T`: Big-M used in the disjunct OA cut gating term. - `max_slack::T`: Upper bound for each per-cut slack variable. - `oa_penalty::T`: V&G 1990 penalty coefficient applied to slacks in the master objective. +- `convergence_tol::Float64`: Relative gap tolerance for the early + stop. The main loop exits once the master bound meets the incumbent + within this relative gap, provided the slacks also pass `slack_tol`. +- `slack_tol::Float64`: Largest total OA-cut slack (summed slack + variables) for which the master bound still counts as a valid + convergence certificate. Positive slack means the master is + violating its own cuts (the nonconvex case), so the bound is not + trustworthy and the loop keeps running to `max_iter` rather than + stopping on a spurious crossing. - `iteration_time_limit::Float64`: Wall-clock budget (seconds) for the LOA iteration loop (seeds + main loop). `Inf` (default) is no limit; each subproblem solve is capped to the budget left so one solve can't @@ -53,25 +60,23 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod mip_optimizer::P inner_method::R max_iter::Int - atol::T - rtol::T M_value::T max_slack::T oa_penalty::T - verbose::Bool + convergence_tol::Float64 + slack_tol::Float64 iteration_time_limit::Float64 time_limit::Float64 function LOA( nlp_optimizer::O; mip_optimizer::P = nlp_optimizer, max_iter::Int = 10, - atol::T = 1e-6, - rtol::T = 1e-4, M_value::T = 1e9, max_slack::T = 1e3, oa_penalty::T = 1e3, inner_method::R = BigM(M_value), - verbose::Bool = false, + convergence_tol::Float64 = 1e-6, + slack_tol::Float64 = 1e-4, iteration_time_limit::Float64 = Inf, time_limit::Float64 = Inf ) where {O, P, R <: AbstractReformulationMethod, T} @@ -79,7 +84,8 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod "LOA inner_method must be BigM or MBM (got $R). " * "Hull and PSplit are not yet supported.") new{O, P, R, T}(nlp_optimizer, mip_optimizer, inner_method, - max_iter, atol, rtol, M_value, max_slack, oa_penalty, verbose, + max_iter, M_value, max_slack, oa_penalty, + convergence_tol, slack_tol, iteration_time_limit, time_limit) end end @@ -93,10 +99,9 @@ end # # Bundles the LOA master MILP and the maps the algorithm needs to # translate original-model refs into master-model refs. -# `objective_ref_map` is split from `variable_map` because the -# InfiniteOpt aggregate-objective path uses a transcribed- -# `JuMP.VariableRef`-keyed map for the objective only, while constraint -# linearization uses the InfiniteOpt-keyed `variable_map`. +# `objective_ref_map` is split from `variable_map` so an extension can +# map the objective's linearization variables differently from the +# constraint ones; in base the two maps are identical. mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM} model::M binary_map::BM @@ -105,43 +110,42 @@ mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM} original_objective::OF alpha_oa::AO objective_ref_map::RM - # All penalized slacks ever appended to the master, in emission - # order — disjunct, global, and objective cuts. Iterated by - # `_check_slacks` after each master solve to diagnose whether - # `max_slack` is binding. - slacks::Vector{Any} end ################################################################################ # SENSE PRIMITIVES ################################################################################ # Val(MIN/MAX)-dispatched primitives so the algorithm reads sense-agnostic. -_disjunct_cut_coefficients(::Val{_MOI.MIN_SENSE}) = (-1, 1) -_disjunct_cut_coefficients(::Val{_MOI.MAX_SENSE}) = (1, -1) +_penalty_sign(::Val{_MOI.MIN_SENSE}) = 1 +_penalty_sign(::Val{_MOI.MAX_SENSE}) = -1 _worst_objective(::Val{_MOI.MIN_SENSE}) = Inf _worst_objective(::Val{_MOI.MAX_SENSE}) = -Inf _is_better(::Val{_MOI.MIN_SENSE}, new, best) = new < best _is_better(::Val{_MOI.MAX_SENSE}, new, best) = new > best _gap(::Val{_MOI.MIN_SENSE}, best, bound) = best - bound _gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best -_flip_sense(::Val{_MOI.MIN_SENSE}) = Val(_MOI.MAX_SENSE) -_flip_sense(::Val{_MOI.MAX_SENSE}) = Val(_MOI.MIN_SENSE) ################################################################################ # MAIN ALGORITHM ################################################################################ # LOA reformulation entry point: set-covering seeds, the master/NLP # iteration loop, commit the best combination, and mark the model ready. -# Plain `LOA` on an `InfiniteModel` dispatches here too — the InfiniteOpt -# overrides act on the inner solve steps (master build, NLP, cuts), not -# on this driver. +# This driver is model-agnostic — extensions override the inner solve +# steps (master build, NLP, cuts), not this loop. function reformulate_model(model::JuMP.AbstractModel, method::LOA) _clear_reformulations(model) combinations = _set_covering_combinations(model) reformulate_model(model, method.inner_method) master = build_loa_master(model, method) - reformulation_map = _build_reformulation_map(model) + # Relax the original model's logical binaries once, permanently. The + # original model is only ever solved as the NLP (never as a MILP — + # the master is a separate copy), so its ZeroOne is never used. + # Stripping it here lets a pure NLP solver handle every solve and + # makes per-iteration fixing overwrite in place (no relax/unrelax, + # no fix/undo). Must follow build_loa_master so the master keeps its + # ZeroOne binaries. + relax_logical_vars(model) JuMP.set_optimizer(model, method.nlp_optimizer) JuMP.set_silent(model) t_start = time() @@ -152,6 +156,7 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) sense_token = Val(JuMP.objective_sense(model)) best_objective = _worst_objective(sense_token) best_result = nothing + master_bound = nothing # Initialization Procedure (Türkay & Grossmann 1996, sec. 2.2): # solve K set-covering NLPs with cycling indicator combinations to @@ -160,75 +165,62 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) # forward as the warm start for the next NLP (T&G §2.3); NLPF # solutions are skipped since their primal is slack-distorted. previous_result = nothing - for (i, combination) in enumerate(combinations) + for combination in combinations time() < loop_deadline || break _set_nlp_warm_start(previous_result) - t_seed = @elapsed result = _solve_nlp( - model, combination, method, reformulation_map; + result = _solve_nlp(model, combination, method; deadline = loop_deadline) avoid_combination(master.model, combination, master.binary_map) add_oa_cuts(model, master, result, method) - _check_penalty(result, method) - if result.feasible && _is_better(sense_token, result.objective, - best_objective) + if result.feasible && + _is_better(sense_token, result.objective, best_objective) best_objective = result.objective best_result = result end result.feasible && (previous_result = result) - method.verbose && _log_seed(i, t_seed, result, best_objective) end - # Master bound is a rigorous dual bound only for a convex (linear) - # inner problem; otherwise stop on V&G (1990) criterion 5 - # (`_nlp_stalled`), not the untrustworthy gap. - rigorous = !_has_nonlinear_constraints(model) - produced_bound = false - prev_nlp_objective = previous_result === nothing ? nothing : - previous_result.objective - for iter in 1:method.max_iter + # Master/NLP loop. Each iteration solves the master MILP for a lower + # bound (`master_bound` is the `alpha_oa` auxiliary, NOT the penalized + # objective the slacks inflate), then solves the NLP at the master's + # combination and updates the incumbent. The loop exits on whichever + # comes first: convergence (the bound meets the incumbent within + # `convergence_tol` while total slack is below `slack_tol`), an + # infeasible master (every combination forbidden by a no-good cut), + # `max_iter`, or the time budget. The slack gate is what makes the + # convergence stop safe without a convexity flag: on a convex inner + # problem the slacks collapse to zero and the crossing is a real + # optimality proof; on a nonconvex one the master pays slack to + # violate its own cuts, the gate stays shut, and the loop runs on to + # `max_iter` instead of stopping at a spurious crossing. + converged = false + for _ in 1:method.max_iter time() < loop_deadline || break _cap_remaining_time(master.model, loop_deadline) - t_master = @elapsed JuMP.optimize!(master.model) - feasible = JuMP.is_solved_and_feasible(master.model) - method.verbose && _log_master(iter, master.model, t_master) - if !feasible - _check_degenerate_master(master, produced_bound, - best_objective) - break - end - _check_slacks(master, method) - master_bound = JuMP.objective_value(master.model) - produced_bound = true - method.verbose && - _log_iter(iter, master_bound, best_objective, sense_token) - if rigorous - _loa_converged( - best_objective, master_bound, sense_token, method) && break - else - _check_nonrigorous_bound( - best_objective, master_bound, sense_token, method) + JuMP.optimize!(master.model) + JuMP.is_solved_and_feasible(master.model) || break + master_bound = JuMP.value(master.alpha_oa) + if best_result !== nothing + gap = _gap(sense_token, best_objective, master_bound) + total_slack = abs(JuMP.objective_value(master.model) - + master_bound) / method.oa_penalty + tol = method.convergence_tol * max(abs(best_objective), 1.0) + if gap <= tol && total_slack <= method.slack_tol + converged = true + break + end end combination = _extract_combination(model, master) _set_nlp_warm_start(previous_result) - t_nlp = @elapsed result = _solve_nlp( - model, combination, method, reformulation_map; + result = _solve_nlp(model, combination, method; deadline = loop_deadline) avoid_combination(master.model, combination, master.binary_map) add_oa_cuts(model, master, result, method) - _check_penalty(result, method) - if result.feasible && _is_better(sense_token, result.objective, - best_objective) + if result.feasible && + _is_better(sense_token, result.objective, best_objective) best_objective = result.objective best_result = result end - method.verbose && _log_nlp(iter, t_nlp, result, best_objective) - # V&G (1990) criterion 5: with no rigorous bound, stop once a - # feasible NLP stops improving on the previous feasible NLP. - if !rigorous && result.feasible - _nlp_stalled(sense_token, result.objective, - prev_nlp_objective) && break - prev_nlp_objective = result.objective - end result.feasible && (previous_result = result) end @@ -243,80 +235,47 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) # Loop-only budget: restore so the final solve isn't crippled. _restore_time_limit(model, original_time_limit) end + _report_loa_gap(best_objective, best_result, master_bound, sense_token, + converged) _set_solution_method(model, method) _set_ready_to_optimize(model, true) return end -# `verbose = true` trace helpers. Print to stdout so a `tee`-d log -# captures them; structured key=value so they're easy to grep. -function _log_seed(i::Int, t::Real, result::NamedTuple, best::Real) - println("LOA_SEED ", i, ": time=", round(t, digits = 3), - "s feasible=", result.feasible, " obj=", result.objective, - " best=", best) - flush(stdout) -end -function _log_master(iter::Int, master::JuMP.AbstractModel, t::Real) - println("LOA_MASTER iter=", iter, - " status=", JuMP.termination_status(master), - " feasible=", JuMP.is_solved_and_feasible(master), - " time=", round(t, digits = 3), "s") - flush(stdout) -end -function _log_iter(iter::Int, lb::Real, ub::Real, sense_token::Val) - println("LOA_ITER ", iter, ": LB=", lb, " UB=", ub, - " gap=", _gap(sense_token, ub, lb)) - flush(stdout) -end -function _log_nlp(iter::Int, t::Real, result::NamedTuple, best::Real) - println("LOA_NLP iter=", iter, - " time=", round(t, digits = 3), "s feasible=", result.feasible, - " obj=", result.objective, " best=", best) - flush(stdout) -end - -# Convergence: absolute gap ≤ atol or relative gap ≤ rtol. Distinct -# from CP's `separation_obj ≤ tolerance` check (different convergence -# shapes — gap vs. single-threshold), so not shared. -function _loa_converged( - best_objective::Real, - master_bound::Real, - sense_token::Val, - method::LOA - ) - (isinf(best_objective) || isinf(master_bound)) && return false - gap = _gap(sense_token, best_objective, master_bound) - gap <= method.atol && return true - abs(best_objective) > 1e-10 && - gap / abs(best_objective) <= method.rtol && return true - return false -end - -# A genuine bound crossing: the master "bound" is worse than the -# incumbent by more than the convergence tolerance (gap strongly -# negative). In valid convex OA, LB ≤ UB always, so this can only -# arise from invalid OA cuts on a nonconvex problem — V&G (1990) report -# the master objective exceeding the integer NLP optimum on every -# nonconvex test problem. The tolerance band separates a real crossing -# from floating-point / solver noise. -function _bound_crossed( +# Report the final gap once the loop ends. `[converged]` = the loop hit +# its convergence test (the master bound met the incumbent within +# `convergence_tol` with total slack below `slack_tol`); `[limit hit]` = +# it stopped on `max_iter`, the time budget, or an exhausted master with +# a gap still open; `[no bound]` = the master never produced a bound +# (fell back to the best seed). `master_bound` is `alpha_oa`, a rigorous +# dual bound only for a convex (linear) inner problem; on a nonconvex +# model it can cross the incumbent, which the slack gate is meant to +# catch and the reported gap surfaces. +function _report_loa_gap( best_objective::Real, - master_bound::Real, + best_result, + master_bound, sense_token::Val, - method::LOA + converged::Bool ) - (isinf(best_objective) || isinf(master_bound)) && return false + if best_result === nothing + @warn "LOA finished: no feasible incumbent found." + return + end + if master_bound === nothing + @info "LOA finished [no bound]: incumbent $best_objective " * + "(master produced no bound; best seed kept)." + return + end gap = _gap(sense_token, best_objective, master_bound) - return gap < -(method.atol + method.rtol * abs(best_objective)) + relative = abs(best_objective) > 1e-10 ? + gap / abs(best_objective) : gap + label = converged ? "converged" : "limit hit" + @info "LOA finished [$label]: incumbent $best_objective, master " * + "bound $master_bound, gap $gap (relative $relative)." + return end -# V&G (1990) criterion 5: a feasible NLP that does not improve on the -# previous feasible NLP signals the heuristic has converged. The first -# feasible NLP (`nothing` previous) is never stalled. -_nlp_stalled(sense_token::Val, objective::Real, previous::Real) = - !_is_better(sense_token, objective, previous) -_nlp_stalled(::Val, ::Real, ::Nothing) = false - # Error fallback for unsupported model types. function reformulate_model(::M, ::LOA) where {M} error("reformulate_model not implemented for model type `$(M)` with LOA.") @@ -364,22 +323,6 @@ _is_linear_F(::Type{<:AbstractVector{<:JuMP.AbstractVariableRef}}) = true _is_linear_F(::Type{<:AbstractVector{<:JuMP.GenericAffExpr}}) = true _is_linear_F(::Type) = false -# True if any constraint carries a non-affine function. LOA's master -# objective is a rigorous lower bound only when every constraint is -# linear: the inner problem is then a (linear, hence convex) GDP and -# the OA cuts are exact supporting hyperplanes. With a nonlinear -# constraint the problem may be nonconvex, the OA linearizations need -# not support the feasible set, and the master bound is not rigorous — -# so LOA terminates on V&G (1990) subproblem-improvement instead. -function _has_nonlinear_constraints(model::JuMP.AbstractModel) - variable_type = JuMP.variable_ref_type(typeof(model)) - for (F, _) in JuMP.list_of_constraint_types(model) - F === variable_type && continue - _is_linear_F(F) || return true - end - return false -end - # OVERRIDABLE. Build the LOA master MILP `M^b_{LA}` (Türkay & Grossmann # 1996, eq. 12): copy decision variables and only the linear # constraints, install `alpha_oa` as the objective auxiliary. Nonlinear @@ -417,7 +360,7 @@ function build_loa_master(model::JuMP.AbstractModel, method::LOA) JuMP.@objective(master, objective_sense, alpha_oa) return _LOAMaster(master, binary_map, variable_map, objective_sense, - original_objective, alpha_oa, variable_map, Any[]) + original_objective, alpha_oa, variable_map) end ################################################################################ @@ -441,14 +384,15 @@ _restore_time_limit(model::JuMP.AbstractModel, seconds::Real) = JuMP.set_time_limit_sec(model, seconds) # Solve the primary NLP for a fixed combination. If feasible, read the -# primal point, duals, and objective. If infeasible, fall through to -# the NLPF (V&G 1990 eq. 8) approximation: a slacked version of the -# same problem that always solves, whose primal becomes the -# linearization site for OA cuts. The master still learns shape -# information from the failed combination instead of only adding a -# no-good cut. +# primal point and objective. If infeasible, fall through to the NLPF +# (V&G 1990 eq. 8) approximation: a slacked version of the same problem +# that always solves, whose primal becomes the linearization site for +# OA cuts. The master still learns shape information from the failed +# combination instead of only adding a no-good cut. function _solve_nlp( - model::M, combination, method::LOA, reformulation_map; + model::M, + combination, + method::LOA; deadline::Float64 = Inf ) where {M <: JuMP.AbstractModel} _cap_remaining_time(model, deadline) @@ -456,11 +400,9 @@ function _solve_nlp( JuMP.optimize!(model, ignore_optimize_hook = true) if JuMP.is_solved_and_feasible(model) lin_point = extract_solution(model) - duals = _collect_nlp_duals( - model, combination, reformulation_map) objective_val = JuMP.objective_value(model) return (combination = combination, - linearization_point = lin_point, duals = duals, + linearization_point = lin_point, objective = objective_val, feasible = true) end return nothing @@ -473,7 +415,6 @@ function _solve_nlp( return (combination = combination, linearization_point = Dict{JuMP.AbstractVariableRef, Any}(), - duals = Dict{DisjunctConstraintRef{M}, Any}(), objective = Inf, feasible = false) end @@ -485,13 +426,13 @@ end # single nonnegative slack `u`, minimize `u`, and return the resulting # primal point as an approximate linearization site. The point # satisfies equalities and variable bounds exactly; inequalities can -# be violated by at most `u`. The "duals" returned are sign-only — the -# OA cut emitter uses `sign(dual)` to pick a direction, and for a -# slacked feasibility solve we know each active constraint's -# linearization is informative in the standard direction. +# be violated by at most `u`. function _solve_nlpf( model::M, combination, method::LOA; deadline::Float64 = Inf ) where {M <: JuMP.AbstractModel} + # The original model's logical binaries are permanently relaxed + # (see reformulate_model), so this copy is continuous — any NLP + # solver, including pure-NLP ones like Ipopt, can solve NLPF. copy, ref_map = JuMP.copy_model(model) JuMP.set_optimizer(copy, method.nlp_optimizer) JuMP.set_silent(copy) @@ -513,30 +454,24 @@ function _solve_nlpf( JuMP.@objective(copy, Min, u) - # Translate each original-model indicator binary to its - # counterpart on the copy, then pin it to the combination value. - # `_nlpf_fix_on_copy` dispatches on value type: scalar `Bool` - # paths use `JuMP.fix`; per-support `AbstractVector{Bool}` paths - # require an `InfiniteModel`-side override (per-support point- - # equality) — see `ext/InfiniteDisjunctiveProgramming.jl`. + # Pin the copy's indicator binaries to the combination. The infinite + # `with_fixed_combination` undoes the original's fix after each + # solve, so this NLPF copy arrives unfixed and its `nlpf_fix_on_copy` + # override re-pins it. The finite path leaves the original fixed in + # place, so the copy inherits the fix and the base `nlpf_fix_on_copy` + # is a no-op. for (indicator, value) in combination binary = _binary_on_copy( _indicator_to_binary(model)[indicator], ref_map) - _nlpf_fix_on_copy(copy, binary, value) + nlpf_fix_on_copy(copy, binary, value) end - try - JuMP.optimize!(copy, ignore_optimize_hook = true) - catch - return nothing - end + JuMP.optimize!(copy, ignore_optimize_hook = true) JuMP.has_values(copy) || return nothing linearization_point = _nlpf_extract_primal(model, ref_map) - duals = _nlpf_sign_duals(model, combination) return (combination = combination, linearization_point = linearization_point, - duals = duals, objective = Inf, feasible = false) end @@ -545,40 +480,19 @@ end # complement-form `1 - y_orig` rebuilds as `1 - ref_map[y_orig]`. _binary_on_copy(binary::JuMP.AbstractVariableRef, ref_map) = ref_map[binary] -function _binary_on_copy( - binary::JuMP.GenericAffExpr, ref_map - ) +function _binary_on_copy(binary::JuMP.GenericAffExpr, ref_map) underlying = only(keys(binary.terms)) return 1.0 - ref_map[underlying] end -# Pin a copy-side binary to a combination value. Plain `JuMP.fix` -# (no `force = true`) — `force` triggers `delete_upper_bound` which -# fails on InfiniteOpt copies whose ZeroOne constraint refs didn't -# survive `JuMP.copy_model`. The fix pins the value to 0/1 which -# satisfies the ZeroOne anyway. Solvers that can't handle binaries -# (Ipopt) will error out — the caller catches and returns `nothing` -# so the iteration falls back to no-good-only behavior. -function _nlpf_fix_on_copy( - copy, binary::JuMP.AbstractVariableRef, value::Bool - ) - JuMP.is_fixed(binary) && JuMP.unfix(binary) - JuMP.fix(binary, value ? 1.0 : 0.0) - return -end -# Complement form `1 - y_underlying`: indicator=value means -# underlying=!value. Recursion routes both scalar `Bool` and per- -# support `AbstractVector{Bool}` to the underlying-binary dispatch. -function _nlpf_fix_on_copy( - copy, binary::JuMP.GenericAffExpr, value - ) - underlying = only(keys(binary.terms)) - _nlpf_fix_on_copy(copy, underlying, _flip(value)) - return -end - -_flip(v::Bool) = !v -_flip(v::AbstractVector{Bool}) = .!v +# OVERRIDABLE. Pin a copy-side binary to a combination value. Base +# no-op: the finite original stays fixed in place (its +# `with_fixed_combination` does not undo), so its NLPF copy inherits the +# fix. The InfiniteOpt extension overrides this — its original is +# cleaned up each iteration, so the copy needs pinning — using +# constraints rather than `JuMP.fix` to avoid force-deleting bounds on +# the relaxed copy. +nlpf_fix_on_copy(copy, binary, value) = nothing _nlpf_should_slack(::Type{<:_MOI.LessThan}) = true _nlpf_should_slack(::Type{<:_MOI.GreaterThan}) = true @@ -588,75 +502,35 @@ _nlpf_slacked_func(func, u, ::_MOI.LessThan) = func - u _nlpf_slacked_func(func, u, ::_MOI.GreaterThan) = func + u # Read primal values from `copy` keyed by the original model's -# variables, in the same per-support shape `extract_solution` would -# have produced on the original. -function _nlpf_extract_primal( - model::JuMP.AbstractModel, ref_map - ) +# variables, in the same vector shape `extract_solution` would have +# produced on the original. +function _nlpf_extract_primal(model::JuMP.AbstractModel, ref_map) V = JuMP.variable_ref_type(typeof(model)) T = JuMP.value_type(typeof(model)) result = Dict{V, Vector{T}}() for v in collect_all_vars(model) JuMP.is_fixed(v) && continue - target = try - ref_map[v] - catch - continue # var not in the copy's ref_map (e.g., parameter only) - end + target = ref_map[v] val = JuMP.value(target) result[v] = val isa AbstractArray ? vec(val) : [val] end return result end -# Sign-only "duals" on active disjunct constraints — the OA cut -# emitter only needs `sign(dual)` to pick the linearization direction, -# and for a slacked-feasibility solve each active constraint's -# linearization is meaningful in its standard direction. -function _nlpf_sign_duals( - model::M, combination::AbstractDict - ) where {M <: JuMP.AbstractModel} - duals = Dict{DisjunctConstraintRef{M}, Any}() - for (indicator, active) in combination - any_active(active) || continue - haskey(_indicator_to_constraints(model), indicator) || continue - for cref in _indicator_to_constraints(model)[indicator] - cref isa DisjunctConstraintRef || continue - con = _disjunct_constraints(model)[ - JuMP.index(cref)].constraint - duals[cref] = _nlpf_dual_sign(con.set) - end - end - return duals -end - -_nlpf_dual_sign(::_MOI.LessThan) = 1.0 -_nlpf_dual_sign(::_MOI.GreaterThan) = -1.0 -_nlpf_dual_sign(::_MOI.EqualTo) = [1.0, 1.0] -_nlpf_dual_sign(::_MOI.Interval) = [1.0, 1.0] -_nlpf_dual_sign(s::_MOI.Nonpositives) = ones(_MOI.dimension(s)) -_nlpf_dual_sign(s::_MOI.Nonnegatives) = -ones(_MOI.dimension(s)) -_nlpf_dual_sign(s::_MOI.Zeros) = ones(_MOI.dimension(s)) -_nlpf_dual_sign(::Any) = 0.0 - -# Fix `combination`, run `f()`, unfix. Logical binaries are relaxed -# from ZeroOne for the duration so a pure NLP solver (Ipopt) can -# handle the inner solve; restored on exit. Extensions override -# `fix_combination` to handle per-support fixing without needing -# their own try/finally lifecycle. +# Fix `combination` in place, then run `f()`. No relax/restore (the +# model's logical binaries are relaxed once, permanently, in +# reformulate_model) and no fix/undo: `fix_combination` overwrites in +# place, so there is no per-iteration state to unwind — hence no +# try/finally. On a solve error the model is left fixed to the last +# combination; the next `fix_combination` (or `commit_combination`) +# overwrites it. function with_fixed_combination( f, model::JuMP.AbstractModel, combination::AbstractDict ) - relaxed = relax_logical_vars(model) - undo = fix_combination(model, combination) - try - return f() - finally - undo() - unrelax_logical_vars(relaxed) - end + fix_combination(model, combination) + return f() end # Iter-to-iter NLP warm start (T&G 1996 §2.3): seed the next primary @@ -665,8 +539,8 @@ end # no-op on iterations following an NLPF fall-through (caller does not # update `previous_result` then), since NLPF's primal is slack- # distorted and a worse start than the prior real NLP. Routes through -# the `set_linearization_start` dispatch so the InfiniteOpt extension's -# per-support broadcast handles `GeneralVariableRef` correctly. +# the `set_linearization_start` dispatch so an extension can broadcast +# a vector-valued start across its variables. function _set_nlp_warm_start(previous) previous === nothing && return for (variable, values) in previous.linearization_point @@ -675,18 +549,16 @@ function _set_nlp_warm_start(previous) return end -# Finalize the model with the LOA-optimal combination: relax logical -# binaries (stripping ZeroOne so a pure NLP solver can handle the -# post-hook `JuMP.optimize!`), fix indicators at the committed values, -# and warm-start from the linearization point. After this, the model -# is no longer in a state suitable for re-running with a different -# `gdp_method`. +# Finalize the model with the LOA-optimal combination: fix indicators +# at the committed values (in place; the model is already permanently +# relaxed by reformulate_model) and warm-start from the linearization +# point. After this, the model is no longer in a state suitable for +# re-running with a different `gdp_method`. function commit_combination( model::JuMP.AbstractModel, combination::AbstractDict, linearization_point::AbstractDict ) - relax_logical_vars(model) fix_combination(model, combination) for (variable, values) in linearization_point set_linearization_start(variable, values) @@ -694,197 +566,40 @@ function commit_combination( return end -# OVERRIDABLE. Apply the combination's fixes and return a closure that -# undoes them. Base loops `fix_indicator`/`unfix_indicator`. The -# InfiniteOpt extension overrides this to handle per-support -# `Vector{Bool}` values via point-equality constraints, keeping all -# cleanup state captured in the returned closure. -function fix_combination( - model::JuMP.AbstractModel, combination::AbstractDict - ) +# OVERRIDABLE. Apply the combination's fixes in place. No undo closure: +# `fix_indicator` uses `force = true` so each call overwrites the prior +# fix, and the model is permanently relaxed, so there is nothing to +# restore. An extension overrides this for vector-valued `Vector{Bool}` +# values (per-support pins reused in place across iterations). +function fix_combination(model::JuMP.AbstractModel, combination::AbstractDict) for (indicator, value) in combination fix_indicator(model, indicator, value) end - return function () - for (indicator, _) in combination - unfix_indicator(model, indicator) - end - end + return end -# OVERRIDABLE. Write the LOA linearization point into a variable's -# warm start. `values` is always per-support shape (length-1 vector -# for finite vars); base unwraps. The InfiniteOpt extension overrides -# for `GeneralVariableRef` to broadcast across transcribed supports. +# OVERRIDABLE. Write the LOA linearization point into a variable's warm +# start. This is deliberately NOT a direct `JuMP.set_start_value` call. +# The linearization point is stored per-variable as a vector — length-1 +# for a scalar/finite variable, length-K for a variable carrying K +# support points (see `extract_solution`). For an InfiniteOpt infinite +# variable that per-support vector must be written to each of the K +# *transcribed* JuMP variables (`transformation_variable(v)` is an array +# of per-support refs); `JuMP.set_start_value` on the single infinite +# `GeneralVariableRef` cannot place a distinct start at each support. +# Base handles the scalar case by unwrapping `only(values)`; the +# extension overrides this seam to broadcast the vector across the +# transcribed instances. Hence the dispatch rather than a bare +# `set_start_value`. set_linearization_start(variable, values::AbstractVector) = JuMP.set_start_value(variable, only(values)) -################################################################################ -# BIGM DUAL COLLECTION -################################################################################ -# Build a map `DisjunctConstraintRef → Vector{}` so -# `_collect_nlp_duals` can sum BigM duals per original disjunct -# constraint. Uses `_num_reform_constraints` to slice the flat -# reformulation-constraint list per original. (Brittle: assumes BigM -# emits constraints in disjunction-iteration order; a cleaner -# alternative would be to have BigM record this map directly.) -function _build_reformulation_map(model::M) where {M <: JuMP.AbstractModel} - ref_cons = _reformulation_constraints(model) - isempty(ref_cons) && return Dict{DisjunctConstraintRef{M}, Vector{Any}}() - CRT = eltype(ref_cons) - rmap = Dict{DisjunctConstraintRef{M}, Vector{CRT}}() - cursor = 1 - for (_, disjunction_data) in _disjunctions(model) - for indicator in disjunction_data.constraint.indicators - haskey(_indicator_to_constraints(model), indicator) || continue - for constraint_ref in _indicator_to_constraints(model)[indicator] - constraint_ref isa DisjunctConstraintRef || continue - constraint = _disjunct_constraints(model)[ - JuMP.index(constraint_ref)].constraint - num_reform = _num_reform_constraints(constraint) - cursor + num_reform - 1 <= length(ref_cons) || break - rmap[constraint_ref] = collect( - ref_cons[cursor:cursor + num_reform - 1]) - cursor += num_reform - end - end - end - return rmap -end - -# Number of BigM-reformulated JuMP constraints per original constraint. -_num_reform_constraints(::JuMP.ScalarConstraint{T, S}) where { - T <: Union{Number, JuMP.AbstractJuMPScalar}, - S <: Union{_MOI.EqualTo, _MOI.Interval}} = 2 -_num_reform_constraints(::JuMP.ScalarConstraint) = 1 -_num_reform_constraints(constraint::JuMP.VectorConstraint{T, S}) where { - T <: Union{Number, JuMP.AbstractJuMPScalar}, - S <: _MOI.Zeros} = 2 * _MOI.dimension(constraint.set) -_num_reform_constraints(constraint::JuMP.VectorConstraint) = - _MOI.dimension(constraint.set) - -# Sum the duals of all reformulation constraints for one disjunct cref. -function _sum_duals( - reformulation_map::AbstractDict, - constraint_ref::DisjunctConstraintRef - ) - haskey(reformulation_map, constraint_ref) || return 0.0 - rcs = reformulation_map[constraint_ref] - isempty(rcs) && return 0.0 - total = JuMP.dual(rcs[1]) - for i in 2:length(rcs) - total = total .+ JuMP.dual(rcs[i]) - end - return total -end - -# Sum BigM-reformulation duals per active disjunct constraint ref. -function _collect_nlp_duals( - model::M, - combination::AbstractDict, - reformulation_map::AbstractDict - ) where {M <: JuMP.AbstractModel} - duals = Dict{DisjunctConstraintRef{M}, Any}() - JuMP.has_duals(model) || return duals - for (indicator, active) in combination - any_active(active) || continue - haskey(_indicator_to_constraints(model), indicator) || continue - for cref in _indicator_to_constraints(model)[indicator] - cref isa DisjunctConstraintRef || continue - duals[cref] = _sum_duals(reformulation_map, cref) - end - end - return duals -end - -################################################################################ -# DIAGNOSTICS -################################################################################ -# V&G 1990 requires ρ > ‖λ*‖_∞ for the penalty term to dominate any -# admissible Lagrange multiplier — otherwise a slack can absorb a real -# violation and the OA cut becomes an invalid relaxation. Check after -# every feasible primary NLP and warn if the bound is at risk. NLPF -# returns sign-only duals (`±1`) so this is a no-op when `!feasible`. -function _check_penalty(result::NamedTuple, method::LOA) - result.feasible || return - isempty(result.duals) && return - max_dual = 0.0 - for (_, d) in result.duals - max_dual = max(max_dual, _max_abs_dual(d)) - end - if max_dual >= method.oa_penalty - @warn "LOA: oa_penalty = $(method.oa_penalty) ≤ max |dual| " * - "= $max_dual. V&G 1990 requires ρ > ‖λ‖_∞; slacks may " * - "absorb real OA-cut violations. Raise `oa_penalty`." - end - return -end -_max_abs_dual(d::Number) = abs(d) -_max_abs_dual(d::AbstractArray) = isempty(d) ? 0.0 : maximum(abs, d) -_max_abs_dual(::Nothing) = 0.0 - -# `max_slack` is a numerical guard (V&G 1990 leaves slacks unbounded; -# in practice an unbounded slack lets the master trivially feasible- -# itself out of the OA constraints). When a slack is at the cap, the -# master is effectively solving a stricter problem than V&G's master. -# Diagnose so the user can raise `max_slack` if the cap binds often. -function _check_slacks(master::_LOAMaster, method::LOA) - JuMP.has_values(master.model) || return - isempty(master.slacks) && return - cap = method.max_slack - threshold = 0.99 * cap - max_value = 0.0 - binding = 0 - for slack in master.slacks - val = JuMP.value(slack) - max_value = max(max_value, val) - val >= threshold && (binding += 1) - end - if binding > 0 - @warn "LOA: $binding slack(s) at max_slack = $cap (max " * - "value $max_value). Master is solving a stricter problem " * - "than V&G's; raise `max_slack` if this persists." - end - return -end - -# A master that is infeasible/unbounded before producing any bound is a -# degenerate exit, not convergence: LOA falls back to the best -# set-covering seed without iterating, usually because most NLP -# subproblems are infeasible and the master lacks the OA cuts to bound -# `alpha_oa`. No-op once a bound has been produced. -function _check_degenerate_master( - master::_LOAMaster, produced_bound::Bool, best_objective::Real - ) - produced_bound && return - @warn "LOA: master returned " * - "$(JuMP.termination_status(master.model)) before any bound; " * - "returning the best seed incumbent ($best_objective). Not a " * - "certified optimum — usually most NLP subproblems are infeasible." - return -end - -# A master bound that crosses the incumbent (`_bound_crossed`) proves the -# OA cuts are not valid supports: the model is nonconvex and the master -# gives no rigorous dual bound. LOA continues as a V&G (1990) heuristic -# and returns the best NLP incumbent. -function _check_nonrigorous_bound( - best_objective::Real, master_bound::Real, sense_token::Val, method::LOA - ) - _bound_crossed(best_objective, master_bound, sense_token, method) || - return - @warn "LOA: master bound ($master_bound) crossed the incumbent " * - "($best_objective); model is nonconvex, dual bound is not " * - "rigorous. Returning the best NLP incumbent (heuristic, V&G 1990)." - return -end - ################################################################################ # COMBO EXTRACTION (master → NLP) ################################################################################ # Read each indicator's binary value from the master MILP solution. -# `combination_val` dispatches per binary type (scalar in base; -# extensions add per-support). +# `combination_val` returns a `Bool` (finite indicator) or per-support +# `Vector{Bool}` (infinite); downstream dispatches on that. function _extract_combination( model::M, master::_LOAMaster @@ -892,15 +607,24 @@ function _extract_combination( combination = Dict{LogicalVariableRef{M}, Any}() for (_, disjunction_data) in _disjunctions(model) for indicator in disjunction_data.constraint.indicators - haskey(master.binary_map, indicator) || continue - combination[indicator] = combination_val(master.binary_map[indicator]) + combination[indicator] = + combination_val(master.binary_map[indicator]) end end return combination end -# OVERRIDABLE. One-shot float→Bool conversion; downstream dispatches on Bool. -combination_val(binary_ref) = round(Bool, JuMP.value(binary_ref)) +# Read the master binary and round to `Bool`. Rounding is required (not +# defensive): a MILP solver returns binaries within its +# integer-feasibility tolerance — e.g. 2.75e-40 for a "0", 1.0000002 for +# a "1" — never exactly 0/1, so `Bool(value)` would throw `InexactError`. +# Not dispatched: the ref type is the same whether the indicator is +# finite or infinite; only `JuMP.value`'s return differs (scalar vs a +# per-support array), so we branch on the value and handle both here. +function combination_val(binary_ref) + val = JuMP.value(binary_ref) + return val isa AbstractArray ? vec(round.(Bool, val)) : round(Bool, val) +end ################################################################################ # OA CUT EMISSION @@ -929,9 +653,12 @@ end # master infeasible — the disjunct and global cuts already carry # slacks but the objective cut did not. function _add_objective_cut( - sense_token::Val, master::_LOAMaster, lin, method::LOA + sense_token::Val, + master::_LOAMaster, + lin, + method::LOA ) - _, penalty_sign = _disjunct_cut_coefficients(sense_token) + penalty_sign = _penalty_sign(sense_token) slack = _penalized_slack(master, method, penalty_sign) _add_objective_cut_body(sense_token, master, lin, slack) end @@ -950,17 +677,16 @@ _add_objective_cut_body(::Val{_MOI.MAX_SENSE}, master, lin, slack) = # constraints are passed through to `JuMP.@constraint` as-is — # valid for affine-after-linearization sets. # -# Finite-only. InfiniteOpt's `add_oa_cuts(::InfiniteModel, ...)` -# override uses `_add_global_oa_cuts_infinite`, which routes through -# transcription to handle per-support / aggregate-ref globals. +# Base (scalar-model) version. An extension that overrides `add_oa_cuts` +# supplies its own global-cut handling for constraints whose +# linearization is vector-valued or derived. function _add_global_oa_cuts( model::JuMP.AbstractModel, master::_LOAMaster, result::NamedTuple, method::LOA ) - _, penalty_sign = _disjunct_cut_coefficients( - Val(master.objective_sense)) + penalty_sign = _penalty_sign(Val(master.objective_sense)) variable_type = JuMP.variable_ref_type(typeof(model)) reform_set = is_gdp_model(model) ? Set(_reformulation_constraints(model)) : Set() @@ -990,26 +716,18 @@ function add_disjunct_oa_cuts( result::NamedTuple, method::LOA ) - sign_factor, penalty_sign = _disjunct_cut_coefficients( - Val(master.objective_sense)) + penalty_sign = _penalty_sign(Val(master.objective_sense)) for (indicator, active) in result.combination - any_active(active) || continue - haskey(master.binary_map, indicator) || continue + is_active(active) || continue haskey(_indicator_to_constraints(model), indicator) || continue for cref in _indicator_to_constraints(model)[indicator] cref isa DisjunctConstraintRef || continue constraint = _disjunct_constraints(model)[ JuMP.index(cref)].constraint - dual = get(result.duals, cref, nothing) - dual === nothing && continue - for (binary_ref, lin_point, var_map, dual_value) in cut_info( - master.binary_map[indicator], active, constraint, - result.linearization_point, master.variable_map, dual) - _add_oa_cut_for_constraint( - constraint, master, binary_ref, lin_point, - var_map, dual_value, method, sign_factor, - penalty_sign, result.feasible) - end + _add_oa_cut_for_constraint( + constraint, master, master.binary_map[indicator], + result.linearization_point, master.variable_map, + method, penalty_sign) end end end @@ -1019,14 +737,10 @@ end # so all three carry the same augmented-penalty treatment: a nonconvex # linearization can be an invalid relaxation, and the penalized slack # keeps the master feasible instead of letting accumulated cuts make -# it infeasible. The slack is recorded on `master.slacks` so the -# `_check_slacks` diagnostic can see whether `max_slack` is binding. -function _penalized_slack( - master::_LOAMaster, method::LOA, penalty_sign::Int - ) +# it infeasible. +function _penalized_slack(master::_LOAMaster, method::LOA, penalty_sign::Int) slack = JuMP.@variable(master.model, lower_bound = 0.0, upper_bound = method.max_slack) - push!(master.slacks, slack) JuMP.set_objective_function(master.model, JuMP.objective_function(master.model) + penalty_sign * method.oa_penalty * slack) @@ -1035,86 +749,81 @@ end # Linearize constraint at `linearization_point`, then dispatch on the # constraint's set to emit slacked OA cut(s) gated by `M(1 − binary)`. -# Linear constraints are exact via BigM and skipped. `feasible` flags -# whether the linearization point came from a real NLP (true) or from -# NLPF (false); equality cuts behave differently in those two regimes. +# Linear constraints are exact via BigM and skipped. function _add_oa_cut_for_constraint( constraint::JuMP.AbstractConstraint, master::_LOAMaster, binary_ref, linearization_point::AbstractDict, var_map::AbstractDict, - dual_value, method::LOA, - sign_factor::Int, - penalty_sign::Int, - feasible::Bool + penalty_sign::Int ) _is_linear_F(typeof(constraint.func)) && return linearization = _linearize_at( constraint.func, linearization_point, var_map) - _emit_disjunct_oa_cut(constraint.set, master, binary_ref, - linearization, dual_value, method, sign_factor, - penalty_sign, feasible) + _emit_disjunct_oa_cut( + constraint.set, master, binary_ref, linearization, method, + penalty_sign) return end -# Inequality sets — one signed cut per Türkay-Grossmann 1996 / Duran- -# Grossmann 1986 OA convention. Primary NLP gives the real Lagrange -# multiplier (whose sign picks the active side); NLPF returns ±1 from -# `_nlpf_dual_sign(set)`. +# Emit the slacked, gated OA cut(s) for a disjunct constraint. The cut +# direction is set-determined (Duran-Grossmann 1986 OA convention): a +# `≤` constraint linearizes in place, a `≥` constraint is negated, and +# equality / interval emit both directions sharing one slack. No dual is +# needed — the constraint's set fixes the direction. function _emit_disjunct_oa_cut( - set::Union{_MOI.LessThan, _MOI.GreaterThan}, - master::_LOAMaster, binary_ref, linearization, dual_value, - method::LOA, sign_factor::Int, penalty_sign::Int, ::Bool + set::_MOI.LessThan, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int ) - sign_value = sign(sign_factor * _collapse_dual(dual_value)) - sign_value == 0 && return - rhs = _set_rhs(set) slack = _penalized_slack(master, method, penalty_sign) JuMP.@constraint(master.model, - sign_value * (linearization - rhs) - slack <= + (linearization - _set_rhs(set)) - slack <= + method.M_value * (1 - binary_ref)) + return +end +function _emit_disjunct_oa_cut( + set::_MOI.GreaterThan, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int + ) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, + (_set_rhs(set) - linearization) - slack <= method.M_value * (1 - binary_ref)) return end - -# Equality — feasible NLP: the summed BigM duals give the OA/ER -# multiplier sign (Duran-Grossmann 1986), so one signed cut suffices. -# Infeasible (NLPF): sign is uninformative, so emit both directions -# sharing one slack — mirrors the global-OA equality treatment in -# `_add_global_oa_row(::EqualTo)`. function _emit_disjunct_oa_cut( set::_MOI.EqualTo, - master::_LOAMaster, binary_ref, linearization, dual_value, - method::LOA, sign_factor::Int, penalty_sign::Int, feasible::Bool + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int ) c = _MOI.constant(set) slack = _penalized_slack(master, method, penalty_sign) - if feasible - sign_value = sign(sign_factor * _collapse_dual(dual_value)) - sign_value == 0 && return - JuMP.@constraint(master.model, - sign_value * (linearization - c) - slack <= - method.M_value * (1 - binary_ref)) - return - end JuMP.@constraint(master.model, - (linearization - c) - slack <= - method.M_value * (1 - binary_ref)) + (linearization - c) - slack <= method.M_value * (1 - binary_ref)) JuMP.@constraint(master.model, - (c - linearization) - slack <= - method.M_value * (1 - binary_ref)) + (c - linearization) - slack <= method.M_value * (1 - binary_ref)) return end - -# Interval — always two-sided regardless of dual: there is no single -# rhs to sign against (`_set_rhs(::Interval) = 0` is wrong here), and -# any active boundary needs its linearization to gate the right side. -# Both rows share one slack so the V&G penalty is not double-counted. function _emit_disjunct_oa_cut( set::_MOI.Interval, - master::_LOAMaster, binary_ref, linearization, ::Any, - method::LOA, ::Int, penalty_sign::Int, ::Bool + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) JuMP.@constraint(master.model, @@ -1125,19 +834,19 @@ function _emit_disjunct_oa_cut( method.M_value * (1 - binary_ref)) return end - -# Fallback for vector sets and any future set types — preserve the -# original single-signed behavior with `_collapse_dual` for the sign. +# Fallback for vector / future set types: emit the `≤`-direction cut +# against the set's RHS (`_set_rhs` defaults to 0). function _emit_disjunct_oa_cut( - set, master::_LOAMaster, binary_ref, linearization, dual_value, - method::LOA, sign_factor::Int, penalty_sign::Int, ::Bool + set, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int ) - sign_value = sign(sign_factor * _collapse_dual(dual_value)) - sign_value == 0 && return - rhs = _set_rhs(set) slack = _penalized_slack(master, method, penalty_sign) JuMP.@constraint(master.model, - sign_value * (linearization - rhs) - slack <= + (linearization - _set_rhs(set)) - slack <= method.M_value * (1 - binary_ref)) return end @@ -1149,24 +858,33 @@ end # `EqualTo` / `Interval` get a two-sided pair sharing one slack. # Unknown set types fall back to the prior hard cut. function _add_global_oa_row( - master::_LOAMaster, lin, set::_MOI.LessThan, - method::LOA, penalty_sign::Int + master::_LOAMaster, + lin, + set::_MOI.LessThan, + method::LOA, + penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) JuMP.@constraint(master.model, lin - _MOI.constant(set) <= slack) return end function _add_global_oa_row( - master::_LOAMaster, lin, set::_MOI.GreaterThan, - method::LOA, penalty_sign::Int + master::_LOAMaster, + lin, + set::_MOI.GreaterThan, + method::LOA, + penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) JuMP.@constraint(master.model, _MOI.constant(set) - lin <= slack) return end function _add_global_oa_row( - master::_LOAMaster, lin, set::_MOI.EqualTo, - method::LOA, penalty_sign::Int + master::_LOAMaster, + lin, + set::_MOI.EqualTo, + method::LOA, + penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) c = _MOI.constant(set) @@ -1175,57 +893,28 @@ function _add_global_oa_row( return end function _add_global_oa_row( - master::_LOAMaster, lin, set::_MOI.Interval, - method::LOA, penalty_sign::Int + master::_LOAMaster, + lin, + set::_MOI.Interval, + method::LOA, + penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) JuMP.@constraint(master.model, lin - set.upper <= slack) JuMP.@constraint(master.model, set.lower - lin <= slack) return end -function _add_global_oa_row( - master::_LOAMaster, lin, set, ::LOA, ::Int - ) +function _add_global_oa_row(master::_LOAMaster, lin, set, ::LOA, ::Int) JuMP.@constraint(master.model, lin in set) return end -# OVERRIDABLE. Yield `(binary_ref, lin_point, var_map, dual)` per OA -# cut to emit for `constraint`. Scalar binary returns one tuple; the -# InfiniteOpt extension overrides to fan out across supports of any -# infinite variable found in `binary_ref` OR `constraint.func`. The -# constraint must factor in because a finite indicator on an -# InfiniteModel can still gate a constraint that depends on an -# infinite var — one cut per support is needed, even though the -# indicator is scalar. -# -# `GenericAffExpr` covers complement-form indicators (`1 - y`) stored -# in `binary_map` when a `Logical` is declared with `logical_complement`. -cut_info( - binary_ref::JuMP.AbstractVariableRef, - active::Bool, - constraint::JuMP.AbstractConstraint, - linearization_point::AbstractDict, - variable_map::AbstractDict, - dual - ) = ((binary_ref, linearization_point, variable_map, dual),) -cut_info( - binary_ref::JuMP.GenericAffExpr, - active::Bool, - constraint::JuMP.AbstractConstraint, - linearization_point::AbstractDict, - variable_map::AbstractDict, - dual - ) = ((binary_ref, linearization_point, variable_map, dual),) - -# OVERRIDABLE. Truthiness of an active descriptor. InfiniteOpt -# overrides for per-support `Vector{Bool}`. -any_active(active::Bool) = active - -# Collapse a per-constraint dual (scalar or vector for Interval / -# EqualTo / Zeros) to a scalar sign-carrier. -_collapse_dual(dual::Number) = dual -_collapse_dual(dual) = sum(dual) +# Is this indicator active in the combination? A finite indicator carries +# a `Bool`; an infinite one carries a per-support `Vector{Bool}` (active +# at some supports, not others). Used to skip disjuncts that are off +# everywhere when emitting OA cuts. +is_active(active::Bool) = active +is_active(active::AbstractVector{Bool}) = any(active) ################################################################################ # LINEARIZATION & EXPRESSION CONVERSION @@ -1292,7 +981,8 @@ _to_nlp_expr(x::Number, ::Dict) = x # expression at point xk via MOI.Nonlinear reverse-mode AD. function _linearize_at( func::Union{JuMP.GenericQuadExpr, JuMP.GenericNonlinearExpr}, - xk::Dict, ref_map + xk::Dict, + ref_map ) vars = JuMP.AbstractVariableRef[] _interrogate_variables(v -> push!(vars, v), func) @@ -1332,10 +1022,10 @@ _set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = _MOI.constant(s) _set_rhs(::Any) = 0.0 -# Unwrap a 1-element per-support `Vector` to its scalar value; -# scalars pass through. `extract_solution` returns per-support -# `Vector`s uniformly (length-1 for finite, length-K for InfiniteOpt). -# AD pipelines and `set_start_value` need a scalar in the finite -# case; per-support consumers slice out a scalar themselves. +# Unwrap a 1-element `Vector` to its scalar value; scalars pass +# through. `extract_solution` returns `Vector`s uniformly (length-1 for +# a scalar variable, length-K for a vector-valued one). AD pipelines +# and `set_start_value` need a scalar in the scalar case; vector-valued +# consumers slice out a scalar themselves. _unwrap_scalar(v::Real) = v _unwrap_scalar(v::AbstractVector) = only(v) diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 7a202264..e470bfbf 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -5,23 +5,23 @@ function test_loa_datatype() @test method.nlp_optimizer == HiGHS.Optimizer @test method.mip_optimizer == HiGHS.Optimizer @test method.max_iter == 10 - @test method.atol == 1e-6 - @test method.rtol == 1e-4 @test method.M_value == 1e9 @test method.max_slack == 1000.0 @test method.oa_penalty == 1000.0 + @test method.convergence_tol == 1e-6 + @test method.slack_tol == 1e-4 @test method.inner_method isa BigM @test method.inner_method.value == 1e9 - method = LOA(HiGHS.Optimizer; max_iter = 50, atol = 1e-8, - rtol = 1e-6, M_value = 1e6, max_slack = 500.0, - oa_penalty = 200.0) + method = LOA(HiGHS.Optimizer; max_iter = 50, M_value = 1e6, + max_slack = 500.0, oa_penalty = 200.0, + convergence_tol = 1e-4, slack_tol = 1e-3) @test method.max_iter == 50 - @test method.atol == 1e-8 - @test method.rtol == 1e-6 @test method.M_value == 1e6 @test method.max_slack == 500.0 @test method.oa_penalty == 200.0 + @test method.convergence_tol == 1e-4 + @test method.slack_tol == 1e-3 @test method.inner_method isa BigM @test method.inner_method.value == 1e6 @@ -75,16 +75,6 @@ function test_no_good_cut() @test num_cons_after == num_cons_before + 1 end -function test_loa_convergence_check() - method = LOA(HiGHS.Optimizer; atol = 1e-6, rtol = 1e-4) - - sense = Val(MOI.MIN_SENSE) - @test DP._loa_converged(1.0, 1.0, sense, method) == true - @test DP._loa_converged(1.0, 0.9999, sense, method) == true - @test DP._loa_converged(1.0, 0.5, sense, method) == false - @test DP._loa_converged(1e-8, 0.0, sense, method) == true -end - function test_loa_reformulate_simple() model = GDPModel(HiGHS.Optimizer) @variable(model, 0 <= x <= 10) @@ -193,8 +183,9 @@ end function test_loa_complement_indicator_nonlinear_disjunct() # Regression: complement-form indicators store `1 - y_base` (an # AffExpr) in `binary_map`. When the complement disjunct has a - # nonlinear constraint, `add_disjunct_oa_cuts` calls `cut_info` - # on that AffExpr; the AffExpr method must dispatch. + # nonlinear constraint, `add_disjunct_oa_cuts` feeds that AffExpr + # straight into the OA cut gating term `M(1 - binary)`, which must + # accept an AffExpr binary (not just a plain variable ref). # # Setup: 0 <= x <= 10, Y2 ≡ ¬Y1. # Disjunct(Y1): x <= 3 (linear) @@ -319,11 +310,119 @@ function test_loa_nlpf_infeasible_disjunct() @test value(Y[1]) ≈ 0.0 atol = 1e-6 end +function test_loa_sense_primitives() + # Sense-dispatched primitives the main loop reads through. The + # finite/infinite solve tests cover every branch except the + # minimize-sense gap (no minimizing model reaches a master bound in + # those tests), so pin all six here for both senses directly. + minv = Val(MOI.MIN_SENSE) + maxv = Val(MOI.MAX_SENSE) + @test DP._penalty_sign(minv) == 1 + @test DP._penalty_sign(maxv) == -1 + @test DP._worst_objective(minv) == Inf + @test DP._worst_objective(maxv) == -Inf + @test DP._is_better(minv, 3.0, 5.0) + @test !DP._is_better(minv, 5.0, 3.0) + @test DP._is_better(maxv, 5.0, 3.0) + @test !DP._is_better(maxv, 3.0, 5.0) + @test DP._gap(minv, 5.0, 3.0) == 2.0 + @test DP._gap(maxv, 3.0, 5.0) == 2.0 +end + +function test_loa_iteration_loop() + # Force the master/NLP refinement loop to run its body rather than + # exhaust the master on the seeds or converge on the first master + # solve. Two disjunctions over disjoint x/z half-lines put the + # optimum on an OFF-diagonal combination the set-covering seeds (the + # diagonal (Y1,W1) and (Y2,W2)) never try. After seeding, the master + # stays feasible and its bound beats the seed incumbent, so LOA + # enters the loop body, extracts the off-diagonal combination, solves + # its NLP, and updates the incumbent before converging. `Min` sense + # also drives the minimize-sense gap path. + # min x - z + # D1: (x <= 4) [Y1] v (x >= 6) [Y2] + # D2: (z <= 4) [W1] v (z >= 6) [W2] + # Optimum: Y1 & W2 -> x = 0, z = 10, obj = -10 (off-diagonal). + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, 0 <= z <= 10) + @variable(model, Y[1:2], Logical) + @variable(model, W[1:2], Logical) + @constraint(model, x <= 4, Disjunct(Y[1])) + @constraint(model, x >= 6, Disjunct(Y[2])) + @disjunction(model, Y) + @constraint(model, z <= 4, Disjunct(W[1])) + @constraint(model, z >= 6, Disjunct(W[2])) + @disjunction(model, W) + @objective(model, Min, x - z) + optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ -10.0 atol = 1e-4 + @test value(Y[1]) ≈ 1.0 atol = 1e-6 + @test value(W[2]) ≈ 1.0 atol = 1e-6 +end + +function test_loa_time_limits() + # Exercise the wall-clock budget paths. A finite `time_limit` + # (overall cap) drives `_cap_remaining_time` during the subproblem + # solves and the final-solve budget reset; a finite + # `iteration_time_limit` (loop-only budget) drives the post-loop + # time-limit restore from no prior limit (the `::Nothing` path). + # Both limits are generous: they govern the path taken, not the + # result. + function build_model() + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + return model + end + + model = build_model() + optimize!(model, gdp_method = LOA(HiGHS.Optimizer; time_limit = 60.0)) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 7.0 atol = 1e-4 + + model = build_model() + optimize!(model, + gdp_method = LOA(HiGHS.Optimizer; iteration_time_limit = 60.0)) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 7.0 atol = 1e-4 +end + +function test_loa_no_feasible_incumbent() + # Every disjunct combination is NLP-infeasible: the global x == 20 + # contradicts the x <= 10 bound under any selection. NLPF does not + # slack equalities, so NLPF also fails (no values) and `_solve_nlp` + # returns its infeasible fallback. No seed or loop NLP yields a + # feasible incumbent, so `best_result` stays `nothing`, the master + # is infeasible (no bound), and LOA exits on the "no feasible + # incumbent" warning from `_report_loa_gap`. + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @constraint(model, x == 20) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + method = LOA(HiGHS.Optimizer) + @test_logs (:warn, r"no feasible incumbent") match_mode = :any begin + DP.reformulate_model(model, method) + end + @test DP._ready_to_optimize(model) +end + @testset "LOA" begin test_loa_datatype() test_set_covering_combos() test_no_good_cut() - test_loa_convergence_check() test_loa_reformulate_simple() test_loa_solve_simple() test_loa_solve_simple_with_mbm() @@ -336,4 +435,8 @@ end test_linearize_nonlinear_sin() test_linearize_nonlinear_multivar() test_to_nlp_expr() + test_loa_sense_primitives() + test_loa_iteration_loop() + test_loa_time_limits() + test_loa_no_feasible_incumbent() end diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index c5df7165..707833fa 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -828,8 +828,8 @@ function test_loa_infinite_complement_indicator() # # Linear disjuncts only — a nonlinear disjunct constraint on an # infinite variable would also hit a separate gap where - # `cut_info` for a finite indicator does not slice the per-support - # linearization point. Out of scope for this regression. + # `_infinite_cut_info` for a finite indicator does not slice the + # per-support linearization point. Out of scope for this regression. # # max ∫ x dt with 0 ≤ x ≤ 10 over t ∈ [0,1]: # Y1: x ≤ 3 Y2 ≡ ¬Y1: x ≤ 8 @@ -855,7 +855,7 @@ end function test_loa_infinite_complement_nonlinear_disjunct() # Regression: a finite (or complement-form AffExpr) indicator # gating a NONLINEAR constraint on an infinite variable. Earlier - # `cut_info` decided fan-out from the indicator only — finite + # `_infinite_cut_info` decided fan-out from the indicator only — finite # indicator → single un-sliced site → `_linearize_at` received # per-support `Vector{Float64}` values and tripped on # `_unwrap_scalar`'s `only(v)`. Fixed by inspecting the constraint @@ -917,14 +917,18 @@ function test_loa_infinite_multidim_parameter() end function test_loa_infinite_nlpf_infeasible_disjunct() - # InfiniteOpt LOA: Y[1]'s per-support constraint x^2 >= 200 is - # NLP-infeasible against the bound x in [0, 10] (max x^2 = 100). - # With infinite indicators `Y[1:2], InfiniteLogical(t)`, the - # combination value is `Vector{Bool}` — exercising the extension - # override `_nlpf_fix_on_copy(::InfiniteModel, - # ::GeneralVariableRef, ::AbstractVector{Bool})` that pins each - # support via point-equality. LOA must still converge to Y[2] - # active everywhere (x = 5), giving objective ∫x dt = 5. + # InfiniteOpt LOA: Y[1]'s per-support constraint x >= 200 is + # NLP-infeasible against the bound x in [0, 10]. With infinite + # indicators `Y[1:2], InfiniteLogical(t)`, the infeasible + # Y[1]-everywhere seed makes the primary NLP fail, so NLPF runs: it + # copies the model, re-pins the binaries on the copy via + # `nlpf_fix_on_copy`, slacks the inequality, and returns a + # linearization point. The disjunct is deliberately LINEAR: + # the model is then convex, so LOA's master bound is rigorous and + # it converges to Y[2] active everywhere (x = 5), giving ∫x dt = 5. + # (A nonconvex infeasible disjunct such as x^2 >= 200 would make LOA + # a heuristic and let the local NLP solver report a constraint- + # violating point as solved — out of scope for the NLPF path here.) ipopt = optimizer_with_attributes(Ipopt.Optimizer, "print_level" => 0, "sb" => "yes") juniper = optimizer_with_attributes(Juniper.Optimizer, @@ -934,7 +938,7 @@ function test_loa_infinite_nlpf_infeasible_disjunct() @infinite_parameter(model, t ∈ [0, 1], num_supports = 3) @variable(model, 0 <= x <= 10, Infinite(t)) @variable(model, Y[1:2], InfiniteLogical(t)) - @constraint(model, x^2 >= 200, Disjunct(Y[1])) + @constraint(model, x >= 200, Disjunct(Y[1])) @constraint(model, x <= 5, Disjunct(Y[2])) @disjunction(model, Y) @objective(model, Max, ∫(x, t)) @@ -974,6 +978,38 @@ function test_loa_infinite_aggregate_global() @test objective_value(model) ≈ 1.0 atol = 1e-2 end +function test_loa_infinite_iteration_loop() + # Force the InfiniteModel LOA main loop to fix per-support + # Vector{Bool} combinations, exercising the in-place fix-constraint + # stash: create on the first main-loop fix, update via + # set_normalized_rhs on the committed re-fix. Two disjunctions over + # disjoint x/z half-lines put the optimum on an off-diagonal + # combination the scalar set-covering seeds never try, so the master + # stays feasible and the loop body runs with per-support values. + # min ∫(x - z) dt + # D1: (x <= 4) [Y1] ∨ (x >= 6) [Y2] + # D2: (z <= 4) [W1] ∨ (z >= 6) [W2] + # Optimum: Y1 & W2 everywhere → x = 0, z = 10, ∫(x - z) = -10. + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 3) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, 0 <= z <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @variable(model, W[1:2], InfiniteLogical(t)) + @constraint(model, x <= 4, Disjunct(Y[1])) + @constraint(model, x >= 6, Disjunct(Y[2])) + @disjunction(model, Y) + @constraint(model, z <= 4, Disjunct(W[1])) + @constraint(model, z >= 6, Disjunct(W[2])) + @disjunction(model, W) + @objective(model, Min, ∫(x - z, t)) + optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) + @test termination_status(model) in + (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ -10.0 atol = 1e-3 +end + @testset "InfiniteDisjunctiveProgramming" begin @testset "Model" begin @@ -1051,6 +1087,7 @@ end test_loa_infinite_complement_nonlinear_disjunct() test_loa_infinite_multidim_parameter() test_loa_infinite_nlpf_infeasible_disjunct() + test_loa_infinite_iteration_loop() else @info "Skipping InfiniteOpt LOA tests: " * "JuMP.copy_model(::InfiniteModel) unavailable " * From 6a9d6e0ac9fbef647e9fcc811f89bceb74016fa4 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Sun, 28 Jun 2026 16:15:51 -0400 Subject: [PATCH 56/72] Reduce functions in loa workflow --- ext/InfiniteDisjunctiveProgramming.jl | 340 +++++------ src/datatypes.jl | 6 + src/hull.jl | 2 + src/loa.jl | 539 ++++++++++-------- src/model.jl | 1 + src/reformulate.jl | 1 + test/constraints/loa.jl | 119 +++- .../InfiniteDisjunctiveProgramming.jl | 107 +++- 8 files changed, 663 insertions(+), 452 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 260cbf3e..b155ee26 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -341,9 +341,9 @@ _per_support_values(variable::InfiniteOpt.GeneralVariableRef) = # Read per-support values from the transformation backend, keyed by # InfiniteOpt vars. Skips fixed vars. The objective-side translation -# (transcribe-then-AD when the objective has aggregate refs) lives -# in the `add_oa_cuts(::InfiniteModel, ...)` override below — base -# `extract_solution` doesn't need to anticipate it. +# (transcribe-then-AD when the objective has aggregate refs) lives in +# the `_objective_linearization_point(::InfiniteModel, ...)` override +# below — base `extract_solution` doesn't need to anticipate it. function DP.extract_solution(model::InfiniteOpt.InfiniteModel) return Dict(var => _per_support_values(var) for var in DP.collect_all_vars(model) @@ -603,34 +603,19 @@ function _at_support( return result end -# Build map: transcribed input JuMP var → master point variable. -# For an infinite input var v, every transcribed support `v_k` -# maps to `ref_map[v](d_k)` (master point variable). Used as -# `objective_ref_map` so the objective OA cut can linearize the -# (already flat) transcribed objective and land in master point -# variables. -# -# Why the transcribe-then-AD detour exists: MOI Nonlinear AD has -# no walker for `InfiniteOpt.MeasureRef`, so an objective like -# `∫(f(z, t), t)` cannot be differentiated directly. The objective -# OA cut therefore transcribes the input model once, ADs the -# resulting flat scalar objective, and uses this map to translate -# the gradient back into master point variables. Per-support -# DISJUNCT cuts do NOT need this — they linearize natively in -# InfiniteModel space via `_infinite_cut_info`. If the objective has no -# measures, this whole layer is dead weight; killing it would -# require either banning measure objectives or hand-writing a -# `_linearize_at` that walks `MeasureRef` symbolically. +# Map transcribed input JuMP var → master point variable, used as +# `objective_ref_map`. Needed because MOI AD can't walk a `MeasureRef`, +# so a measure objective is transcribed, AD'd flat, and the gradient +# mapped back to master point vars. Per-support disjunct cuts skip this +# (they linearize natively via `_infinite_cut_info`). function _transcribed_to_master_point( model::InfiniteOpt.InfiniteModel, ref_map::AbstractDict ) result = Dict{JuMP.VariableRef, InfiniteOpt.GeneralVariableRef}() for v in DP.collect_all_vars(model) - # Point vars share their transcribed instance with the - # underlying infinite var's per-support transcription, so - # skipping them here doesn't lose any transcribed→master - # mappings. + # Point vars share the infinite var's transcription, so skipping + # them loses no mappings. _is_point_var(v) && continue master_var = ref_map[v] transcribed = InfiniteOpt.transformation_variable(v) @@ -638,12 +623,9 @@ function _transcribed_to_master_point( if isempty(prefs) result[transcribed] = master_var else - # `_supports_of` returns scalars for 1-D parameters and - # vectors for multi-D dependent groups; `_at_support` - # routes both shapes through `v(support)`. Using - # `vec(InfiniteOpt.supports(...))` here would silently - # flatten a multi-D matrix into scalars and break point - # evaluation on dependent parameter groups. + # `_supports_of`/`_at_support` handle 1-D (scalar) and + # multi-D (vector) supports; `vec(supports(...))` would + # flatten multi-D and break point evaluation. for (k, support) in enumerate(_supports_of(v)) result[vec(transcribed)[k]] = _at_support(master_var, support) @@ -653,33 +635,19 @@ function _transcribed_to_master_point( return result end -# Build the LOA master from `JuMP.copy_model(model)` and strip the -# nonlinear constraints (they re-enter via OA cuts). `binary_map[ -# indicator]` and `variable_map[v]` hold single InfiniteOpt vars on -# the master; per-support handling happens downstream via point -# evaluation on those refs. OA cuts added in the LOA loop are -# point-evaluated scalar constraints on the master InfiniteModel; -# transcription is rebuilt before each master solve. -# -# Objective handling branches on `_has_aggregate_ref`. When the -# objective is aggregate-free (no MeasureRef / ParameterFunctionRef), -# `original_objective` is the InfiniteOpt objective itself and -# `objective_ref_map = ref_map`, so AD walks the InfiniteOpt -# expression directly. When it contains an aggregate, AD cannot see -# inside it; we fall back to transcribing the input model and using -# the flat scalar objective with a transcribed-to-master point map. +# Build the master from `JuMP.copy_model(model)`, keeping linear +# constraints and the InfiniteOpt vars (per-support handling is done +# downstream by point evaluation). The objective branches on +# `_has_aggregate_ref`: aggregate-free objectives AD the InfiniteOpt +# expression directly; aggregate ones are transcribed flat and mapped +# back via `_transcribed_to_master_point`. function DP.build_loa_master( model::InfiniteOpt.InfiniteModel, method::DP.LOA )::DP._LOAMaster - # Linear, non-aggregate constraints stay on the master. Nonlinear - # constraints — and aggregate-wrapped affine ones like - # `∫(x^2,t) ≤ c`, which read linear via `_is_linear_F` over a - # `MeasureRef` but expand to a nonlinear form on transcription — - # re-enter as OA cuts after each NLP solve, so they are dropped - # at copy time instead of copied then deleted. Variable bounds - # proper live on VariableInfo and survive `copy_model` regardless; - # the `F === GeneralVariableRef` early-return covers - # variable-ref-as-constraint registrations. + # Keep only linear, non-aggregate constraints. Nonlinear and + # aggregate-wrapped ones (e.g. `∫(x^2,t) ≤ c`) re-enter as OA cuts, + # so they are dropped at copy time. Variable bounds survive on + # VariableInfo regardless. variable_type = InfiniteOpt.GeneralVariableRef master, copy_ref_map = JuMP.copy_model( model; @@ -742,32 +710,60 @@ function DP.build_loa_master( variable_map[v] = ref_map[v] end - # Aggregate-wrapped LINEAR constraints (e.g. `𝔼(W, ξ) ≥ α`) - # were filtered out at `copy_model` time because `copy_model` - # cannot transfer MeasureRefs across InfiniteModels. The - # nonlinear-aggregate path re-adds them as OA cuts after each - # NLP solve, but `_add_global_oa_cuts_infinite` short-circuits - # on linear `F` — so without this step a linear chance - # constraint is missing from the master entirely, and the - # master will pick combinations that violate it. Transcribe - # each such constraint once and add the flat scalar form to - # the master directly. + # Aggregate-wrapped LINEAR constraints (e.g. `𝔼(W, ξ) ≥ α`) were + # dropped at copy time and the OA-cut path skips linear `F`, so add + # each one to the master directly as a transcribed flat scalar. _add_aggregate_linear_constraints( master, model, ref_map, variable_type) return DP._LOAMaster(master, binary_map, variable_map, objective_sense, original_objective, alpha_oa, - objective_ref_map) + objective_ref_map, DP._build_disaggregator( + model, method.inner_method, variable_map, binary_map)) end -# Walk the original model's linear-`F` constraints, transcribe those -# containing a `MeasureRef` / `ParameterFunctionRef`, and append the -# resulting flat scalar constraint to the master. No linearization -# needed (the constraint is already affine post-transcription) — -# `_linearize_at` on an `AffExpr` just substitutes variables via -# `transcribed_to_master`. Reuses the transformation backend that -# `_add_global_oa_cuts_infinite` will rebuild later; building it -# now is cheap and idempotent. +# Record one master-space disaggregation for `InfiniteModel`. The Hull +# cut emitter runs through the per-support fan-out over point-evaluated +# variables, so key the map by `(point variable, point binary)` at each +# support — `disaggregate_expression` then substitutes the point +# disaggregated variable per support. Finite (parameter-free) entries +# key once, unsliced. +function DP._record_disaggregation( + hull::DP._Hull, + ::InfiniteOpt.InfiniteModel, + variable, + binary, + disaggregated + ) + supports = _disaggregator_supports(variable, binary) + if supports === nothing + hull.disjunct_variables[(variable, binary)] = disaggregated + else + for support in supports + hull.disjunct_variables[(_at_support(variable, support), + _at_support(binary, support))] = + _at_support(disaggregated, support) + end + end + return +end + +# Supports the per-support fan-out gates this disjunct on (mirrors +# `_relevant_supports`): the binary's if the indicator is infinite, else +# the variable's if it is infinite, else `nothing` (both finite, one +# unsliced key). Keying off the binary lets a finite variable under an +# infinite indicator match the sliced binary the emitter looks up. +function _disaggregator_supports(master_var, master_bin) + binary_var = _find_infinite_var(master_bin) + binary_var === nothing || return _supports_of(binary_var) + isempty(InfiniteOpt.parameter_refs(master_var)) && return nothing + return _supports_of(master_var) +end + +# Transcribe each linear constraint containing a `MeasureRef` / +# `ParameterFunctionRef` and append the flat scalar form to the master. +# Already affine post-transcription, so `_linearize_at` just substitutes +# variables via `transcribed_to_master`. function _add_aggregate_linear_constraints( master::InfiniteOpt.InfiniteModel, model::InfiniteOpt.InfiniteModel, @@ -790,14 +786,11 @@ function _add_aggregate_linear_constraints( InfiniteOpt.build_transformation_backend!(model) transcribed_to_master = _transcribed_to_master_point(model, ref_map) - # `_transcribed_to_master_point` only walks decision vars. Finite - # parameters (`@finite_parameter`) survive transcription as - # scalar JuMP variables and can appear in transcribed - # constraints (e.g. the `α` on the RHS of an event constraint). - # Map each transcribed-parameter JuMP var to the master's - # corresponding parameter so the constraint stays - # parameter-relative (the master then honors `set_value(α, ...)` - # without rebuild). + # Finite parameters survive transcription as scalar JuMP vars and can + # appear in transcribed constraints, but `_transcribed_to_master_point` + # only walks decision vars. Map them to the master's parameters so the + # constraint stays parameter-relative (honors `set_value` without + # rebuild). for p in InfiniteOpt.all_parameters(model) InfiniteOpt.dispatch_variable_ref(p) isa InfiniteOpt.FiniteParameterRef || continue @@ -834,50 +827,34 @@ function _add_aggregate_linear_constraints( return end -# Override `add_oa_cuts` for `InfiniteModel`: translate the -# linearization point into the form the master's `original_objective` -# expects, and route global OA cuts through transcription so they -# work over infinite vars and aggregate refs. The base -# `result.linearization_point` has per-support `Vector` values keyed -# on InfiniteOpt vars; the master's objective is either the raw -# InfiniteOpt expression (non-aggregate, expects scalar `xk[v]`) or -# the transcribed flat scalar expression (aggregate, expects -# transcribed-`JuMP.VariableRef`-keyed scalar dict). The translation -# produces whichever shape `_linearize_at` needs. -function DP.add_oa_cuts( +# Objective linearization point for `InfiniteModel` (the seam the shared +# base `add_oa_cuts` calls). The master objective is either the raw +# InfiniteOpt expression (non-aggregate, expects a scalar `xk[v]` keyed +# on InfiniteOpt vars) or the transcribed flat scalar expression +# (aggregate, expects a transcribed-`JuMP.VariableRef`-keyed scalar +# dict). Translate the base per-support `Vector`-valued point into +# whichever shape `original_objective` needs. +function DP._objective_linearization_point( model::InfiniteOpt.InfiniteModel, - master::DP._LOAMaster, - result::NamedTuple, - method::DP.LOA + linearization_point::AbstractDict ) - isempty(result.linearization_point) && return - obj_point = if _has_aggregate_ref(JuMP.objective_function(model)) - _transcribe_linearization_point( - model, result.linearization_point) - else - T = eltype(valtype(result.linearization_point)) - Dict{InfiniteOpt.GeneralVariableRef, T}( - var => values[1] - for (var, values) in result.linearization_point - if isempty(InfiniteOpt.parameter_refs(var))) - end - linearization = DP._linearize_at(master.original_objective, - obj_point, master.objective_ref_map) - DP._add_objective_cut( - Val(master.objective_sense), master, linearization, method) - _add_global_oa_cuts_infinite(model, master, result, method) - DP.add_disjunct_oa_cuts(model, master, result, method) - return + _has_aggregate_ref(JuMP.objective_function(model)) && + return _transcribe_linearization_point(model, linearization_point) + T = eltype(valtype(linearization_point)) + return Dict{InfiniteOpt.GeneralVariableRef, T}( + var => values[1] + for (var, values) in linearization_point + if isempty(InfiniteOpt.parameter_refs(var))) end -# Global OA cuts for `InfiniteModel`. Mirrors base `_add_global_oa_cuts` -# but routes through transcription so per-support / aggregate-ref -# expressions reach `_linearize_at` as flat scalars over `JuMP.VariableRef`s. -# Aggregate-containing constraints (e.g. `∫f(x,t)dt ≤ 0`) transcribe -# to a single scalar expression. Constraints with infinite-parameter -# dependence (e.g. `x(t) ≥ 0`) transcribe to a per-support `AbstractArray` -# of scalar expressions — one OA cut is emitted per support. -function _add_global_oa_cuts_infinite( +# Global OA cuts for `InfiniteModel`: route through transcription so +# per-support / aggregate-ref expressions reach `_linearize_at` as flat +# scalars over `JuMP.VariableRef`s. Aggregate-containing constraints +# (e.g. `∫f(x,t)dt ≤ 0`) transcribe to a single scalar expression. +# Constraints with infinite-parameter dependence (e.g. `x(t) ≥ 0`) +# transcribe to a per-support `AbstractArray` of scalar expressions — +# one OA cut is emitted per support. +function DP._add_global_oa_cuts( model::InfiniteOpt.InfiniteModel, master::DP._LOAMaster, result::NamedTuple, @@ -926,86 +903,56 @@ function _add_global_oa_cuts_infinite( return end -# Override `add_disjunct_oa_cuts` for `InfiniteModel`. Same shape as -# the base loop, but each constraint is checked for aggregate refs. -# Aggregate constraints (e.g. those containing a `MeasureRef`) are -# transcribed via `InfiniteOpt.transformation_expression`, then -# handed back to the base `_add_oa_cut_for_constraint` as a regular -# `JuMP.ScalarConstraint` over flat `JuMP.VariableRef`s — which the -# base AD pipeline can linearize correctly. -# -# `transcribed_to_master` and `transcribed_xk` are built lazily once -# per `add_disjunct_oa_cuts` call and shared across all aggregate -# constraints in the iteration. -function DP.add_disjunct_oa_cuts( +# Per-constraint OA cut emission for `InfiniteModel` (the seam the base +# `add_disjunct_oa_cuts` driver calls). Non-aggregate constraints fan out +# per support via `_infinite_cut_info`; aggregate ones (`MeasureRef`) are +# transcribed flat and handed to the base `_add_oa_cut_for_constraint`. +# Transcription is rebuilt per aggregate constraint, not cached — a rare +# path (Hull errors on it), so the redundancy is a non-issue. +function DP._add_disjunct_constraint_oa_cuts( model::InfiniteOpt.InfiniteModel, + constraint::JuMP.AbstractConstraint, master::DP._LOAMaster, + binary_ref, + active, result::NamedTuple, - method::DP.LOA + method::DP.LOA, + penalty_sign::Int ) - penalty_sign = DP._penalty_sign(Val(master.objective_sense)) - transcribed_to_master = Ref{Any}(nothing) - transcribed_xk = Ref{Any}(nothing) - ensure_transcribed = function () - transcribed_to_master[] === nothing || return + if _has_aggregate_ref(constraint.func) InfiniteOpt.build_transformation_backend!(model) - transcribed_to_master[] = _transcribed_to_master_point( - model, master.variable_map) - transcribed_xk[] = _transcribe_linearization_point( + transcribed_func = InfiniteOpt.transformation_expression( + constraint.func) + transcribed_constraint = JuMP.ScalarConstraint( + transcribed_func, constraint.set) + transcribed_xk = _transcribe_linearization_point( model, result.linearization_point) + transcribed_to_master = _transcribed_to_master_point( + model, master.variable_map) + for (point_binary, _, _) in _infinite_cut_info(binary_ref, + active, transcribed_func, result.linearization_point, + master.variable_map) + DP._add_oa_cut_for_constraint(transcribed_constraint, master, + point_binary, transcribed_xk, transcribed_to_master, + method, penalty_sign) + end return end - for (indicator, active) in result.combination - DP.is_active(active) || continue - haskey(DP._indicator_to_constraints(model), indicator) || - continue - for orig_constraint_ref in - DP._indicator_to_constraints(model)[indicator] - orig_constraint_ref isa DP.DisjunctConstraintRef || - continue - constraint = DP._disjunct_constraints(model)[ - JuMP.index(orig_constraint_ref)].constraint - if _has_aggregate_ref(constraint.func) - ensure_transcribed() - transcribed_func = - InfiniteOpt.transformation_expression( - constraint.func) - transcribed_constraint = JuMP.ScalarConstraint( - transcribed_func, constraint.set) - for (binary_ref, _, _) in _infinite_cut_info( - master.binary_map[indicator], active, - transcribed_func, result.linearization_point, - master.variable_map) - DP._add_oa_cut_for_constraint( - transcribed_constraint, master, binary_ref, - transcribed_xk[], transcribed_to_master[], - method, penalty_sign) - end - continue - end - for (binary_ref, linearization_point, var_map) in - _infinite_cut_info( - master.binary_map[indicator], active, - constraint.func, result.linearization_point, - master.variable_map) - DP._add_oa_cut_for_constraint( - constraint, master, binary_ref, - linearization_point, var_map, method, penalty_sign) - end - end + for (point_binary, point, var_map) in _infinite_cut_info(binary_ref, + active, constraint.func, result.linearization_point, + master.variable_map) + DP._add_oa_cut_for_constraint(constraint, master, point_binary, + point, var_map, method, penalty_sign) end + return end -# Apply per-indicator fixes for `combination` and return a closure that -# reverses them. A scalar (`Bool`) value delegates to base -# `fix_indicator` (force-fix, which unwraps complement-form `1 - y` -# AffExprs). A per-support `AbstractVector{Bool}` value pins each support -# of the underlying infinite binary with a point-equality constraint. -# The returned closure deletes those constraints and unfixes, so the -# extension's `with_fixed_combination` can tear the fix down each -# iteration — a stale per-support pin would clash with the next -# combination. `commit_combination` ignores the closure, persisting the -# committed fix. +# Apply the combination's fixes and return a closure that reverses them. +# A `Bool` delegates to base `fix_indicator`; a per-support `Vector{Bool}` +# pins each support of the infinite binary with a point-equality +# constraint. The closure (used by `with_fixed_combination` to tear down +# each iteration) deletes those pins and unfixes; `commit_combination` +# ignores it to persist the committed fix. function DP.fix_combination( model::InfiniteOpt.InfiniteModel, combination::AbstractDict ) @@ -1039,14 +986,9 @@ function DP.fix_combination( end end -# OVERRIDE for InfiniteModel: fix the combination, run `f()`, then -# always undo. The finite base fixes in place and needs no teardown, but -# the infinite path pins per-support combinations with point-equality -# constraints (and scalar seeds with `JuMP.fix`) that must be cleared -# between iterations — a stale per-support pin would clash with the next -# combination — so this lifecycle needs the `try/finally` the base -# omits. The model is relaxed once in `reformulate_model`, so there is -# no relax/unrelax here. +# Fix the combination, run `f()`, then always undo. The infinite path's +# per-support point-equality pins must be cleared between iterations, so +# this needs the `try/finally` the finite base omits. function DP.with_fixed_combination( f, model::InfiniteOpt.InfiniteModel, combination::AbstractDict ) diff --git a/src/datatypes.jl b/src/datatypes.jl index 6abf23c0..14b96ab0 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -596,6 +596,11 @@ mutable struct GDPData{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, C reformulation_variables::Vector{V} reformulation_constraints::Vector{C} + # Hull disaggregated variable for each (original variable, indicator). + # Recorded so logic-based OA can rebuild convex-hull cuts after the + # reformulation's temporary `_Hull` disaggregation state is discarded. + disaggregations::Dict{Tuple{V, LogicalVariableRef{M}}, V} + # Solution data solution_method::Union{Nothing, AbstractSolutionMethod} ready_to_optimize::Bool @@ -614,6 +619,7 @@ mutable struct GDPData{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, C Dict{V, Tuple{T, T}}(), Vector{V}(), Vector{C}(), + Dict{Tuple{V, LogicalVariableRef{M}}, V}(), nothing, false, ) diff --git a/src/hull.jl b/src/hull.jl index 779369e9..a91a9d65 100644 --- a/src/hull.jl +++ b/src/hull.jl @@ -42,6 +42,8 @@ function _disaggregate_variable( #temp storage push!(method.disjunction_variables[vref], dvref) method.disjunct_variables[vref, bvref] = dvref + #persist for logic-based OA, which needs the map after reformulation + _disaggregations(model)[(vref, lvref)] = dvref #create bounding constraints dvname = JuMP.name(dvref) lbname = isempty(dvname) ? "" : "$(dvname)_lower_bound" diff --git a/src/loa.jl b/src/loa.jl index f03dcdb7..ef41b127 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -1,16 +1,9 @@ ################################################################################ # LOGIC-BASED OUTER APPROXIMATION (LOA) ################################################################################ -# Türkay & Grossmann (1996), Comp. & Chem. Eng. 20(8), 959-978 -# With augmented-penalty OA master from: -# Viswanathan & Grossmann (1990), Comp. & Chem. Eng. 14(7), 769-782 -# -# Infeasible primary NLPs fall through to NLPF (V&G 1990 eq. 8): a -# slacked feasibility version of the same problem whose primal becomes -# the linearization site for OA cuts. A no-good cut still forbids that -# combination on the master. NLPF is bypassed for vector-valued -# `Vector{Bool}` combinations (which an extension may supply) — those -# fall back to no-good-only. +# Iterate a primary NLP (inner-method reformulation, binaries fixed) and a +# master MILP accumulating OA and no-good cuts. Infeasible NLPs fall +# through to a slacked feasibility problem (NLPF) for the cut site. ################################################################################ ################################################################################ @@ -19,41 +12,25 @@ """ LOA{O, P, R, T} <: AbstractReformulationMethod -Logic-based Outer Approximation solver for GDP models. Iterates between a -primary NLP (the original model reformulated by `inner_method` with -indicator binaries fixed per iteration) and a master MILP that -accumulates outer-approximation and no-good cuts. - -`inner_method` defaults to `BigM(M_value)`. `MBM(optimizer)` is also -supported. Other reformulations (`Hull`, `PSplit`) are not yet supported. +Logic-based Outer Approximation solver for GDP models. Iterates a primary +NLP (original model reformulated by `inner_method`, binaries fixed per +iteration) and a master MILP accumulating OA and no-good cuts. +`inner_method` is `BigM` (default), `MBM`, or `Hull`. ## Fields -- `nlp_optimizer::O`: Solver used for the primary NLP. -- `mip_optimizer::P`: Solver used for the master MILP (defaults to - `nlp_optimizer`). -- `inner_method::R`: Reformulation applied to the primary NLP — `BigM` - or `MBM`. -- `max_iter::Int`: Maximum LOA iterations after set-covering seeding. -- `M_value::T`: Big-M used in the disjunct OA cut gating term. -- `max_slack::T`: Upper bound for each per-cut slack variable. -- `oa_penalty::T`: V&G 1990 penalty coefficient applied to slacks in - the master objective. -- `convergence_tol::Float64`: Relative gap tolerance for the early - stop. The main loop exits once the master bound meets the incumbent - within this relative gap, provided the slacks also pass `slack_tol`. -- `slack_tol::Float64`: Largest total OA-cut slack (summed slack - variables) for which the master bound still counts as a valid - convergence certificate. Positive slack means the master is - violating its own cuts (the nonconvex case), so the bound is not - trustworthy and the loop keeps running to `max_iter` rather than - stopping on a spurious crossing. -- `iteration_time_limit::Float64`: Wall-clock budget (seconds) for the - LOA iteration loop (seeds + main loop). `Inf` (default) is no limit; - each subproblem solve is capped to the budget left so one solve can't - overrun it. -- `time_limit::Float64`: Overall wall-clock cap (seconds) covering the - iteration loop AND the final committed solve. `Inf` (default) is no - cap; when set, the final solve gets only the budget left under it. +- `nlp_optimizer::O`: solver for the primary NLP. +- `mip_optimizer::P`: solver for the master MILP (default `nlp_optimizer`). +- `inner_method::R`: NLP reformulation — `BigM`, `MBM`, or `Hull`. +- `max_iter::Int`: max iterations after set-covering seeding. +- `M_value::T`: big-M for the disjunct OA cut gating term. +- `max_slack::T`: upper bound per slack variable. +- `oa_penalty::T`: penalty on slacks in the master objective. +- `convergence_tol::Float64`: relative gap tolerance for the early stop. +- `slack_tol::Float64`: max total slack for which the bound still counts + as converged (positive slack = nonconvex crossing, keep iterating). +- `iteration_time_limit::Float64`: budget (s) for the iteration loop. +- `time_limit::Float64`: overall budget (s) incl. the final solve + (default 3600; `Inf` disables). """ struct LOA{O, P, R, T} <: AbstractReformulationMethod nlp_optimizer::O @@ -78,11 +55,11 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod convergence_tol::Float64 = 1e-6, slack_tol::Float64 = 1e-4, iteration_time_limit::Float64 = Inf, - time_limit::Float64 = Inf + time_limit::Float64 = 3600.0 ) where {O, P, R <: AbstractReformulationMethod, T} - R <: Union{BigM, MBM} || error( - "LOA inner_method must be BigM or MBM (got $R). " * - "Hull and PSplit are not yet supported.") + R <: Union{BigM, MBM, Hull} || error( + "LOA inner_method must be BigM, MBM, or Hull (got $R). " * + "PSplit is not yet supported.") new{O, P, R, T}(nlp_optimizer, mip_optimizer, inner_method, max_iter, M_value, max_slack, oa_penalty, convergence_tol, slack_tol, @@ -93,16 +70,11 @@ end ################################################################################ # LOA MASTER ################################################################################ -# TODO: Move `_LOAMaster` to `src/datatypes.jl` alongside `GDPSubmodel` -# once the LOA API stabilizes. Keeping it co-located with the algorithm -# while the field set is still in flux. -# -# Bundles the LOA master MILP and the maps the algorithm needs to -# translate original-model refs into master-model refs. -# `objective_ref_map` is split from `variable_map` so an extension can -# map the objective's linearization variables differently from the -# constraint ones; in base the two maps are identical. -mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM} +# The LOA master MILP plus maps from original- to master-model refs. +# `objective_ref_map` splits from `variable_map` so an extension can map +# objective vars separately (identical in base); `disaggregator` is the +# master `_Hull` for Hull cuts, `nothing` for Big-M / MBM. +mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM, DG} model::M binary_map::BM variable_map::VM @@ -110,6 +82,7 @@ mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM} original_objective::OF alpha_oa::AO objective_ref_map::RM + disaggregator::DG end ################################################################################ @@ -128,23 +101,18 @@ _gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best ################################################################################ # MAIN ALGORITHM ################################################################################ -# LOA reformulation entry point: set-covering seeds, the master/NLP -# iteration loop, commit the best combination, and mark the model ready. -# This driver is model-agnostic — extensions override the inner solve -# steps (master build, NLP, cuts), not this loop. +# LOA entry point: set-covering seeds, the master/NLP loop, commit the +# best combination. Model-agnostic — extensions override the inner steps. function reformulate_model(model::JuMP.AbstractModel, method::LOA) _clear_reformulations(model) combinations = _set_covering_combinations(model) reformulate_model(model, method.inner_method) master = build_loa_master(model, method) - # Relax the original model's logical binaries once, permanently. The - # original model is only ever solved as the NLP (never as a MILP — - # the master is a separate copy), so its ZeroOne is never used. - # Stripping it here lets a pure NLP solver handle every solve and - # makes per-iteration fixing overwrite in place (no relax/unrelax, - # no fix/undo). Must follow build_loa_master so the master keeps its - # ZeroOne binaries. + # Relax the original's binaries once (it is only ever the NLP, never a + # MILP), so a pure NLP solver handles every solve and per-iteration + # fixing overwrites in place. After build_loa_master, which keeps the + # master's binaries. relax_logical_vars(model) JuMP.set_optimizer(model, method.nlp_optimizer) JuMP.set_silent(model) @@ -158,12 +126,9 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) best_result = nothing master_bound = nothing - # Initialization Procedure (Türkay & Grossmann 1996, sec. 2.2): - # solve K set-covering NLPs with cycling indicator combinations to - # seed the master with at least one OA cut per disjunct. - # `previous_result` carries the most recent FEASIBLE NLP primal - # forward as the warm start for the next NLP (T&G §2.3); NLPF - # solutions are skipped since their primal is slack-distorted. + # Seed the master with one OA cut per disjunct via the set-covering + # NLPs. `previous_result` warm-starts the next NLP from the last + # feasible primal (NLPF primals are slack-distorted, so skipped). previous_result = nothing for combination in combinations time() < loop_deadline || break @@ -180,19 +145,12 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) result.feasible && (previous_result = result) end - # Master/NLP loop. Each iteration solves the master MILP for a lower - # bound (`master_bound` is the `alpha_oa` auxiliary, NOT the penalized - # objective the slacks inflate), then solves the NLP at the master's - # combination and updates the incumbent. The loop exits on whichever - # comes first: convergence (the bound meets the incumbent within - # `convergence_tol` while total slack is below `slack_tol`), an - # infeasible master (every combination forbidden by a no-good cut), - # `max_iter`, or the time budget. The slack gate is what makes the - # convergence stop safe without a convexity flag: on a convex inner - # problem the slacks collapse to zero and the crossing is a real - # optimality proof; on a nonconvex one the master pays slack to - # violate its own cuts, the gate stays shut, and the loop runs on to - # `max_iter` instead of stopping at a spurious crossing. + # Master/NLP loop: solve the master for a bound (`master_bound` is + # `alpha_oa`, not the slack-penalized objective), solve the NLP at its + # combination, update the incumbent. Exit on convergence (bound meets + # incumbent within `convergence_tol` AND total slack below + # `slack_tol`), infeasible master, `max_iter`, or time. The slack gate + # keeps the stop safe: nonconvex crossings pay slack and keep going. converged = false for _ in 1:method.max_iter time() < loop_deadline || break @@ -242,15 +200,11 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) return end -# Report the final gap once the loop ends. `[converged]` = the loop hit -# its convergence test (the master bound met the incumbent within -# `convergence_tol` with total slack below `slack_tol`); `[limit hit]` = -# it stopped on `max_iter`, the time budget, or an exhausted master with -# a gap still open; `[no bound]` = the master never produced a bound -# (fell back to the best seed). `master_bound` is `alpha_oa`, a rigorous -# dual bound only for a convex (linear) inner problem; on a nonconvex -# model it can cross the incumbent, which the slack gate is meant to -# catch and the reported gap surfaces. +# Report the final gap. `[converged]` = passed the convergence test; +# `[limit hit]` = stopped on max_iter / time / exhausted master with a +# gap open; `[no bound]` = the master never produced a bound. The bound +# is rigorous only for a convex inner problem; on a nonconvex one it can +# cross the incumbent (a negative gap). function _report_loa_gap( best_objective::Real, best_result, @@ -284,17 +238,10 @@ end ################################################################################ # SET-COVERING INITIALIZATION ################################################################################ -# Türkay & Grossmann (1996), sec. 2.2: produce a minimal set of -# combinations that activates every indicator at least once, so the -# master starts with an OA cut per disjunct. Nested disjunctions are -# enumerated alongside top-level ones — inconsistent combinations -# (nested active under inactive parent) are handled by the no-good -# cut emitted from the infeasible NLP solve. -# -# `K = max(disjunction sizes)` combinations suffice: combination `k` -# activates the `k`-th indicator of each disjunction, cycling via -# `mod1` for disjunctions shorter than `K`. Every indicator gets -# activated at least once over k = 1..K. +# `K = max disjunction size` combinations that activate every indicator +# at least once: combination `k` activates the `k`-th indicator of each +# disjunction, cycling via `mod1`. Inconsistent nested combinations are +# caught by the no-good cut from the infeasible NLP. function _set_covering_combinations(model::JuMP.AbstractModel) LogicalRef = LogicalVariableRef{typeof(model)} indicator_lists = [collect(d.constraint.indicators) @@ -313,21 +260,17 @@ end ################################################################################ # MASTER CONSTRUCTION ################################################################################ -# True for linear constraint function types (scalar/vector variable refs -# and affine expressions). LOA master per Türkay & Grossmann (1996) is -# pure MILP; nonlinear `f`, `g`, `h_{ij}` enter as OA cuts after each -# NLP solve. +# True for linear constraint function types (variable refs / affine). +# Nonlinear functions enter the master as OA cuts, not at copy time. _is_linear_F(::Type{<:JuMP.AbstractVariableRef}) = true _is_linear_F(::Type{<:JuMP.GenericAffExpr}) = true _is_linear_F(::Type{<:AbstractVector{<:JuMP.AbstractVariableRef}}) = true _is_linear_F(::Type{<:AbstractVector{<:JuMP.GenericAffExpr}}) = true _is_linear_F(::Type) = false -# OVERRIDABLE. Build the LOA master MILP `M^b_{LA}` (Türkay & Grossmann -# 1996, eq. 12): copy decision variables and only the linear +# OVERRIDABLE. Build the master MILP: copy the variables and linear # constraints, install `alpha_oa` as the objective auxiliary. Nonlinear -# objective and disjunct constraints enter as OA cuts after each NLP -# solve. Returns an `_LOAMaster`. +# objective and disjunct constraints enter as OA cuts per NLP solve. function build_loa_master(model::JuMP.AbstractModel, method::LOA) original_objective = JuMP.objective_function(model) objective_sense = JuMP.objective_sense(model) @@ -359,17 +302,51 @@ function build_loa_master(model::JuMP.AbstractModel, method::LOA) alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") JuMP.@objective(master, objective_sense, alpha_oa) + disaggregator = _build_disaggregator(model, method.inner_method, + variable_map, binary_map) return _LOAMaster(master, binary_map, variable_map, objective_sense, - original_objective, alpha_oa, variable_map) + original_objective, alpha_oa, variable_map, disaggregator) +end + +# Master-space Hull disaggregator for the convex-hull cut emitter +# (`nothing` for Big-M / MBM). Remaps the recorded `(variable, indicator) +# -> disaggregated variable` map into master refs. +_build_disaggregator(::JuMP.AbstractModel, ::Union{BigM, MBM}, + variable_map, binary_map) = nothing +function _build_disaggregator( + model::JuMP.AbstractModel, + inner::Hull, + variable_map::AbstractDict, + binary_map::AbstractDict + ) + hull = _Hull(inner, Set{valtype(variable_map)}()) + for ((variable, indicator), disaggregated) in _disaggregations(model) + _record_disaggregation(hull, model, variable_map[variable], + binary_map[indicator], variable_map[disaggregated]) + end + return hull +end + +# OVERRIDABLE. Record one master-space disaggregation in the Hull +# disaggregator. Base keys it directly by `(variable, binary)`; the +# InfiniteOpt extension keys per support so per-support cut emission +# matches. +function _record_disaggregation( + hull::_Hull, + ::JuMP.AbstractModel, + variable, + binary, + disaggregated + ) + hull.disjunct_variables[(variable, binary)] = disaggregated + return end ################################################################################ # NLP SUBPROBLEM ################################################################################ -# Cap `target`'s solver to the wall-clock budget left before `deadline` -# (seconds, `time()` clock). No-op when `deadline` is `Inf` (no LOA time -# limit). Keeps one in-flight solve from overrunning the whole-loop -# budget; the loop also breaks once `deadline` passes. +# Cap `target`'s solver to the budget left before `deadline` so one solve +# can't overrun the loop. No-op when `deadline` is `Inf`. function _cap_remaining_time(target::JuMP.AbstractModel, deadline::Float64) isfinite(deadline) || return JuMP.set_time_limit_sec(target, max(0.0, deadline - time())) @@ -383,12 +360,9 @@ _restore_time_limit(model::JuMP.AbstractModel, ::Nothing) = _restore_time_limit(model::JuMP.AbstractModel, seconds::Real) = JuMP.set_time_limit_sec(model, seconds) -# Solve the primary NLP for a fixed combination. If feasible, read the -# primal point and objective. If infeasible, fall through to the NLPF -# (V&G 1990 eq. 8) approximation: a slacked version of the same problem -# that always solves, whose primal becomes the linearization site for -# OA cuts. The master still learns shape information from the failed -# combination instead of only adding a no-good cut. +# Solve the primary NLP at a fixed combination. If infeasible, fall +# through to NLPF (a slacked version that always solves) so the master +# still gets a linearization site, not just a no-good cut. function _solve_nlp( model::M, combination, @@ -421,18 +395,15 @@ end ################################################################################ # NLPF (FEASIBILITY SUBPROBLEM) ################################################################################ -# V&G 1990 eq. 8: when the primary NLP is infeasible at a fixed -# combination, copy the model, slack every scalar inequality with a -# single nonnegative slack `u`, minimize `u`, and return the resulting -# primal point as an approximate linearization site. The point -# satisfies equalities and variable bounds exactly; inequalities can -# be violated by at most `u`. +# When the primary NLP is infeasible, copy the model, slack every scalar +# inequality with one nonnegative `u`, minimize `u`, and return the +# primal as the linearization site (equalities/bounds exact, inequalities +# violated by at most `u`). function _solve_nlpf( model::M, combination, method::LOA; deadline::Float64 = Inf ) where {M <: JuMP.AbstractModel} - # The original model's logical binaries are permanently relaxed - # (see reformulate_model), so this copy is continuous — any NLP - # solver, including pure-NLP ones like Ipopt, can solve NLPF. + # The original's binaries are permanently relaxed, so this copy is + # continuous — any NLP solver (e.g. Ipopt) can solve NLPF. copy, ref_map = JuMP.copy_model(model) JuMP.set_optimizer(copy, method.nlp_optimizer) JuMP.set_silent(copy) @@ -454,12 +425,9 @@ function _solve_nlpf( JuMP.@objective(copy, Min, u) - # Pin the copy's indicator binaries to the combination. The infinite - # `with_fixed_combination` undoes the original's fix after each - # solve, so this NLPF copy arrives unfixed and its `nlpf_fix_on_copy` - # override re-pins it. The finite path leaves the original fixed in - # place, so the copy inherits the fix and the base `nlpf_fix_on_copy` - # is a no-op. + # Pin the copy's binaries to the combination. Finite: the original + # stays fixed so the copy inherits it (base no-op). Infinite: the + # original is unfixed each iteration, so the override re-pins here. for (indicator, value) in combination binary = _binary_on_copy( _indicator_to_binary(model)[indicator], ref_map) @@ -485,13 +453,9 @@ function _binary_on_copy(binary::JuMP.GenericAffExpr, ref_map) return 1.0 - ref_map[underlying] end -# OVERRIDABLE. Pin a copy-side binary to a combination value. Base -# no-op: the finite original stays fixed in place (its -# `with_fixed_combination` does not undo), so its NLPF copy inherits the -# fix. The InfiniteOpt extension overrides this — its original is -# cleaned up each iteration, so the copy needs pinning — using -# constraints rather than `JuMP.fix` to avoid force-deleting bounds on -# the relaxed copy. +# OVERRIDABLE. Pin a copy-side binary to a combination value. Base no-op +# (the finite copy inherits the original's fix); the InfiniteOpt +# extension re-pins via constraints. nlpf_fix_on_copy(copy, binary, value) = nothing _nlpf_should_slack(::Type{<:_MOI.LessThan}) = true @@ -517,13 +481,9 @@ function _nlpf_extract_primal(model::JuMP.AbstractModel, ref_map) return result end -# Fix `combination` in place, then run `f()`. No relax/restore (the -# model's logical binaries are relaxed once, permanently, in -# reformulate_model) and no fix/undo: `fix_combination` overwrites in -# place, so there is no per-iteration state to unwind — hence no -# try/finally. On a solve error the model is left fixed to the last -# combination; the next `fix_combination` (or `commit_combination`) -# overwrites it. +# Fix `combination` in place, then run `f()`. No undo: `fix_combination` +# overwrites in place and the binaries are permanently relaxed, so there +# is no per-iteration state to unwind. function with_fixed_combination( f, model::JuMP.AbstractModel, @@ -533,14 +493,9 @@ function with_fixed_combination( return f() end -# Iter-to-iter NLP warm start (T&G 1996 §2.3): seed the next primary -# NLP solve from the most recent FEASIBLE solution's primal point. -# No-op on the first seed iteration when `previous` is `nothing`, and -# no-op on iterations following an NLPF fall-through (caller does not -# update `previous_result` then), since NLPF's primal is slack- -# distorted and a worse start than the prior real NLP. Routes through -# the `set_linearization_start` dispatch so an extension can broadcast -# a vector-valued start across its variables. +# Iter-to-iter NLP warm start: seed the next NLP from the last FEASIBLE +# primal (no-op on the first seed and after an NLPF fall-through, whose +# primal is slack-distorted). Routes through `set_linearization_start`. function _set_nlp_warm_start(previous) previous === nothing && return for (variable, values) in previous.linearization_point @@ -566,11 +521,9 @@ function commit_combination( return end -# OVERRIDABLE. Apply the combination's fixes in place. No undo closure: -# `fix_indicator` uses `force = true` so each call overwrites the prior -# fix, and the model is permanently relaxed, so there is nothing to -# restore. An extension overrides this for vector-valued `Vector{Bool}` -# values (per-support pins reused in place across iterations). +# OVERRIDABLE. Apply the combination's fixes in place (force-fix, no +# undo). An extension overrides this for per-support `Vector{Bool}` +# values. function fix_combination(model::JuMP.AbstractModel, combination::AbstractDict) for (indicator, value) in combination fix_indicator(model, indicator, value) @@ -578,19 +531,9 @@ function fix_combination(model::JuMP.AbstractModel, combination::AbstractDict) return end -# OVERRIDABLE. Write the LOA linearization point into a variable's warm -# start. This is deliberately NOT a direct `JuMP.set_start_value` call. -# The linearization point is stored per-variable as a vector — length-1 -# for a scalar/finite variable, length-K for a variable carrying K -# support points (see `extract_solution`). For an InfiniteOpt infinite -# variable that per-support vector must be written to each of the K -# *transcribed* JuMP variables (`transformation_variable(v)` is an array -# of per-support refs); `JuMP.set_start_value` on the single infinite -# `GeneralVariableRef` cannot place a distinct start at each support. -# Base handles the scalar case by unwrapping `only(values)`; the -# extension overrides this seam to broadcast the vector across the -# transcribed instances. Hence the dispatch rather than a bare -# `set_start_value`. +# OVERRIDABLE. Warm-start a variable from the linearization point (stored +# as a per-support vector). Base unwraps the scalar; the InfiniteOpt +# extension broadcasts across the transcribed per-support refs. set_linearization_start(variable, values::AbstractVector) = JuMP.set_start_value(variable, only(values)) @@ -614,13 +557,9 @@ function _extract_combination( return combination end -# Read the master binary and round to `Bool`. Rounding is required (not -# defensive): a MILP solver returns binaries within its -# integer-feasibility tolerance — e.g. 2.75e-40 for a "0", 1.0000002 for -# a "1" — never exactly 0/1, so `Bool(value)` would throw `InexactError`. -# Not dispatched: the ref type is the same whether the indicator is -# finite or infinite; only `JuMP.value`'s return differs (scalar vs a -# per-support array), so we branch on the value and handle both here. +# Read the master binary and round to `Bool` (a MILP solver returns +# values within its integer tolerance, never exactly 0/1). Branches on +# the value: scalar (finite) or per-support array (infinite). function combination_val(binary_ref) val = JuMP.value(binary_ref) return val isa AbstractArray ? vec(round.(Bool, val)) : round(Bool, val) @@ -637,7 +576,8 @@ function add_oa_cuts( ) isempty(result.linearization_point) && return linearization = _linearize_at(master.original_objective, - result.linearization_point, master.objective_ref_map) + _objective_linearization_point(model, result.linearization_point), + master.objective_ref_map) _add_objective_cut( Val(master.objective_sense), master, linearization, method) _add_global_oa_cuts(model, master, result, method) @@ -645,13 +585,15 @@ function add_oa_cuts( return end -# Slacked objective cut (V&G 1990 eq. 6). For MIN, `lin ≤ α + σ` with -# `σ ≥ 0` penalized in the master objective so the slack drives to -# zero at convergence; the linearization is then `α ≥ lin` (standard -# OA bound). MAX is symmetric: `lin ≥ α − σ`. Without the slack, an -# accumulated bad linearization on a nonconvex objective can make the -# master infeasible — the disjunct and global cuts already carry -# slacks but the objective cut did not. +# OVERRIDABLE. The point at which the master objective is linearized. +# Base uses the raw point; an extension whose `original_objective` is +# transcribed or derived overrides this to match its shape. +_objective_linearization_point(::JuMP.AbstractModel, linearization_point) = + linearization_point + +# Slacked objective cut. MIN: `lin <= alpha_oa + sigma`; MAX symmetric. +# The slack (like the disjunct/global cuts) keeps a nonconvex objective +# linearization from making the master infeasible. function _add_objective_cut( sense_token::Val, master::_LOAMaster, @@ -667,19 +609,10 @@ _add_objective_cut_body(::Val{_MOI.MIN_SENSE}, master, lin, slack) = _add_objective_cut_body(::Val{_MOI.MAX_SENSE}, master, lin, slack) = JuMP.@constraint(master.model, lin >= master.alpha_oa - slack) -# Add the OA cut `g(x^l) + ∇g(x^l)^T (x − x^l) in con.set` for every -# nonlinear global constraint of `model` — the third cut class in -# Türkay & Grossmann (1996, eq. 12) alongside the objective and the -# disjunct cuts. Walks `JuMP.list_of_constraint_types`, skipping -# variable bounds, linear functions (already in the master), and -# BigM-reformulated forms (in `_reformulation_constraints`). -# `LessThan` and `GreaterThan` are supported; equalities and vector -# constraints are passed through to `JuMP.@constraint` as-is — -# valid for affine-after-linearization sets. -# -# Base (scalar-model) version. An extension that overrides `add_oa_cuts` -# supplies its own global-cut handling for constraints whose -# linearization is vector-valued or derived. +# OVERRIDABLE. Add an OA cut for every nonlinear global constraint, +# skipping variable bounds, linear functions, and reformulation +# constraints. Base (scalar) version; an extension overrides it for +# vector-valued / transcribed linearizations. function _add_global_oa_cuts( model::JuMP.AbstractModel, master::_LOAMaster, @@ -706,10 +639,9 @@ function _add_global_oa_cuts( return end -# OVERRIDABLE. Add V&G 1990 augmented-penalty OA cuts for each active -# disjunct's nonlinear constraints: fresh per-cut slack `σ_ik` with a -# penalty term in the master objective, and cut body -# `s (lin − rhs) − σ ≤ M(1 − y)`. +# Add slacked OA cuts for each active disjunct's nonlinear constraints. +# This driver (iterate active disjuncts and their constraints) is shared; +# the per-constraint emission is the OVERRIDABLE seam below. function add_disjunct_oa_cuts( model::JuMP.AbstractModel, master::_LOAMaster, @@ -724,20 +656,35 @@ function add_disjunct_oa_cuts( cref isa DisjunctConstraintRef || continue constraint = _disjunct_constraints(model)[ JuMP.index(cref)].constraint - _add_oa_cut_for_constraint( - constraint, master, master.binary_map[indicator], - result.linearization_point, master.variable_map, - method, penalty_sign) + _add_disjunct_constraint_oa_cuts(model, constraint, master, + master.binary_map[indicator], active, result, method, + penalty_sign) end end end -# Fresh nonnegative slack added to the master objective with the V&G -# 1990 penalty. Shared by the disjunct, global, and objective OA cuts -# so all three carry the same augmented-penalty treatment: a nonconvex -# linearization can be an invalid relaxation, and the penalized slack -# keeps the master feasible instead of letting accumulated cuts make -# it infeasible. +# OVERRIDABLE. Emit the OA cut(s) for one active disjunct constraint. +# Base emits a single cut; the InfiniteOpt extension fans out per +# support. `active` is unused in base but drives the extension's fan-out. +function _add_disjunct_constraint_oa_cuts( + ::JuMP.AbstractModel, + constraint::JuMP.AbstractConstraint, + master::_LOAMaster, + binary_ref, + active, + result::NamedTuple, + method::LOA, + penalty_sign::Int + ) + _add_oa_cut_for_constraint( + constraint, master, binary_ref, result.linearization_point, + master.variable_map, method, penalty_sign) + return +end + +# Fresh penalized slack added to the master objective. Shared by the +# disjunct, global, and objective cuts so a nonconvex (invalid) +# linearization can't make the master infeasible. function _penalized_slack(master::_LOAMaster, method::LOA, penalty_sign::Int) slack = JuMP.@variable(master.model, lower_bound = 0.0, upper_bound = method.max_slack) @@ -762,17 +709,41 @@ function _add_oa_cut_for_constraint( _is_linear_F(typeof(constraint.func)) && return linearization = _linearize_at( constraint.func, linearization_point, var_map) - _emit_disjunct_oa_cut( + _emit_disjunct_oa_cut(method.inner_method, constraint.set, master, binary_ref, linearization, method, penalty_sign) return end -# Emit the slacked, gated OA cut(s) for a disjunct constraint. The cut -# direction is set-determined (Duran-Grossmann 1986 OA convention): a -# `≤` constraint linearizes in place, a `≥` constraint is negated, and -# equality / interval emit both directions sharing one slack. No dual is -# needed — the constraint's set fixes the direction. +# Route the disjunct OA cut through the gating that matches the inner +# reformulation: Big-M / MBM gate with `M(1 - y)`; Hull emits the +# convex-hull cut on disaggregated variables (no big-M). +_emit_disjunct_oa_cut( + ::Union{BigM, MBM}, + set, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int + ) = + _emit_disjunct_oa_cut( + set, master, binary_ref, linearization, method, penalty_sign) +_emit_disjunct_oa_cut( + ::Hull, + set, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int + ) = + _emit_hull_oa_cut( + set, master, binary_ref, linearization, method, penalty_sign) + +# Emit the slacked, big-M-gated OA cut(s) for a disjunct constraint. The +# set fixes the direction: `<=` in place, `>=` negated, equality/interval +# both directions sharing one slack. function _emit_disjunct_oa_cut( set::_MOI.LessThan, master::_LOAMaster, @@ -851,12 +822,92 @@ function _emit_disjunct_oa_cut( return end -# Slacked global OA row(s) (V&G 1990). The nonconvex linearization may -# be an invalid relaxation, so each row carries a penalized slack -# rather than being a hard constraint — without this the accumulated -# global cuts make the master infeasible on nonconvex models. -# `EqualTo` / `Interval` get a two-sided pair sharing one slack. -# Unknown set types fall back to the prior hard cut. +# Convex-hull disjunct OA cut. `disaggregate_expression` rewrites the OA +# linearization `linearization - rhs` into disaggregated space (each +# variable becomes its per-disjunct copy, the constant scaled by the +# binary), giving the sharp cut that switches off at `y = 0` with no +# big-M. The slack handles the nonconvex case. Set dispatch mirrors +# `_emit_disjunct_oa_cut`. +function _emit_hull_oa_cut( + set::_MOI.LessThan, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int + ) + body = disaggregate_expression(master.model, + linearization - _set_rhs(set), binary_ref, master.disaggregator) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, body - slack <= 0) + return +end +function _emit_hull_oa_cut( + set::_MOI.GreaterThan, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int + ) + body = disaggregate_expression(master.model, + linearization - _set_rhs(set), binary_ref, master.disaggregator) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, body + slack >= 0) + return +end +function _emit_hull_oa_cut( + set::_MOI.EqualTo, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int + ) + body = disaggregate_expression(master.model, + linearization - _set_rhs(set), binary_ref, master.disaggregator) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, body - slack <= 0) + JuMP.@constraint(master.model, body + slack >= 0) + return +end +function _emit_hull_oa_cut( + set::_MOI.Interval, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int + ) + upper = disaggregate_expression(master.model, + linearization - set.upper, binary_ref, master.disaggregator) + lower = disaggregate_expression(master.model, + linearization - set.lower, binary_ref, master.disaggregator) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, upper - slack <= 0) + JuMP.@constraint(master.model, lower + slack >= 0) + return +end +# Fallback for vector / future set types: one upper-bound cut. +function _emit_hull_oa_cut( + set, + master::_LOAMaster, + binary_ref, + linearization, + method::LOA, + penalty_sign::Int + ) + body = disaggregate_expression(master.model, + linearization - _set_rhs(set), binary_ref, master.disaggregator) + slack = _penalized_slack(master, method, penalty_sign) + JuMP.@constraint(master.model, body - slack <= 0) + return +end + +# Slacked global OA row(s): each carries a penalized slack so a nonconvex +# (invalid) linearization can't make the master infeasible. EqualTo / +# Interval get a two-sided pair sharing one slack; unknown sets fall back +# to a hard cut. function _add_global_oa_row( master::_LOAMaster, lin, @@ -909,21 +960,14 @@ function _add_global_oa_row(master::_LOAMaster, lin, set, ::LOA, ::Int) return end -# Is this indicator active in the combination? A finite indicator carries -# a `Bool`; an infinite one carries a per-support `Vector{Bool}` (active -# at some supports, not others). Used to skip disjuncts that are off -# everywhere when emitting OA cuts. +# Is this indicator active anywhere? Finite: a `Bool`; infinite: a +# per-support `Vector{Bool}`. Skips disjuncts that are off everywhere. is_active(active::Bool) = active is_active(active::AbstractVector{Bool}) = any(active) ################################################################################ # LINEARIZATION & EXPRESSION CONVERSION ################################################################################ -# TODO: Move this section out of `loa.jl` (likely back to -# `src/utilities.jl`) when a second OA-style method lands and these -# helpers earn their generality. Currently only LOA uses them, so -# they live next to the algorithm that consumes them. - # First-order Taylor for a single var or affine expression mapped into # master space. function _linearize_at( @@ -1022,10 +1066,7 @@ _set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = _MOI.constant(s) _set_rhs(::Any) = 0.0 -# Unwrap a 1-element `Vector` to its scalar value; scalars pass -# through. `extract_solution` returns `Vector`s uniformly (length-1 for -# a scalar variable, length-K for a vector-valued one). AD pipelines -# and `set_start_value` need a scalar in the scalar case; vector-valued -# consumers slice out a scalar themselves. +# Unwrap a 1-element `Vector` to its scalar; scalars pass through +# (`extract_solution` returns length-1 vectors for scalar variables). _unwrap_scalar(v::Real) = v _unwrap_scalar(v::AbstractVector) = only(v) diff --git a/src/model.jl b/src/model.jl index 0f26a4f0..efd6ff08 100644 --- a/src/model.jl +++ b/src/model.jl @@ -83,6 +83,7 @@ _indicator_to_constraints(model::JuMP.AbstractModel) = gdp_data(model).indicator _constraint_to_indicator(model::JuMP.AbstractModel) = gdp_data(model).constraint_to_indicator _reformulation_variables(model::JuMP.AbstractModel) = gdp_data(model).reformulation_variables _reformulation_constraints(model::JuMP.AbstractModel) = gdp_data(model).reformulation_constraints +_disaggregations(model::JuMP.AbstractModel) = gdp_data(model).disaggregations _variable_bounds(model::JuMP.AbstractModel) = gdp_data(model).variable_bounds _solution_method(model::JuMP.AbstractModel) = gdp_data(model).solution_method # Get the current solution method _ready_to_optimize(model::JuMP.AbstractModel) = gdp_data(model).ready_to_optimize # Determine if the model is ready to call `optimize!` without a optimize hook diff --git a/src/reformulate.jl b/src/reformulate.jl index 9a3b65cb..86270104 100644 --- a/src/reformulate.jl +++ b/src/reformulate.jl @@ -24,6 +24,7 @@ function _clear_reformulations(model::JuMP.AbstractModel) delete.(model, _reformulation_variables(model)) empty!(gdp_data(model).reformulation_constraints) empty!(gdp_data(model).reformulation_variables) + empty!(gdp_data(model).disaggregations) empty!(gdp_data(model).variable_bounds) return end diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index e470bfbf..16a06d9f 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -389,8 +389,8 @@ function test_loa_time_limits() @test objective_value(model) ≈ 7.0 atol = 1e-4 model = build_model() - optimize!(model, - gdp_method = LOA(HiGHS.Optimizer; iteration_time_limit = 60.0)) + optimize!(model, gdp_method = LOA(HiGHS.Optimizer; + iteration_time_limit = 60.0, time_limit = Inf)) @test termination_status(model) == MOI.OPTIMAL @test objective_value(model) ≈ 7.0 atol = 1e-4 end @@ -419,6 +419,116 @@ function test_loa_no_feasible_incumbent() @test DP._ready_to_optimize(model) end +function test_loa_hull_linear() + # Same GDP as test_loa_solve_simple, but inner_method = Hull so the + # NLP and master carry disaggregated variables and the disjunct + # cuts are convex-hull cuts rather than big-M. Optimum unchanged: + # x = 7 via the second disjunct. + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + optimize!(model, gdp_method = LOA(HiGHS.Optimizer; + inner_method = Hull())) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 7.0 atol = 1e-4 +end + +function test_loa_hull_two_disjunctions() + # Two independent disjunctions under Hull; optimum x = 7, z = 5. + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, 0 <= z <= 10) + @variable(model, Y[1:2], Logical) + @variable(model, W[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @constraint(model, z <= 2, Disjunct(W[1])) + @constraint(model, z <= 5, Disjunct(W[2])) + @disjunction(model, W) + @objective(model, Max, x + z) + optimize!(model, gdp_method = LOA(HiGHS.Optimizer; + inner_method = Hull())) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 12.0 atol = 1e-4 +end + +function test_loa_hull_nonlinear_global() + # max x s.t. x^2 <= 25 (global), (x <= 3) ∨ (x <= 8) under Hull. + # Linear disjuncts enter the master as exact Hull perspectives; the + # global nonlinear enters via global OA cuts. Optimum: x = 5. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = GDPModel(juniper) + set_silent(model) + @variable(model, 0 <= x <= 10) + @constraint(model, x^2 <= 25) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 8, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + optimize!(model, gdp_method = LOA(juniper; + mip_optimizer = HiGHS.Optimizer, inner_method = Hull())) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 5.0 atol = 1e-3 +end + +function test_loa_hull_nonlinear_disjunct() + # Nonlinear disjunct constraint under Hull: this is the case big-M + # and Hull genuinely differ on. 0 <= x <= 10, Y1: x <= 3, + # Y2: x^2 <= 64. The convex-hull OA cut disaggregates the + # linearization of x^2. Optimum: x = 8 via Y2. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = GDPModel(juniper) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x^2 <= 64, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + optimize!(model, gdp_method = LOA(juniper; + mip_optimizer = HiGHS.Optimizer, inner_method = Hull())) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-3 +end + +function test_loa_hull_complement_nonlinear() + # Hull with a complement indicator Y2 ≡ ¬Y1 and a nonlinear + # disjunct under Y2. The disaggregator is keyed by the complement + # binary `1 - y1` (an AffExpr); the cut must disaggregate against + # it. Optimum: x = 8 via Y2. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = GDPModel(juniper) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y1, Logical) + @variable(model, Y2, Logical, logical_complement = Y1) + @constraint(model, x <= 3, Disjunct(Y1)) + @constraint(model, x^2 <= 64, Disjunct(Y2)) + @disjunction(model, [Y1, Y2]) + @objective(model, Max, x) + optimize!(model, gdp_method = LOA(juniper; + mip_optimizer = HiGHS.Optimizer, inner_method = Hull())) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-3 +end + @testset "LOA" begin test_loa_datatype() test_set_covering_combos() @@ -439,4 +549,9 @@ end test_loa_iteration_loop() test_loa_time_limits() test_loa_no_feasible_incumbent() + test_loa_hull_linear() + test_loa_hull_two_disjunctions() + test_loa_hull_nonlinear_global() + test_loa_hull_nonlinear_disjunct() + test_loa_hull_complement_nonlinear() end diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index 707833fa..e1011be6 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -797,7 +797,7 @@ function test_loa_infinite_nonlinear_global() # Disjunct Y[2] permits x up to 8 but the global x^2 <= 25 caps # x at 5. The per-support global transcribes to an `AbstractArray` # of scalar constraints, so this exercises the array branch of - # `_add_global_oa_cuts_infinite`. Without the global cut the + # `_add_global_oa_cuts`. Without the global cut the # master would allow x = 8 and report 8.0; the binding optimum # is ∫5 dt = 5. ipopt = optimizer_with_attributes(Ipopt.Optimizer, @@ -954,7 +954,7 @@ function test_loa_infinite_aggregate_global() # (y >= 1) ∨ (y >= 3), 0 <= x, y <= 10 over t ∈ [0, 1]. # The aggregate global transcribes to a single scalar (the # measure is flattened), exercising the non-array branch of - # `_add_global_oa_cuts_infinite`. Y[1] (y >= 1) is the cheaper + # `_add_global_oa_cuts`. Y[1] (y >= 1) is the cheaper # disjunct: x = 1 satisfies x >= y and ∫x^2 = 1 <= 4, giving # objective ∫1 dt = 1. ipopt = optimizer_with_attributes(Ipopt.Optimizer, @@ -1010,6 +1010,105 @@ function test_loa_infinite_iteration_loop() @test objective_value(model) ≈ -10.0 atol = 1e-3 end +function test_loa_infinite_hull_linear() + # InfiniteOpt LOA with inner_method = Hull. Linear disjuncts enter + # the master as exact per-support Hull perspectives over + # disaggregated infinite variables. max ∫x dt, Y1: x <= 3, + # Y2: x <= 8. Optimum: x = 8 across t → ∫8 dt = 8. + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 8, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, ∫(x, t)) + optimize!(model, gdp_method = LOA(HiGHS.Optimizer; + inner_method = Hull())) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-2 +end + +function test_loa_infinite_hull_nonlinear_disjunct() + # The infinite Hull case big-M differs from: a NONLINEAR constraint + # on an infinite variable inside a disjunct, under a plain infinite + # indicator. The convex-hull OA cut is built per support over the + # disaggregated infinite variable (point-evaluated). max ∫x dt, + # Y1: x <= 3, Y2: x^2 <= 64. Optimum: x = 8 via Y2. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = InfiniteGDPModel(juniper) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x^2 <= 64, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, ∫(x, t)) + optimize!(model, gdp_method = LOA(juniper; + mip_optimizer = HiGHS.Optimizer, inner_method = Hull())) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-2 +end + +function test_loa_infinite_hull_complement_nonlinear() + # Hull with a complement indicator Y2 ≡ ¬Y1 gating a nonlinear + # constraint on an infinite variable. The per-support disaggregator + # is keyed by the point-evaluated complement binary `1 - y1(t_k)` + # (an AffExpr). max ∫x dt, Y1: x <= 3, Y2: x^2 <= 64. Optimum 8. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = InfiniteGDPModel(juniper) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 5) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y1, Logical) + @variable(model, Y2, Logical, logical_complement = Y1) + @constraint(model, x <= 3, Disjunct(Y1)) + @constraint(model, x^2 <= 64, Disjunct(Y2)) + @disjunction(model, [Y1, Y2]) + @objective(model, Max, ∫(x, t)) + optimize!(model, gdp_method = LOA(juniper; + mip_optimizer = HiGHS.Optimizer, inner_method = Hull())) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-2 +end + +function test_loa_infinite_hull_finite_var() + # A FINITE variable disaggregated inside a nonlinear disjunct + # constraint gated by an INFINITE indicator. The fan-out slices the + # binary per support, so the disaggregator must key the finite + # variable's copy by the per-support binary `y(t_k)` (driven off the + # binary, not the variable). max ∫x dt + w over t ∈ [0,1]: + # Y1: x^2 + w^2 <= 25 Y2: x <= 0 + # Y1 is active; x(t) = w = sqrt(12.5), so the optimum is + # ∫sqrt(12.5) dt + sqrt(12.5) = 2 sqrt(12.5) ≈ 7.0711. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = InfiniteGDPModel(juniper) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 4) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, 0 <= w <= 10) + @variable(model, Y[1:2], InfiniteLogical(t)) + @constraint(model, x^2 + w^2 <= 25, Disjunct(Y[1])) + @constraint(model, x <= 0, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, ∫(x, t) + w) + optimize!(model, gdp_method = LOA(juniper; + mip_optimizer = HiGHS.Optimizer, inner_method = Hull())) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 7.0711 atol = 1e-2 +end + @testset "InfiniteDisjunctiveProgramming" begin @testset "Model" begin @@ -1088,6 +1187,10 @@ end test_loa_infinite_multidim_parameter() test_loa_infinite_nlpf_infeasible_disjunct() test_loa_infinite_iteration_loop() + test_loa_infinite_hull_linear() + test_loa_infinite_hull_nonlinear_disjunct() + test_loa_infinite_hull_complement_nonlinear() + test_loa_infinite_hull_finite_var() else @info "Skipping InfiniteOpt LOA tests: " * "JuMP.copy_model(::InfiniteModel) unavailable " * From 9896a78018faa49bb88ba5542bc0fc54d755d393 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Tue, 30 Jun 2026 16:11:00 -0400 Subject: [PATCH 57/72] LOA Hull inner method --- ext/InfiniteDisjunctiveProgramming.jl | 90 ++-- src/datatypes.jl | 19 +- src/hull.jl | 7 +- src/loa.jl | 426 ++++++------------ src/model.jl | 1 - src/reformulate.jl | 1 - test/constraints/loa.jl | 23 +- .../InfiniteDisjunctiveProgramming.jl | 4 +- 8 files changed, 200 insertions(+), 371 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index b155ee26..0381caf5 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -342,7 +342,7 @@ _per_support_values(variable::InfiniteOpt.GeneralVariableRef) = # Read per-support values from the transformation backend, keyed by # InfiniteOpt vars. Skips fixed vars. The objective-side translation # (transcribe-then-AD when the objective has aggregate refs) lives in -# the `_objective_linearization_point(::InfiniteModel, ...)` override +# the `objective_linearization_point(::InfiniteModel, ...)` override # below — base `extract_solution` doesn't need to anticipate it. function DP.extract_solution(model::InfiniteOpt.InfiniteModel) return Dict(var => _per_support_values(var) @@ -440,7 +440,7 @@ function DP.replace_variables_in_constraint( end # Bool active arises from `_set_covering_combinations`, which keys -# combinations on `LogicalVariableRef → Bool` regardless of whether +# combinations on `LogicalVariableRef -> Bool` regardless of whether # the indicator is infinite. Broadcast over all supports for an # infinite indicator; for a finite (or point-variable) ref, fall # through to the base scalar dispatch. @@ -524,15 +524,6 @@ end _find_infinite_var(v::InfiniteOpt.GeneralVariableRef) = isempty(InfiniteOpt.parameter_refs(v)) ? nothing : v -function _find_infinite_var(expr::JuMP.GenericAffExpr) - for v in keys(expr.terms) - v isa InfiniteOpt.GeneralVariableRef || continue - result = _find_infinite_var(v) - result === nothing || return result - end - return nothing -end - function _find_infinite_var(expr) found = Ref{Any}(nothing) DP._interrogate_variables(expr) do v @@ -603,7 +594,7 @@ function _at_support( return result end -# Map transcribed input JuMP var → master point variable, used as +# Map transcribed input JuMP var -> master point variable, used as # `objective_ref_map`. Needed because MOI AD can't walk a `MeasureRef`, # so a measure objective is transcribed, AD'd flat, and the gradient # mapped back to master point vars. Per-support disjunct cuts skip this @@ -642,7 +633,7 @@ end # expression directly; aggregate ones are transcribed flat and mapped # back via `_transcribed_to_master_point`. function DP.build_loa_master( - model::InfiniteOpt.InfiniteModel, method::DP.LOA + model::InfiniteOpt.InfiniteModel, method::DP.LOA, sink = nothing )::DP._LOAMaster # Keep only linear, non-aggregate constraints. Nonlinear and # aggregate-wrapped ones (e.g. `∫(x^2,t) ≤ c`) re-enter as OA cuts, @@ -719,7 +710,7 @@ function DP.build_loa_master( return DP._LOAMaster(master, binary_map, variable_map, objective_sense, original_objective, alpha_oa, objective_ref_map, DP._build_disaggregator( - model, method.inner_method, variable_map, binary_map)) + model, method.inner_method, variable_map, binary_map, sink)) end # Record one master-space disaggregation for `InfiniteModel`. The Hull @@ -728,7 +719,7 @@ end # support — `disaggregate_expression` then substitutes the point # disaggregated variable per support. Finite (parameter-free) entries # key once, unsliced. -function DP._record_disaggregation( +function DP.record_disaggregation( hull::DP._Hull, ::InfiniteOpt.InfiniteModel, variable, @@ -834,7 +825,7 @@ end # (aggregate, expects a transcribed-`JuMP.VariableRef`-keyed scalar # dict). Translate the base per-support `Vector`-valued point into # whichever shape `original_objective` needs. -function DP._objective_linearization_point( +function DP.objective_linearization_point( model::InfiniteOpt.InfiniteModel, linearization_point::AbstractDict ) @@ -854,7 +845,7 @@ end # Constraints with infinite-parameter dependence (e.g. `x(t) ≥ 0`) # transcribe to a per-support `AbstractArray` of scalar expressions — # one OA cut is emitted per support. -function DP._add_global_oa_cuts( +function DP.add_global_oa_cuts( model::InfiniteOpt.InfiniteModel, master::DP._LOAMaster, result::NamedTuple, @@ -907,9 +898,9 @@ end # `add_disjunct_oa_cuts` driver calls). Non-aggregate constraints fan out # per support via `_infinite_cut_info`; aggregate ones (`MeasureRef`) are # transcribed flat and handed to the base `_add_oa_cut_for_constraint`. -# Transcription is rebuilt per aggregate constraint, not cached — a rare -# path (Hull errors on it), so the redundancy is a non-issue. -function DP._add_disjunct_constraint_oa_cuts( +# `cache` memoizes the transcription maps once per pass (lazily, on the +# first aggregate constraint) so they are not rebuilt per constraint. +function DP.add_disjunct_constraint_oa_cuts( model::InfiniteOpt.InfiniteModel, constraint::JuMP.AbstractConstraint, master::DP._LOAMaster, @@ -917,18 +908,21 @@ function DP._add_disjunct_constraint_oa_cuts( active, result::NamedTuple, method::DP.LOA, - penalty_sign::Int + penalty_sign::Int, + cache ) if _has_aggregate_ref(constraint.func) - InfiniteOpt.build_transformation_backend!(model) + if cache[] === nothing + InfiniteOpt.build_transformation_backend!(model) + cache[] = (_transcribe_linearization_point( + model, result.linearization_point), + _transcribed_to_master_point(model, master.variable_map)) + end + transcribed_xk, transcribed_to_master = cache[] transcribed_func = InfiniteOpt.transformation_expression( constraint.func) transcribed_constraint = JuMP.ScalarConstraint( transcribed_func, constraint.set) - transcribed_xk = _transcribe_linearization_point( - model, result.linearization_point) - transcribed_to_master = _transcribed_to_master_point( - model, master.variable_map) for (point_binary, _, _) in _infinite_cut_info(binary_ref, active, transcribed_func, result.linearization_point, master.variable_map) @@ -986,36 +980,12 @@ function DP.fix_combination( end end -# Fix the combination, run `f()`, then always undo. The infinite path's -# per-support point-equality pins must be cleared between iterations, so -# this needs the `try/finally` the finite base omits. -function DP.with_fixed_combination( - f, model::InfiniteOpt.InfiniteModel, combination::AbstractDict - ) - undo = DP.fix_combination(model, combination) - try - return f() - finally - undo() - end -end - -# Pin a copy-side binary on an NLPF copy of an `InfiniteModel`. The -# infinite `with_fixed_combination` undoes the original's fix before the -# NLPF fall-through, so the copy arrives unfixed and is pinned here. The -# copy is discarded after the solve, so a point-equality constraint -# needs no teardown; using constraints rather than `JuMP.fix` avoids -# force-deleting bounds on the relaxed copy (whose bound refs may not -# survive `copy_model`). -function DP.nlpf_fix_on_copy( - copy::InfiniteOpt.InfiniteModel, binary, value::Bool - ) - JuMP.@constraint(copy, binary == (value ? 1.0 : 0.0)) - return -end -function DP.nlpf_fix_on_copy( - copy::InfiniteOpt.InfiniteModel, binary, value::AbstractVector{Bool} - ) +# Per-support fix for an infinite logical's `Vector{Bool}` value: an +# equality at each support. `with_fixed_combination` undid the original's +# fix before the NLPF fall-through, so this is the only fix on the copy. +# Equalities (not `JuMP.fix`) avoid force-deleting the relaxed copy's +# bounds, whose refs may not survive `copy_model`. +function DP._fix_binary_on_copy(copy, binary, value::AbstractVector) underlying = binary isa JuMP.GenericAffExpr ? only(keys(binary.terms)) : binary for (k, support) in enumerate(_supports_of(underlying)) @@ -1086,12 +1056,4 @@ _add_to_transcribed_dict( ) = (d[ts] = values[1]; nothing) -# Finite var: single transcribed ref, scalar value (feas-side path) -_add_to_transcribed_dict( - d::AbstractDict, - ts::JuMP.AbstractVariableRef, - value::Real - ) = - (d[ts] = value; nothing) - end diff --git a/src/datatypes.jl b/src/datatypes.jl index 14b96ab0..11e7afd7 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -417,8 +417,11 @@ constraints. """ struct Hull{T} <: AbstractReformulationMethod value::T - function Hull(ϵ::T = 1e-6) where {T} - new{T}(ϵ) + # Internal: LOA installs a Dict here to collect the disaggregation + # map during reformulation; `nothing` for every other use. + sink::Any + function Hull(ϵ::T = 1e-6; sink = nothing) where {T} + new{T}(ϵ, sink) end end @@ -427,11 +430,13 @@ mutable struct _Hull{V <: JuMP.AbstractVariableRef, T} <: AbstractReformulationM value::T disjunction_variables::Dict{V, Vector{V}} disjunct_variables::Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V} + sink::Any function _Hull(method::Hull{T}, vrefs::Set{V}) where {T, V <: JuMP.AbstractVariableRef} new{V, T}( method.value, - Dict{V, Vector{V}}(vref => V[] for vref in vrefs), - Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V}() + Dict{V, Vector{V}}(vref => V[] for vref in vrefs), + Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V}(), + method.sink ) end end @@ -596,11 +601,6 @@ mutable struct GDPData{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, C reformulation_variables::Vector{V} reformulation_constraints::Vector{C} - # Hull disaggregated variable for each (original variable, indicator). - # Recorded so logic-based OA can rebuild convex-hull cuts after the - # reformulation's temporary `_Hull` disaggregation state is discarded. - disaggregations::Dict{Tuple{V, LogicalVariableRef{M}}, V} - # Solution data solution_method::Union{Nothing, AbstractSolutionMethod} ready_to_optimize::Bool @@ -619,7 +619,6 @@ mutable struct GDPData{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, C Dict{V, Tuple{T, T}}(), Vector{V}(), Vector{C}(), - Dict{Tuple{V, LogicalVariableRef{M}}, V}(), nothing, false, ) diff --git a/src/hull.jl b/src/hull.jl index a91a9d65..9075799f 100644 --- a/src/hull.jl +++ b/src/hull.jl @@ -42,8 +42,8 @@ function _disaggregate_variable( #temp storage push!(method.disjunction_variables[vref], dvref) method.disjunct_variables[vref, bvref] = dvref - #persist for logic-based OA, which needs the map after reformulation - _disaggregations(model)[(vref, lvref)] = dvref + #record into the LOA sink when one is installed (LOA-Hull only) + method.sink === nothing || (method.sink[(vref, lvref)] = dvref) #create bounding constraints dvname = JuMP.name(dvref) lbname = isempty(dvname) ? "" : "$(dvname)_lower_bound" @@ -256,7 +256,8 @@ function reformulate_disjunction(model::JuMP.AbstractModel, disj::Disjunction, m return ref_cons end function reformulate_disjunction(model::JuMP.AbstractModel, disj::Disjunction, method::_Hull) - return reformulate_disjunction(model, disj, Hull(method.value)) + return reformulate_disjunction(model, disj, + Hull(method.value; sink = method.sink)) end function reformulate_disjunct_constraint( diff --git a/src/loa.jl b/src/loa.jl index ef41b127..ff722d84 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -1,10 +1,6 @@ ################################################################################ # LOGIC-BASED OUTER APPROXIMATION (LOA) ################################################################################ -# Iterate a primary NLP (inner-method reformulation, binaries fixed) and a -# master MILP accumulating OA and no-good cuts. Infeasible NLPs fall -# through to a slacked feasibility problem (NLPF) for the cut site. -################################################################################ ################################################################################ # METHOD TYPE @@ -106,13 +102,14 @@ _gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best function reformulate_model(model::JuMP.AbstractModel, method::LOA) _clear_reformulations(model) combinations = _set_covering_combinations(model) - reformulate_model(model, method.inner_method) - - master = build_loa_master(model, method) - # Relax the original's binaries once (it is only ever the NLP, never a - # MILP), so a pure NLP solver handles every solve and per-iteration - # fixing overwrites in place. After build_loa_master, which keeps the - # master's binaries. + # Hull needs the disaggregation map; thread a LOA-owned sink through + # the inner reformulation to collect it (kept off GDPData). + inner, sink = _loa_inner_method(model, method.inner_method) + reformulate_model(model, inner) + + master = build_loa_master(model, method, sink) + # The original is only ever the NLP (build_loa_master keeps the + # master's binaries), so relax it once; fixing overwrites in place. relax_logical_vars(model) JuMP.set_optimizer(model, method.nlp_optimizer) JuMP.set_silent(model) @@ -126,9 +123,8 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) best_result = nothing master_bound = nothing - # Seed the master with one OA cut per disjunct via the set-covering - # NLPs. `previous_result` warm-starts the next NLP from the last - # feasible primal (NLPF primals are slack-distorted, so skipped). + # Seed the master with an OA cut per set-covering combination, each + # NLP warm-started from the last feasible primal. previous_result = nothing for combination in combinations time() < loop_deadline || break @@ -145,12 +141,9 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) result.feasible && (previous_result = result) end - # Master/NLP loop: solve the master for a bound (`master_bound` is - # `alpha_oa`, not the slack-penalized objective), solve the NLP at its - # combination, update the incumbent. Exit on convergence (bound meets - # incumbent within `convergence_tol` AND total slack below - # `slack_tol`), infeasible master, `max_iter`, or time. The slack gate - # keeps the stop safe: nonconvex crossings pay slack and keep going. + # Master/NLP loop: `alpha_oa` is the bound, the NLP refines the + # incumbent. Exit on convergence (bound meets incumbent and slack + # settled), infeasible master, max_iter, or time. converged = false for _ in 1:method.max_iter time() < loop_deadline || break @@ -200,11 +193,8 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) return end -# Report the final gap. `[converged]` = passed the convergence test; -# `[limit hit]` = stopped on max_iter / time / exhausted master with a -# gap open; `[no bound]` = the master never produced a bound. The bound -# is rigorous only for a convex inner problem; on a nonconvex one it can -# cross the incumbent (a negative gap). +# Report the final gap. The bound is rigorous only for a convex inner +# problem; on a nonconvex one it can cross the incumbent (negative gap). function _report_loa_gap( best_objective::Real, best_result, @@ -271,7 +261,13 @@ _is_linear_F(::Type) = false # OVERRIDABLE. Build the master MILP: copy the variables and linear # constraints, install `alpha_oa` as the objective auxiliary. Nonlinear # objective and disjunct constraints enter as OA cuts per NLP solve. -function build_loa_master(model::JuMP.AbstractModel, method::LOA) +# `sink` is the disaggregation map collected by the inner Hull +# reformulation (`nothing` for Big-M / MBM). +function build_loa_master( + model::JuMP.AbstractModel, + method::LOA, + sink = nothing + ) original_objective = JuMP.objective_function(model) objective_sense = JuMP.objective_sense(model) variable_type = JuMP.variable_ref_type(typeof(model)) @@ -303,25 +299,38 @@ function build_loa_master(model::JuMP.AbstractModel, method::LOA) JuMP.@objective(master, objective_sense, alpha_oa) disaggregator = _build_disaggregator(model, method.inner_method, - variable_map, binary_map) + variable_map, binary_map, sink) return _LOAMaster(master, binary_map, variable_map, objective_sense, original_objective, alpha_oa, variable_map, disaggregator) end +# The inner reformulation method LOA runs, plus the disaggregation sink to +# collect (Big-M / MBM need none). For Hull, return a sink-carrying copy +# so the reformulation records its `(variable, indicator) -> disaggregated +# variable` map into a fresh LOA-owned `Dict`. +_loa_inner_method(::JuMP.AbstractModel, inner::Union{BigM, MBM}) = + (inner, nothing) +function _loa_inner_method(model::JuMP.AbstractModel, inner::Hull) + V = JuMP.variable_ref_type(typeof(model)) + sink = Dict{Tuple{V, LogicalVariableRef{typeof(model)}}, V}() + return (Hull(inner.value; sink = sink), sink) +end + # Master-space Hull disaggregator for the convex-hull cut emitter -# (`nothing` for Big-M / MBM). Remaps the recorded `(variable, indicator) -# -> disaggregated variable` map into master refs. +# (`nothing` for Big-M / MBM). Remaps the collected `(variable, indicator) +# -> disaggregated variable` sink into master refs. _build_disaggregator(::JuMP.AbstractModel, ::Union{BigM, MBM}, - variable_map, binary_map) = nothing + variable_map, binary_map, sink) = nothing function _build_disaggregator( model::JuMP.AbstractModel, inner::Hull, variable_map::AbstractDict, - binary_map::AbstractDict + binary_map::AbstractDict, + sink ) hull = _Hull(inner, Set{valtype(variable_map)}()) - for ((variable, indicator), disaggregated) in _disaggregations(model) - _record_disaggregation(hull, model, variable_map[variable], + for ((variable, indicator), disaggregated) in sink + record_disaggregation(hull, model, variable_map[variable], binary_map[indicator], variable_map[disaggregated]) end return hull @@ -331,7 +340,7 @@ end # disaggregator. Base keys it directly by `(variable, binary)`; the # InfiniteOpt extension keys per support so per-support cut emission # matches. -function _record_disaggregation( +function record_disaggregation( hull::_Hull, ::JuMP.AbstractModel, variable, @@ -373,11 +382,9 @@ function _solve_nlp( primary = with_fixed_combination(model, combination) do JuMP.optimize!(model, ignore_optimize_hook = true) if JuMP.is_solved_and_feasible(model) - lin_point = extract_solution(model) - objective_val = JuMP.objective_value(model) return (combination = combination, - linearization_point = lin_point, - objective = objective_val, feasible = true) + linearization_point = extract_solution(model), + objective = JuMP.objective_value(model), feasible = true) end return nothing end @@ -400,7 +407,10 @@ end # primal as the linearization site (equalities/bounds exact, inequalities # violated by at most `u`). function _solve_nlpf( - model::M, combination, method::LOA; deadline::Float64 = Inf + model::M, + combination, + method::LOA; + deadline::Float64 = Inf ) where {M <: JuMP.AbstractModel} # The original's binaries are permanently relaxed, so this copy is # continuous — any NLP solver (e.g. Ipopt) can solve NLPF. @@ -425,14 +435,9 @@ function _solve_nlpf( JuMP.@objective(copy, Min, u) - # Pin the copy's binaries to the combination. Finite: the original - # stays fixed so the copy inherits it (base no-op). Infinite: the - # original is unfixed each iteration, so the override re-pins here. - for (indicator, value) in combination - binary = _binary_on_copy( - _indicator_to_binary(model)[indicator], ref_map) - nlpf_fix_on_copy(copy, binary, value) - end + # Fix the combination on the copy so NLPF solves at the chosen + # binaries, not by leaning on the copy inheriting the original's fix. + fix_combination_on_copy(copy, model, combination, ref_map) JuMP.optimize!(copy, ignore_optimize_hook = true) JuMP.has_values(copy) || return nothing @@ -443,20 +448,28 @@ function _solve_nlpf( objective = Inf, feasible = false) end -# Translate a binary reference from the original model to its -# counterpart on the copy. Direct refs go through `ref_map`; -# complement-form `1 - y_orig` rebuilds as `1 - ref_map[y_orig]`. -_binary_on_copy(binary::JuMP.AbstractVariableRef, ref_map) = - ref_map[binary] -function _binary_on_copy(binary::JuMP.GenericAffExpr, ref_map) - underlying = only(keys(binary.terms)) - return 1.0 - ref_map[underlying] +# Fix each indicator's binary on the NLPF copy at its combination value, +# mapped into copy space. Explicit so NLPF is self-contained rather than +# relying on the copy inheriting the original's fix. +function fix_combination_on_copy(copy, model, combination, ref_map) + for (indicator, value) in combination + binary = _binary_on_copy( + _indicator_to_binary(model)[indicator], ref_map) + _fix_binary_on_copy(copy, binary, value) + end + return end -# OVERRIDABLE. Pin a copy-side binary to a combination value. Base no-op -# (the finite copy inherits the original's fix); the InfiniteOpt -# extension re-pins via constraints. -nlpf_fix_on_copy(copy, binary, value) = nothing +# Map an original binary (or its `1 - y` complement) into NLPF-copy space. +_binary_on_copy(binary::JuMP.AbstractVariableRef, ref_map) = ref_map[binary] +_binary_on_copy(binary::JuMP.GenericAffExpr, ref_map) = + 1.0 - ref_map[only(keys(binary.terms))] + +# OVERRIDABLE. Fix one binary on the copy at a scalar value via an +# equality (the copy is discarded, so the fix needs no teardown). The +# InfiniteOpt extension adds a per-support method for a `Vector{Bool}`. +_fix_binary_on_copy(copy, binary, value::Bool) = + JuMP.@constraint(copy, binary == (value ? 1.0 : 0.0)) _nlpf_should_slack(::Type{<:_MOI.LessThan}) = true _nlpf_should_slack(::Type{<:_MOI.GreaterThan}) = true @@ -481,16 +494,20 @@ function _nlpf_extract_primal(model::JuMP.AbstractModel, ref_map) return result end -# Fix `combination` in place, then run `f()`. No undo: `fix_combination` -# overwrites in place and the binaries are permanently relaxed, so there -# is no per-iteration state to unwind. +# Fix `combination`, run `f()`, then undo. The finite undo is a no-op +# (fixes overwrite in place); the infinite `fix_combination` clears its +# per-support pins. function with_fixed_combination( f, model::JuMP.AbstractModel, combination::AbstractDict ) - fix_combination(model, combination) - return f() + undo = fix_combination(model, combination) + try + return f() + finally + undo() + end end # Iter-to-iter NLP warm start: seed the next NLP from the last FEASIBLE @@ -521,14 +538,14 @@ function commit_combination( return end -# OVERRIDABLE. Apply the combination's fixes in place (force-fix, no -# undo). An extension overrides this for per-support `Vector{Bool}` -# values. +# OVERRIDABLE. Apply the combination's fixes in place and return an undo +# closure (a no-op here, since the finite fixes overwrite in place). An +# extension overrides this for per-support `Vector{Bool}` values. function fix_combination(model::JuMP.AbstractModel, combination::AbstractDict) for (indicator, value) in combination fix_indicator(model, indicator, value) end - return + return () -> nothing end # OVERRIDABLE. Warm-start a variable from the linearization point (stored @@ -538,7 +555,7 @@ set_linearization_start(variable, values::AbstractVector) = JuMP.set_start_value(variable, only(values)) ################################################################################ -# COMBO EXTRACTION (master → NLP) +# COMBO EXTRACTION (master -> NLP) ################################################################################ # Read each indicator's binary value from the master MILP solution. # `combination_val` returns a `Bool` (finite indicator) or per-support @@ -576,11 +593,11 @@ function add_oa_cuts( ) isempty(result.linearization_point) && return linearization = _linearize_at(master.original_objective, - _objective_linearization_point(model, result.linearization_point), + objective_linearization_point(model, result.linearization_point), master.objective_ref_map) _add_objective_cut( Val(master.objective_sense), master, linearization, method) - _add_global_oa_cuts(model, master, result, method) + add_global_oa_cuts(model, master, result, method) add_disjunct_oa_cuts(model, master, result, method) return end @@ -588,7 +605,7 @@ end # OVERRIDABLE. The point at which the master objective is linearized. # Base uses the raw point; an extension whose `original_objective` is # transcribed or derived overrides this to match its shape. -_objective_linearization_point(::JuMP.AbstractModel, linearization_point) = +objective_linearization_point(::JuMP.AbstractModel, linearization_point) = linearization_point # Slacked objective cut. MIN: `lin <= alpha_oa + sigma`; MAX symmetric. @@ -613,7 +630,7 @@ _add_objective_cut_body(::Val{_MOI.MAX_SENSE}, master, lin, slack) = # skipping variable bounds, linear functions, and reformulation # constraints. Base (scalar) version; an extension overrides it for # vector-valued / transcribed linearizations. -function _add_global_oa_cuts( +function add_global_oa_cuts( model::JuMP.AbstractModel, master::_LOAMaster, result::NamedTuple, @@ -641,7 +658,8 @@ end # Add slacked OA cuts for each active disjunct's nonlinear constraints. # This driver (iterate active disjuncts and their constraints) is shared; -# the per-constraint emission is the OVERRIDABLE seam below. +# the per-constraint emission is the OVERRIDABLE seam below. `cache` is a +# per-pass scratch passed to each seam call (used by the extension). function add_disjunct_oa_cuts( model::JuMP.AbstractModel, master::_LOAMaster, @@ -649,6 +667,7 @@ function add_disjunct_oa_cuts( method::LOA ) penalty_sign = _penalty_sign(Val(master.objective_sense)) + cache = Ref{Any}(nothing) for (indicator, active) in result.combination is_active(active) || continue haskey(_indicator_to_constraints(model), indicator) || continue @@ -656,17 +675,18 @@ function add_disjunct_oa_cuts( cref isa DisjunctConstraintRef || continue constraint = _disjunct_constraints(model)[ JuMP.index(cref)].constraint - _add_disjunct_constraint_oa_cuts(model, constraint, master, + add_disjunct_constraint_oa_cuts(model, constraint, master, master.binary_map[indicator], active, result, method, - penalty_sign) + penalty_sign, cache) end end end # OVERRIDABLE. Emit the OA cut(s) for one active disjunct constraint. # Base emits a single cut; the InfiniteOpt extension fans out per -# support. `active` is unused in base but drives the extension's fan-out. -function _add_disjunct_constraint_oa_cuts( +# support. `active` is unused in base but drives the extension's fan-out; +# `cache` is a per-pass scratch the extension memoizes transcription into. +function add_disjunct_constraint_oa_cuts( ::JuMP.AbstractModel, constraint::JuMP.AbstractConstraint, master::_LOAMaster, @@ -674,7 +694,8 @@ function _add_disjunct_constraint_oa_cuts( active, result::NamedTuple, method::LOA, - penalty_sign::Int + penalty_sign::Int, + cache ) _add_oa_cut_for_constraint( constraint, master, binary_ref, result.linearization_point, @@ -694,9 +715,9 @@ function _penalized_slack(master::_LOAMaster, method::LOA, penalty_sign::Int) return slack end -# Linearize constraint at `linearization_point`, then dispatch on the -# constraint's set to emit slacked OA cut(s) gated by `M(1 − binary)`. -# Linear constraints are exact via BigM and skipped. +# Linearize the constraint at `linearization_point`, then emit slacked OA +# cut(s) gated to match the inner reformulation (Big-M / MBM or Hull). +# Linear constraints are exact via the reformulation and skipped. function _add_oa_cut_for_constraint( constraint::JuMP.AbstractConstraint, master::_LOAMaster, @@ -715,99 +736,19 @@ function _add_oa_cut_for_constraint( return end -# Route the disjunct OA cut through the gating that matches the inner -# reformulation: Big-M / MBM gate with `M(1 - y)`; Hull emits the -# convex-hull cut on disaggregated variables (no big-M). -_emit_disjunct_oa_cut( - ::Union{BigM, MBM}, - set, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) = - _emit_disjunct_oa_cut( - set, master, binary_ref, linearization, method, penalty_sign) -_emit_disjunct_oa_cut( - ::Hull, - set, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) = - _emit_hull_oa_cut( - set, master, binary_ref, linearization, method, penalty_sign) +# The `<= 0` directions of an OA cut for `set`: `lin - rhs` for LessThan, +# `rhs - lin` for GreaterThan, both for EqualTo / Interval. The caller +# adds the gating, slack, and (for Hull) disaggregation per direction. +_oa_cut_terms(set::_MOI.GreaterThan, lin) = (_set_rhs(set) - lin,) +_oa_cut_terms(set::_MOI.EqualTo, lin) = + (lin - _MOI.constant(set), _MOI.constant(set) - lin) +_oa_cut_terms(set::_MOI.Interval, lin) = (lin - set.upper, set.lower - lin) +_oa_cut_terms(set, lin) = (lin - _set_rhs(set),) -# Emit the slacked, big-M-gated OA cut(s) for a disjunct constraint. The -# set fixes the direction: `<=` in place, `>=` negated, equality/interval -# both directions sharing one slack. -function _emit_disjunct_oa_cut( - set::_MOI.LessThan, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, - (linearization - _set_rhs(set)) - slack <= - method.M_value * (1 - binary_ref)) - return -end -function _emit_disjunct_oa_cut( - set::_MOI.GreaterThan, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, - (_set_rhs(set) - linearization) - slack <= - method.M_value * (1 - binary_ref)) - return -end -function _emit_disjunct_oa_cut( - set::_MOI.EqualTo, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) - c = _MOI.constant(set) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, - (linearization - c) - slack <= method.M_value * (1 - binary_ref)) - JuMP.@constraint(master.model, - (c - linearization) - slack <= method.M_value * (1 - binary_ref)) - return -end -function _emit_disjunct_oa_cut( - set::_MOI.Interval, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, - (linearization - set.upper) - slack <= - method.M_value * (1 - binary_ref)) - JuMP.@constraint(master.model, - (set.lower - linearization) - slack <= - method.M_value * (1 - binary_ref)) - return -end -# Fallback for vector / future set types: emit the `≤`-direction cut -# against the set's RHS (`_set_rhs` defaults to 0). +# Big-M / MBM disjunct cut: each direction slacked and gated by +# `M(1 - binary)`. function _emit_disjunct_oa_cut( + ::Union{BigM, MBM}, set, master::_LOAMaster, binary_ref, @@ -816,80 +757,20 @@ function _emit_disjunct_oa_cut( penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, - (linearization - _set_rhs(set)) - slack <= - method.M_value * (1 - binary_ref)) + for term in _oa_cut_terms(set, linearization) + JuMP.@constraint(master.model, + term - slack <= method.M_value * (1 - binary_ref)) + end return end -# Convex-hull disjunct OA cut. `disaggregate_expression` rewrites the OA -# linearization `linearization - rhs` into disaggregated space (each -# variable becomes its per-disjunct copy, the constant scaled by the -# binary), giving the sharp cut that switches off at `y = 0` with no -# big-M. The slack handles the nonconvex case. Set dispatch mirrors -# `_emit_disjunct_oa_cut`. -function _emit_hull_oa_cut( - set::_MOI.LessThan, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) - body = disaggregate_expression(master.model, - linearization - _set_rhs(set), binary_ref, master.disaggregator) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, body - slack <= 0) - return -end -function _emit_hull_oa_cut( - set::_MOI.GreaterThan, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) - body = disaggregate_expression(master.model, - linearization - _set_rhs(set), binary_ref, master.disaggregator) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, body + slack >= 0) - return -end -function _emit_hull_oa_cut( - set::_MOI.EqualTo, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) - body = disaggregate_expression(master.model, - linearization - _set_rhs(set), binary_ref, master.disaggregator) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, body - slack <= 0) - JuMP.@constraint(master.model, body + slack >= 0) - return -end -function _emit_hull_oa_cut( - set::_MOI.Interval, - master::_LOAMaster, - binary_ref, - linearization, - method::LOA, - penalty_sign::Int - ) - upper = disaggregate_expression(master.model, - linearization - set.upper, binary_ref, master.disaggregator) - lower = disaggregate_expression(master.model, - linearization - set.lower, binary_ref, master.disaggregator) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, upper - slack <= 0) - JuMP.@constraint(master.model, lower + slack >= 0) - return -end -# Fallback for vector / future set types: one upper-bound cut. -function _emit_hull_oa_cut( +# Convex-hull disjunct cut: `disaggregate_expression` rewrites each +# direction into disaggregated space (variables -> per-disjunct copies, +# constant scaled by the binary), so the cut switches off at `y = 0` with +# no big-M. It is linear in its argument, so the two-sided sets need no +# special casing. +function _emit_disjunct_oa_cut( + ::Hull, set, master::_LOAMaster, binary_ref, @@ -897,62 +778,29 @@ function _emit_hull_oa_cut( method::LOA, penalty_sign::Int ) - body = disaggregate_expression(master.model, - linearization - _set_rhs(set), binary_ref, master.disaggregator) slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, body - slack <= 0) + for term in _oa_cut_terms(set, linearization) + body = disaggregate_expression( + master.model, term, binary_ref, master.disaggregator) + JuMP.@constraint(master.model, body - slack <= 0) + end return end -# Slacked global OA row(s): each carries a penalized slack so a nonconvex -# (invalid) linearization can't make the master infeasible. EqualTo / -# Interval get a two-sided pair sharing one slack; unknown sets fall back -# to a hard cut. +# Slacked global OA row(s): each direction carries a penalized slack so a +# nonconvex linearization can't make the master infeasible. Unknown sets +# fall back to a hard cut. function _add_global_oa_row( master::_LOAMaster, lin, - set::_MOI.LessThan, + set::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo, _MOI.Interval}, method::LOA, penalty_sign::Int ) slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, lin - _MOI.constant(set) <= slack) - return -end -function _add_global_oa_row( - master::_LOAMaster, - lin, - set::_MOI.GreaterThan, - method::LOA, - penalty_sign::Int - ) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, _MOI.constant(set) - lin <= slack) - return -end -function _add_global_oa_row( - master::_LOAMaster, - lin, - set::_MOI.EqualTo, - method::LOA, - penalty_sign::Int - ) - slack = _penalized_slack(master, method, penalty_sign) - c = _MOI.constant(set) - JuMP.@constraint(master.model, lin - c <= slack) - JuMP.@constraint(master.model, c - lin <= slack) - return -end -function _add_global_oa_row( - master::_LOAMaster, - lin, - set::_MOI.Interval, - method::LOA, - penalty_sign::Int - ) - slack = _penalized_slack(master, method, penalty_sign) - JuMP.@constraint(master.model, lin - set.upper <= slack) - JuMP.@constraint(master.model, set.lower - lin <= slack) + for term in _oa_cut_terms(set, lin) + JuMP.@constraint(master.model, term <= slack) + end return end function _add_global_oa_row(master::_LOAMaster, lin, set, ::LOA, ::Int) diff --git a/src/model.jl b/src/model.jl index efd6ff08..0f26a4f0 100644 --- a/src/model.jl +++ b/src/model.jl @@ -83,7 +83,6 @@ _indicator_to_constraints(model::JuMP.AbstractModel) = gdp_data(model).indicator _constraint_to_indicator(model::JuMP.AbstractModel) = gdp_data(model).constraint_to_indicator _reformulation_variables(model::JuMP.AbstractModel) = gdp_data(model).reformulation_variables _reformulation_constraints(model::JuMP.AbstractModel) = gdp_data(model).reformulation_constraints -_disaggregations(model::JuMP.AbstractModel) = gdp_data(model).disaggregations _variable_bounds(model::JuMP.AbstractModel) = gdp_data(model).variable_bounds _solution_method(model::JuMP.AbstractModel) = gdp_data(model).solution_method # Get the current solution method _ready_to_optimize(model::JuMP.AbstractModel) = gdp_data(model).ready_to_optimize # Determine if the model is ready to call `optimize!` without a optimize hook diff --git a/src/reformulate.jl b/src/reformulate.jl index 86270104..9a3b65cb 100644 --- a/src/reformulate.jl +++ b/src/reformulate.jl @@ -24,7 +24,6 @@ function _clear_reformulations(model::JuMP.AbstractModel) delete.(model, _reformulation_variables(model)) empty!(gdp_data(model).reformulation_constraints) empty!(gdp_data(model).reformulation_variables) - empty!(gdp_data(model).disaggregations) empty!(gdp_data(model).variable_bounds) return end diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 16a06d9f..198ada2a 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -158,7 +158,7 @@ end function test_loa_nonlinear_global() # max x s.t. x^2 <= 25 (global), (x <= 3) ∨ (x <= 8), 0 <= x <= 10. # Disjunct Y[2] permits x up to 8 but the global x^2 <= 25 bounds - # x to 5. Verifies that `_add_global_oa_cuts` emits the + # x to 5. Verifies that `add_global_oa_cuts` emits the # linearization of the global into the master without breaking # the loop. Result must hit the global-binding optimum. ipopt = optimizer_with_attributes(Ipopt.Optimizer, @@ -529,6 +529,26 @@ function test_loa_hull_complement_nonlinear() @test objective_value(model) ≈ 8.0 atol = 1e-3 end +function test_loa_hull_nested_sink() + # The inner disjunction (over y) is a disjunct of the outer (gated by + # z[1]). Hull's nested redispatch must propagate the LOA sink so the + # inner disaggregations are recorded, else LOA-Hull emits the nested + # cut on the aggregated variable. + model = GDPModel() + @variable(model, 0 <= x <= 10) + @variable(model, y[1:2], Logical) + @variable(model, z[1:2], Logical) + @constraint(model, x <= 3, Disjunct(y[1])) + @constraint(model, x <= 8, Disjunct(y[2])) + @disjunction(model, y, Disjunct(z[1])) + @constraint(model, x <= 1, Disjunct(z[2])) + @disjunction(model, z) + sink = Dict{Any, Any}() + DP.reformulate_model(model, Hull(; sink = sink)) + @test haskey(sink, (x, y[1])) + @test haskey(sink, (x, y[2])) +end + @testset "LOA" begin test_loa_datatype() test_set_covering_combos() @@ -554,4 +574,5 @@ end test_loa_hull_nonlinear_global() test_loa_hull_nonlinear_disjunct() test_loa_hull_complement_nonlinear() + test_loa_hull_nested_sink() end diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index e1011be6..6c73d55d 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -797,7 +797,7 @@ function test_loa_infinite_nonlinear_global() # Disjunct Y[2] permits x up to 8 but the global x^2 <= 25 caps # x at 5. The per-support global transcribes to an `AbstractArray` # of scalar constraints, so this exercises the array branch of - # `_add_global_oa_cuts`. Without the global cut the + # `add_global_oa_cuts`. Without the global cut the # master would allow x = 8 and report 8.0; the binding optimum # is ∫5 dt = 5. ipopt = optimizer_with_attributes(Ipopt.Optimizer, @@ -954,7 +954,7 @@ function test_loa_infinite_aggregate_global() # (y >= 1) ∨ (y >= 3), 0 <= x, y <= 10 over t ∈ [0, 1]. # The aggregate global transcribes to a single scalar (the # measure is flattened), exercising the non-array branch of - # `_add_global_oa_cuts`. Y[1] (y >= 1) is the cheaper + # `add_global_oa_cuts`. Y[1] (y >= 1) is the cheaper # disjunct: x = 1 satisfies x >= y and ∫x^2 = 1 <= 4, giving # objective ∫1 dt = 1. ipopt = optimizer_with_attributes(Ipopt.Optimizer, From c77f732c95ae4668cbed3a0074d2b26dcfe6b991 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Wed, 1 Jul 2026 16:18:55 -0400 Subject: [PATCH 58/72] Docstrings and tests for LOA --- docs/src/index.md | 12 +- ext/InfiniteDisjunctiveProgramming.jl | 64 ++--- src/datatypes.jl | 76 ++++++ src/loa.jl | 352 +++++++------------------- src/utilities.jl | 103 +++++++- test/constraints/loa.jl | 75 ------ test/utilities.jl | 75 ++++++ 7 files changed, 390 insertions(+), 367 deletions(-) diff --git a/docs/src/index.md b/docs/src/index.md index aa1902e3..615d598e 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -61,7 +61,7 @@ A [`GDPModel`](@ref) is a `JuMP Model` with a [`GDPData`](@ref) field in the mod - `Logical Constraints`: Selector (cardinality) or proposition (Boolean) constraints describing the relationships between the logical variables. - `Disjunct Constraints`: Constraints associated with each disjunct in the model. - `Disjunctions`: Disjunction constraints. -- `Solution Method`: The reformulation technique or solution method. Currently, supported methods include Big-M, Hull, and Indicator Constraints. +- `Solution Method`: The reformulation technique or solution method. Currently, supported methods include Big-M, Multiple Big-M, Hull, P-Split, Indicator Constraints, and Logic-based Outer Approximation (LOA). - `Reformulation Variables`: List of JuMP variables created when reformulating a GDP model into a MIP model. - `Reformulation Constraints`: List of constraints created when reformulating a GDP model into a MIP model. - `Ready to Optimize`: Flag indicating if the model can be optimized. @@ -163,9 +163,15 @@ The following reformulation methods are currently supported: 1. [Big-M](https://optimization.cbe.cornell.edu/index.php?title=Disjunctive_inequalities#Big-M_Reformulation[1][2]): The [`BigM`](@ref) struct is used. -2. [Hull](https://optimization.cbe.cornell.edu/index.php?title=Disjunctive_inequalities#Convex-Hull_Reformulation[1][2]): The [`Hull`](@ref) struct is used. +2. Multiple Big-M: A tighter big-M reformulation that computes a separate big-M value for each disjunct constraint (rather than a single global value) by solving auxiliary mini-models. This is invoked with the [`MBM`](@ref) struct, which requires an optimizer. -3. [Indicator](https://jump.dev/JuMP.jl/stable/manual/constraints/#Indicator-constraints): This method reformulates each disjunct constraint into an indicator constraint with the Boolean reformulation counterpart of the Logical variable used to define the disjunct constraint. This is invoked with [`Indicator`](@ref). +3. [Hull](https://optimization.cbe.cornell.edu/index.php?title=Disjunctive_inequalities#Convex-Hull_Reformulation[1][2]): The [`Hull`](@ref) struct is used. + +4. P-Split: A partitioned reformulation that splits the variables into groups and applies a P-split formulation to each group, giving a relaxation between Big-M and Hull in tightness. This is invoked with the [`PSplit`](@ref) struct. + +5. [Indicator](https://jump.dev/JuMP.jl/stable/manual/constraints/#Indicator-constraints): This method reformulates each disjunct constraint into an indicator constraint with the Boolean reformulation counterpart of the Logical variable used to define the disjunct constraint. This is invoked with [`Indicator`](@ref). + +6. Logic-based Outer Approximation (LOA): An iterative solution method for nonlinear GDPs, rather than a single-shot reformulation. It alternates between a primal NLP (the model reformulated by an inner method — `BigM`, `MBM`, or `Hull` — with the disjunct binaries fixed at the current selection) and a master MILP that accumulates outer-approximation and no-good cuts until the bound meets the incumbent. This is invoked with the [`LOA`](@ref) struct, e.g. `optimize!(m, gdp_method = LOA(Ipopt.Optimizer))`. ## Release Notes diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 0381caf5..c96f275d 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -633,12 +633,10 @@ end # expression directly; aggregate ones are transcribed flat and mapped # back via `_transcribed_to_master_point`. function DP.build_loa_master( - model::InfiniteOpt.InfiniteModel, method::DP.LOA, sink = nothing - )::DP._LOAMaster - # Keep only linear, non-aggregate constraints. Nonlinear and - # aggregate-wrapped ones (e.g. `∫(x^2,t) ≤ c`) re-enter as OA cuts, - # so they are dropped at copy time. Variable bounds survive on - # VariableInfo regardless. + model::InfiniteOpt.InfiniteModel, + method::DP.LOA, + sink = nothing + ) variable_type = InfiniteOpt.GeneralVariableRef master, copy_ref_map = JuMP.copy_model( model; @@ -650,16 +648,10 @@ function DP.build_loa_master( return !_has_aggregate_ref(con.func) end ) - # `copy_model` copies the GDP optimize-hook; clear it so - # `optimize!(master)` doesn't re-trigger reformulation on the - # (empty-GDP-data) master copy. JuMP.set_optimize_hook(master, nothing) JuMP.set_optimizer(master, method.mip_optimizer) JuMP.set_silent(master) - # InfiniteReferenceMap supports indexing but not iteration; build - # a Dict so downstream LOA code can `haskey` / iterate over the - # source-side refs LOA cares about. ref_map = Dict{InfiniteOpt.GeneralVariableRef, InfiniteOpt.GeneralVariableRef}() for v in DP.collect_all_vars(model) @@ -701,7 +693,7 @@ function DP.build_loa_master( variable_map[v] = ref_map[v] end - # Aggregate-wrapped LINEAR constraints (e.g. `𝔼(W, ξ) ≥ α`) were + # Aggregate-wrapped LINEAR constraints (e.g. `E(W, e) ≥ alpha`) were # dropped at copy time and the OA-cut path skips linear `F`, so add # each one to the master directly as a transcribed flat scalar. _add_aggregate_linear_constraints( @@ -713,12 +705,6 @@ function DP.build_loa_master( model, method.inner_method, variable_map, binary_map, sink)) end -# Record one master-space disaggregation for `InfiniteModel`. The Hull -# cut emitter runs through the per-support fan-out over point-evaluated -# variables, so key the map by `(point variable, point binary)` at each -# support — `disaggregate_expression` then substitutes the point -# disaggregated variable per support. Finite (parameter-free) entries -# key once, unsliced. function DP.record_disaggregation( hull::DP._Hull, ::InfiniteOpt.InfiniteModel, @@ -894,22 +880,36 @@ function DP.add_global_oa_cuts( return end -# Per-constraint OA cut emission for `InfiniteModel` (the seam the base -# `add_disjunct_oa_cuts` driver calls). Non-aggregate constraints fan out -# per support via `_infinite_cut_info`; aggregate ones (`MeasureRef`) are -# transcribed flat and handed to the base `_add_oa_cut_for_constraint`. -# `cache` memoizes the transcription maps once per pass (lazily, on the -# first aggregate constraint) so they are not rebuilt per constraint. -function DP.add_disjunct_constraint_oa_cuts( +# Override the disjunct-OA-cut pass for `InfiniteModel`: own the per-pass +# transcription `cache` and delegate each active disjunct constraint to +# the emitter below. Reuses the base active-disjunct walk so only the +# per-constraint emission differs from base. +function DP.add_disjunct_oa_cuts( model::InfiniteOpt.InfiniteModel, - constraint::JuMP.AbstractConstraint, master::DP._LOAMaster, - binary_ref, - active, result::NamedTuple, - method::DP.LOA, - penalty_sign::Int, - cache + method::DP.LOA + ) + penalty_sign = DP._penalty_sign(Val(master.objective_sense)) + cache = Ref{Any}(nothing) + DP._each_active_disjunct_constraint(model, master, + result) do binary_ref, active, constraint + _add_infinite_disjunct_constraint_oa_cuts(model, constraint, + master, binary_ref, active, result, method, penalty_sign, + cache) + end + return +end + +# Emit the OA cut(s) for one active disjunct constraint on an +# `InfiniteModel`. Non-aggregate constraints fan out per support via +# `_infinite_cut_info`; aggregate ones (`MeasureRef`) are transcribed flat +# and handed to `_add_oa_cut_for_constraint`. `cache` memoizes the +# transcription maps once per pass (lazily, on the first aggregate +# constraint). +function _add_infinite_disjunct_constraint_oa_cuts( + model, constraint, master, binary_ref, active, result, method, + penalty_sign, cache ) if _has_aggregate_ref(constraint.func) if cache[] === nothing diff --git a/src/datatypes.jl b/src/datatypes.jl index 11e7afd7..4d8c9208 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -571,6 +571,82 @@ A type for using indicator constraint approach for linear disjunctive constraint """ struct Indicator <: AbstractReformulationMethod end +################################################################################ +# LOA +################################################################################ +""" + LOA{O, P, R, T} <: AbstractReformulationMethod + +Logic-based Outer Approximation solver for GDP models. Iterates a primary +NLP (original model reformulated by `inner_method`, binaries fixed per +iteration) and a master MILP accumulating OA and no-good cuts. +`inner_method` is `BigM` (default), `MBM`, or `Hull`. + +## Fields +- `nlp_optimizer::O`: solver for the primary NLP. +- `mip_optimizer::P`: solver for the master MILP (default `nlp_optimizer`). +- `inner_method::R`: NLP reformulation — `BigM`, `MBM`, or `Hull`. +- `max_iter::Int`: max iterations after set-covering seeding. +- `M_value::T`: big-M for the disjunct OA cut gating term. +- `max_slack::T`: upper bound per slack variable. +- `oa_penalty::T`: penalty on slacks in the master objective. +- `convergence_tol::Float64`: relative gap tolerance for the early stop. +- `slack_tol::Float64`: max total slack for which the bound still counts + as converged (positive slack = nonconvex crossing, keep iterating). +- `iteration_time_limit::Float64`: budget (s) for the iteration loop. +- `time_limit::Float64`: overall budget (s) incl. the final solve + (default 3600; `Inf` disables). +""" +struct LOA{O, P, R, T} <: AbstractReformulationMethod + nlp_optimizer::O + mip_optimizer::P + inner_method::R + max_iter::Int + M_value::T + max_slack::T + oa_penalty::T + convergence_tol::Float64 + slack_tol::Float64 + iteration_time_limit::Float64 + time_limit::Float64 + function LOA( + nlp_optimizer::O; + mip_optimizer::P = nlp_optimizer, + max_iter::Int = 10, + M_value::T = 1e9, + max_slack::T = 1e3, + oa_penalty::T = 1e3, + inner_method::R = BigM(M_value), + convergence_tol::Float64 = 1e-6, + slack_tol::Float64 = 1e-4, + iteration_time_limit::Float64 = Inf, + time_limit::Float64 = 3600.0 + ) where {O, P, R <: AbstractReformulationMethod, T} + R <: Union{BigM, MBM, Hull} || error( + "LOA inner_method must be BigM, MBM, or Hull (got $R). " * + "PSplit is not yet supported.") + new{O, P, R, T}(nlp_optimizer, mip_optimizer, inner_method, + max_iter, M_value, max_slack, oa_penalty, + convergence_tol, slack_tol, + iteration_time_limit, time_limit) + end +end + +# The LOA master MILP plus maps from original- to master-model refs. +# `objective_ref_map` splits from `variable_map` so an extension can map +# objective vars separately (identical in base); `disaggregator` is the +# master `_Hull` for Hull cuts, `nothing` for Big-M / MBM. +mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM, DG} + model::M + binary_map::BM + variable_map::VM + objective_sense::_MOI.OptimizationSense + original_objective::OF + alpha_oa::AO + objective_ref_map::RM + disaggregator::DG +end + ################################################################################ # GDP Data ################################################################################ diff --git a/src/loa.jl b/src/loa.jl index ff722d84..14d6e629 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -3,88 +3,8 @@ ################################################################################ ################################################################################ -# METHOD TYPE +# SENSE HANDLERS ################################################################################ -""" - LOA{O, P, R, T} <: AbstractReformulationMethod - -Logic-based Outer Approximation solver for GDP models. Iterates a primary -NLP (original model reformulated by `inner_method`, binaries fixed per -iteration) and a master MILP accumulating OA and no-good cuts. -`inner_method` is `BigM` (default), `MBM`, or `Hull`. - -## Fields -- `nlp_optimizer::O`: solver for the primary NLP. -- `mip_optimizer::P`: solver for the master MILP (default `nlp_optimizer`). -- `inner_method::R`: NLP reformulation — `BigM`, `MBM`, or `Hull`. -- `max_iter::Int`: max iterations after set-covering seeding. -- `M_value::T`: big-M for the disjunct OA cut gating term. -- `max_slack::T`: upper bound per slack variable. -- `oa_penalty::T`: penalty on slacks in the master objective. -- `convergence_tol::Float64`: relative gap tolerance for the early stop. -- `slack_tol::Float64`: max total slack for which the bound still counts - as converged (positive slack = nonconvex crossing, keep iterating). -- `iteration_time_limit::Float64`: budget (s) for the iteration loop. -- `time_limit::Float64`: overall budget (s) incl. the final solve - (default 3600; `Inf` disables). -""" -struct LOA{O, P, R, T} <: AbstractReformulationMethod - nlp_optimizer::O - mip_optimizer::P - inner_method::R - max_iter::Int - M_value::T - max_slack::T - oa_penalty::T - convergence_tol::Float64 - slack_tol::Float64 - iteration_time_limit::Float64 - time_limit::Float64 - function LOA( - nlp_optimizer::O; - mip_optimizer::P = nlp_optimizer, - max_iter::Int = 10, - M_value::T = 1e9, - max_slack::T = 1e3, - oa_penalty::T = 1e3, - inner_method::R = BigM(M_value), - convergence_tol::Float64 = 1e-6, - slack_tol::Float64 = 1e-4, - iteration_time_limit::Float64 = Inf, - time_limit::Float64 = 3600.0 - ) where {O, P, R <: AbstractReformulationMethod, T} - R <: Union{BigM, MBM, Hull} || error( - "LOA inner_method must be BigM, MBM, or Hull (got $R). " * - "PSplit is not yet supported.") - new{O, P, R, T}(nlp_optimizer, mip_optimizer, inner_method, - max_iter, M_value, max_slack, oa_penalty, - convergence_tol, slack_tol, - iteration_time_limit, time_limit) - end -end - -################################################################################ -# LOA MASTER -################################################################################ -# The LOA master MILP plus maps from original- to master-model refs. -# `objective_ref_map` splits from `variable_map` so an extension can map -# objective vars separately (identical in base); `disaggregator` is the -# master `_Hull` for Hull cuts, `nothing` for Big-M / MBM. -mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM, DG} - model::M - binary_map::BM - variable_map::VM - objective_sense::_MOI.OptimizationSense - original_objective::OF - alpha_oa::AO - objective_ref_map::RM - disaggregator::DG -end - -################################################################################ -# SENSE PRIMITIVES -################################################################################ -# Val(MIN/MAX)-dispatched primitives so the algorithm reads sense-agnostic. _penalty_sign(::Val{_MOI.MIN_SENSE}) = 1 _penalty_sign(::Val{_MOI.MAX_SENSE}) = -1 _worst_objective(::Val{_MOI.MIN_SENSE}) = Inf @@ -95,15 +15,13 @@ _gap(::Val{_MOI.MIN_SENSE}, best, bound) = best - bound _gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best ################################################################################ -# MAIN ALGORITHM +# MAIN LOOP ################################################################################ -# LOA entry point: set-covering seeds, the master/NLP loop, commit the -# best combination. Model-agnostic — extensions override the inner steps. +# LOA entry: set-covering seeds, the master/NLP loop, commit the best combo function reformulate_model(model::JuMP.AbstractModel, method::LOA) _clear_reformulations(model) combinations = _set_covering_combinations(model) - # Hull needs the disaggregation map; thread a LOA-owned sink through - # the inner reformulation to collect it (kept off GDPData). + # Hull needs the disaggregation map if its used as inner function inner, sink = _loa_inner_method(model, method.inner_method) reformulate_model(model, inner) @@ -142,8 +60,7 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) end # Master/NLP loop: `alpha_oa` is the bound, the NLP refines the - # incumbent. Exit on convergence (bound meets incumbent and slack - # settled), infeasible master, max_iter, or time. + # incumbent. Exit on convergence, infeasible master, max_iter, or time. converged = false for _ in 1:method.max_iter time() < loop_deadline || break @@ -226,7 +143,7 @@ function reformulate_model(::M, ::LOA) where {M} end ################################################################################ -# SET-COVERING INITIALIZATION +# SET-COVERING INITIALIZATION (simple version from pyomo) ################################################################################ # `K = max disjunction size` combinations that activate every indicator # at least once: combination `k` activates the `k`-th indicator of each @@ -258,11 +175,20 @@ _is_linear_F(::Type{<:AbstractVector{<:JuMP.AbstractVariableRef}}) = true _is_linear_F(::Type{<:AbstractVector{<:JuMP.GenericAffExpr}}) = true _is_linear_F(::Type) = false -# OVERRIDABLE. Build the master MILP: copy the variables and linear -# constraints, install `alpha_oa` as the objective auxiliary. Nonlinear -# objective and disjunct constraints enter as OA cuts per NLP solve. -# `sink` is the disaggregation map collected by the inner Hull -# reformulation (`nothing` for Big-M / MBM). +""" + build_loa_master(model, method::LOA, sink = nothing)::_LOAMaster + +Build the LOA master MILP: copy `model`'s variables and linear +constraints, install the `alpha_oa` objective auxiliary, and record the +original-to-master reference maps. Nonlinear objective and disjunct +constraints are not copied; they enter later as OA cuts per NLP solve. +`sink` is the `(variable, indicator) -> disaggregated variable` map from +an inner Hull reformulation (`nothing` for Big-M / MBM). + +## Returns +- `_LOAMaster`: the master model with its reference maps and + disaggregator. +""" function build_loa_master( model::JuMP.AbstractModel, method::LOA, @@ -305,9 +231,9 @@ function build_loa_master( end # The inner reformulation method LOA runs, plus the disaggregation sink to -# collect (Big-M / MBM need none). For Hull, return a sink-carrying copy -# so the reformulation records its `(variable, indicator) -> disaggregated -# variable` map into a fresh LOA-owned `Dict`. +# collect (Big-M / MBM need none). +#For Hull, return a sink-carrying copy so the reformulation records its `(variable, indicator) +# -> disaggregated variable` map into a fresh LOA-owned `Dict`. _loa_inner_method(::JuMP.AbstractModel, inner::Union{BigM, MBM}) = (inner, nothing) function _loa_inner_method(model::JuMP.AbstractModel, inner::Hull) @@ -336,10 +262,13 @@ function _build_disaggregator( return hull end -# OVERRIDABLE. Record one master-space disaggregation in the Hull -# disaggregator. Base keys it directly by `(variable, binary)`; the -# InfiniteOpt extension keys per support so per-support cut emission -# matches. +""" + record_disaggregation(hull::_Hull, model, variable, binary, + disaggregated) + +Record one master-space disaggregation in the Hull `hull` used to gate +Hull OA cuts, keyed by `(variable, binary)`. +""" function record_disaggregation( hull::_Hull, ::JuMP.AbstractModel, @@ -354,16 +283,11 @@ end ################################################################################ # NLP SUBPROBLEM ################################################################################ -# Cap `target`'s solver to the budget left before `deadline` so one solve -# can't overrun the loop. No-op when `deadline` is `Inf`. function _cap_remaining_time(target::JuMP.AbstractModel, deadline::Float64) isfinite(deadline) || return JuMP.set_time_limit_sec(target, max(0.0, deadline - time())) return end - -# Restore the model's solver time limit after the loop — the factory -# value captured before capping, or unset if the factory set none. _restore_time_limit(model::JuMP.AbstractModel, ::Nothing) = JuMP.unset_time_limit_sec(model) _restore_time_limit(model::JuMP.AbstractModel, seconds::Real) = @@ -440,7 +364,9 @@ function _solve_nlpf( fix_combination_on_copy(copy, model, combination, ref_map) JuMP.optimize!(copy, ignore_optimize_hook = true) - JuMP.has_values(copy) || return nothing + # Use the primal only at a genuine feasible point; a solver can report + # `has_values` with a nonfeasible/NaN primal that would poison the cut. + JuMP.is_solved_and_feasible(copy) || return nothing linearization_point = _nlpf_extract_primal(model, ref_map) return (combination = combination, @@ -496,7 +422,9 @@ end # Fix `combination`, run `f()`, then undo. The finite undo is a no-op # (fixes overwrite in place); the infinite `fix_combination` clears its -# per-support pins. +# per-support fixed values. + +# TODO: Refactor to avoid this. function with_fixed_combination( f, model::JuMP.AbstractModel, @@ -538,9 +466,15 @@ function commit_combination( return end -# OVERRIDABLE. Apply the combination's fixes in place and return an undo -# closure (a no-op here, since the finite fixes overwrite in place). An -# extension overrides this for per-support `Vector{Bool}` values. +""" + fix_combination(model, combination::AbstractDict)::Function + +Fix each indicator in `combination` to its value on `model`, in place, +and return a zero-argument closure that undoes the fixes. + +## Returns +- `Function`: an undo closure called by `with_fixed_combination`. +""" function fix_combination(model::JuMP.AbstractModel, combination::AbstractDict) for (indicator, value) in combination fix_indicator(model, indicator, value) @@ -548,9 +482,12 @@ function fix_combination(model::JuMP.AbstractModel, combination::AbstractDict) return () -> nothing end -# OVERRIDABLE. Warm-start a variable from the linearization point (stored -# as a per-support vector). Base unwraps the scalar; the InfiniteOpt -# extension broadcasts across the transcribed per-support refs. +""" + set_linearization_start(variable, values::AbstractVector) + +Warm-start `variable` from the linearization point, unwrapping the single +element of `values` as its start value. +""" set_linearization_start(variable, values::AbstractVector) = JuMP.set_start_value(variable, only(values)) @@ -602,9 +539,12 @@ function add_oa_cuts( return end -# OVERRIDABLE. The point at which the master objective is linearized. -# Base uses the raw point; an extension whose `original_objective` is -# transcribed or derived overrides this to match its shape. +""" + objective_linearization_point(model, linearization_point) + +Return the point at which the master objective is linearized. Returns +`linearization_point` unchanged. +""" objective_linearization_point(::JuMP.AbstractModel, linearization_point) = linearization_point @@ -626,10 +566,15 @@ _add_objective_cut_body(::Val{_MOI.MIN_SENSE}, master, lin, slack) = _add_objective_cut_body(::Val{_MOI.MAX_SENSE}, master, lin, slack) = JuMP.@constraint(master.model, lin >= master.alpha_oa - slack) -# OVERRIDABLE. Add an OA cut for every nonlinear global constraint, -# skipping variable bounds, linear functions, and reformulation -# constraints. Base (scalar) version; an extension overrides it for -# vector-valued / transcribed linearizations. +""" + add_global_oa_cuts(model, master::_LOAMaster, result::NamedTuple, + method::LOA) + +Add a slacked OA cut to `master` for every nonlinear global (non-disjunct) +constraint of `model`, linearized at `result.linearization_point`. +Variable bounds, linear functions, and reformulation constraints are +skipped. +""" function add_global_oa_cuts( model::JuMP.AbstractModel, master::_LOAMaster, @@ -656,18 +601,11 @@ function add_global_oa_cuts( return end -# Add slacked OA cuts for each active disjunct's nonlinear constraints. -# This driver (iterate active disjuncts and their constraints) is shared; -# the per-constraint emission is the OVERRIDABLE seam below. `cache` is a -# per-pass scratch passed to each seam call (used by the extension). -function add_disjunct_oa_cuts( - model::JuMP.AbstractModel, - master::_LOAMaster, - result::NamedTuple, - method::LOA - ) - penalty_sign = _penalty_sign(Val(master.objective_sense)) - cache = Ref{Any}(nothing) +# Iterate each active disjunct's disjunct constraints, invoking +# `f(binary_ref, active, constraint)` per constraint. Shared by the base +# and InfiniteOpt `add_disjunct_oa_cuts` drivers so neither reimplements +# the active-disjunct walk. +function _each_active_disjunct_constraint(f, model, master, result) for (indicator, active) in result.combination is_active(active) || continue haskey(_indicator_to_constraints(model), indicator) || continue @@ -675,31 +613,33 @@ function add_disjunct_oa_cuts( cref isa DisjunctConstraintRef || continue constraint = _disjunct_constraints(model)[ JuMP.index(cref)].constraint - add_disjunct_constraint_oa_cuts(model, constraint, master, - master.binary_map[indicator], active, result, method, - penalty_sign, cache) + f(master.binary_map[indicator], active, constraint) end end + return end -# OVERRIDABLE. Emit the OA cut(s) for one active disjunct constraint. -# Base emits a single cut; the InfiniteOpt extension fans out per -# support. `active` is unused in base but drives the extension's fan-out; -# `cache` is a per-pass scratch the extension memoizes transcription into. -function add_disjunct_constraint_oa_cuts( - ::JuMP.AbstractModel, - constraint::JuMP.AbstractConstraint, +""" + add_disjunct_oa_cuts(model, master::_LOAMaster, result::NamedTuple, + method::LOA) + +Add a slacked OA cut to `master` for each active disjunct's nonlinear +constraints, linearized at `result.linearization_point` and gated to +match the inner reformulation. Emits one cut per constraint. +""" +function add_disjunct_oa_cuts( + model::JuMP.AbstractModel, master::_LOAMaster, - binary_ref, - active, result::NamedTuple, - method::LOA, - penalty_sign::Int, - cache + method::LOA ) - _add_oa_cut_for_constraint( - constraint, master, binary_ref, result.linearization_point, - master.variable_map, method, penalty_sign) + penalty_sign = _penalty_sign(Val(master.objective_sense)) + _each_active_disjunct_constraint(model, master, + result) do binary_ref, _active, constraint + _add_oa_cut_for_constraint(constraint, master, binary_ref, + result.linearization_point, master.variable_map, method, + penalty_sign) + end return end @@ -736,6 +676,11 @@ function _add_oa_cut_for_constraint( return end +# Extract RHS from an MOI set. +_set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = + _MOI.constant(s) +_set_rhs(::Any) = 0.0 + # The `<= 0` directions of an OA cut for `set`: `lin - rhs` for LessThan, # `rhs - lin` for GreaterThan, both for EqualTo / Interval. The caller # adds the gating, slack, and (for Hull) disaggregation per direction. @@ -813,108 +758,3 @@ end is_active(active::Bool) = active is_active(active::AbstractVector{Bool}) = any(active) -################################################################################ -# LINEARIZATION & EXPRESSION CONVERSION -################################################################################ -# First-order Taylor for a single var or affine expression mapped into -# master space. -function _linearize_at( - variable::JuMP.AbstractVariableRef, - ::AbstractDict, - ref_map::AbstractDict - ) - target = ref_map[variable] - return JuMP.GenericAffExpr{Float64, typeof(target)}(0.0, target => 1.0) -end -function _linearize_at( - func::JuMP.GenericAffExpr, - ::AbstractDict, - ref_map::AbstractDict - ) - V = valtype(ref_map) - T = JuMP.value_type(V) <: Number ? JuMP.value_type(V) : Float64 - result = JuMP.GenericAffExpr{T, V}(T(func.constant)) - for (variable, coefficient) in func.terms - JuMP.add_to_expression!(result, coefficient, ref_map[variable]) - end - return result -end - -# Convert JuMP expression trees to Julia Expr with -# MOI.VariableIndex leaves for MOI.Nonlinear evaluation. -function _to_nlp_expr(expr::JuMP.GenericNonlinearExpr, idx::Dict) - args = Any[_to_nlp_expr(a, idx) for a in expr.args] - return Expr(:call, expr.head, args...) -end -function _to_nlp_expr(expr::JuMP.GenericAffExpr, idx::Dict) - parts = Any[expr.constant] - for (var, coef) in expr.terms - push!(parts, Expr(:call, :*, coef, _MOI.VariableIndex(idx[var]))) - end - length(parts) == 1 && return parts[1] - return Expr(:call, :+, parts...) -end -function _to_nlp_expr(expr::JuMP.GenericQuadExpr, idx::Dict) - parts = Any[_to_nlp_expr(expr.aff, idx)] - for (pair, coef) in expr.terms - push!(parts, Expr(:call, :*, coef, - _MOI.VariableIndex(idx[pair.a]), - _MOI.VariableIndex(idx[pair.b]))) - end - length(parts) == 1 && return parts[1] - return Expr(:call, :+, parts...) -end -function _to_nlp_expr(var::JuMP.AbstractVariableRef, idx::Dict) - return _MOI.VariableIndex(idx[var]) -end -_to_nlp_expr(x::Number, ::Dict) = x - -# First-order Taylor linearization of a quadratic or nonlinear -# expression at point xk via MOI.Nonlinear reverse-mode AD. -function _linearize_at( - func::Union{JuMP.GenericQuadExpr, JuMP.GenericNonlinearExpr}, - xk::Dict, - ref_map - ) - vars = JuMP.AbstractVariableRef[] - _interrogate_variables(v -> push!(vars, v), func) - unique!(vars) - isempty(vars) && return JuMP.AffExpr(JuMP.value(v -> 0.0, func)) - - n = length(vars) - T = JuMP.value_type(typeof(JuMP.owner_model(vars[1]))) - idx = Dict(vars[i] => i for i in 1:n) - nlp = _MOI.Nonlinear.Model() - _MOI.Nonlinear.set_objective(nlp, _to_nlp_expr(func, idx)) - ord = [_MOI.VariableIndex(i) for i in 1:n] - evaluator = _MOI.Nonlinear.Evaluator( - nlp, _MOI.Nonlinear.SparseReverseMode(), ord) - _MOI.initialize(evaluator, [:Grad]) - - xk_vec = [_unwrap_scalar(get(xk, v, zero(T))) for v in vars] - f_xk = _MOI.eval_objective(evaluator, xk_vec) - grad = zeros(T, n) - _MOI.eval_objective_gradient(evaluator, grad, xk_vec) - - constant = T(f_xk) - for i in 1:n - constant -= grad[i] * xk_vec[i] - end - V = typeof(ref_map[vars[1]]) - result = JuMP.GenericAffExpr{T, V}(constant) - for i in 1:n - iszero(grad[i]) && continue - JuMP.add_to_expression!(result, grad[i], ref_map[vars[i]]) - end - return result -end - -# Extract RHS from an MOI set. -_set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = - _MOI.constant(s) -_set_rhs(::Any) = 0.0 - -# Unwrap a 1-element `Vector` to its scalar; scalars pass through -# (`extract_solution` returns length-1 vectors for scalar variables). -_unwrap_scalar(v::Real) = v -_unwrap_scalar(v::AbstractVector) = only(v) diff --git a/src/utilities.jl b/src/utilities.jl index e5686af5..7e61fdec 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -530,5 +530,106 @@ function _remap_constraint_to_indicator( ::Dict{DisjunctConstraintRef{M}, DisjunctConstraintRef{M}}, disj_map::Dict{DisjunctionRef{M}, DisjunctionRef{M}} ) where {M <: JuMP.AbstractModel} - return disj_map[con_ref] + return disj_map[con_ref] +end + +################################################################################ +# LINEARIZATION & EXPRESSION CONVERSION +################################################################################ +# First-order Taylor for a single var or affine expression mapped into +# master space. +function _linearize_at( + variable::JuMP.AbstractVariableRef, + ::AbstractDict, + ref_map::AbstractDict + ) + target = ref_map[variable] + return JuMP.GenericAffExpr{Float64, typeof(target)}(0.0, target => 1.0) +end +function _linearize_at( + func::JuMP.GenericAffExpr, + ::AbstractDict, + ref_map::AbstractDict + ) + V = valtype(ref_map) + T = JuMP.value_type(V) <: Number ? JuMP.value_type(V) : Float64 + result = JuMP.GenericAffExpr{T, V}(T(func.constant)) + for (variable, coefficient) in func.terms + JuMP.add_to_expression!(result, coefficient, ref_map[variable]) + end + return result +end + +# Convert JuMP expression trees to Julia Expr with +# MOI.VariableIndex leaves for MOI.Nonlinear evaluation. +function _to_nlp_expr(expr::JuMP.GenericNonlinearExpr, idx::Dict) + args = Any[_to_nlp_expr(a, idx) for a in expr.args] + return Expr(:call, expr.head, args...) end +function _to_nlp_expr(expr::JuMP.GenericAffExpr, idx::Dict) + parts = Any[expr.constant] + for (var, coef) in expr.terms + push!(parts, Expr(:call, :*, coef, _MOI.VariableIndex(idx[var]))) + end + length(parts) == 1 && return parts[1] + return Expr(:call, :+, parts...) +end +function _to_nlp_expr(expr::JuMP.GenericQuadExpr, idx::Dict) + parts = Any[_to_nlp_expr(expr.aff, idx)] + for (pair, coef) in expr.terms + push!(parts, Expr(:call, :*, coef, + _MOI.VariableIndex(idx[pair.a]), + _MOI.VariableIndex(idx[pair.b]))) + end + length(parts) == 1 && return parts[1] + return Expr(:call, :+, parts...) +end +function _to_nlp_expr(var::JuMP.AbstractVariableRef, idx::Dict) + return _MOI.VariableIndex(idx[var]) +end +_to_nlp_expr(x::Number, ::Dict) = x + +# First-order Taylor linearization of a quadratic or nonlinear +# expression at point xk via MOI.Nonlinear reverse-mode AD. +function _linearize_at( + func::Union{JuMP.GenericQuadExpr, JuMP.GenericNonlinearExpr}, + xk::Dict, + ref_map + ) + vars = JuMP.AbstractVariableRef[] + _interrogate_variables(v -> push!(vars, v), func) + unique!(vars) + isempty(vars) && return JuMP.AffExpr(JuMP.value(v -> 0.0, func)) + + n = length(vars) + T = JuMP.value_type(typeof(JuMP.owner_model(vars[1]))) + idx = Dict(vars[i] => i for i in 1:n) + nlp = _MOI.Nonlinear.Model() + _MOI.Nonlinear.set_objective(nlp, _to_nlp_expr(func, idx)) + ord = [_MOI.VariableIndex(i) for i in 1:n] + evaluator = _MOI.Nonlinear.Evaluator( + nlp, _MOI.Nonlinear.SparseReverseMode(), ord) + _MOI.initialize(evaluator, [:Grad]) + + xk_vec = [_unwrap_scalar(get(xk, v, zero(T))) for v in vars] + f_xk = _MOI.eval_objective(evaluator, xk_vec) + grad = zeros(T, n) + _MOI.eval_objective_gradient(evaluator, grad, xk_vec) + + constant = T(f_xk) + for i in 1:n + constant -= grad[i] * xk_vec[i] + end + V = typeof(ref_map[vars[1]]) + result = JuMP.GenericAffExpr{T, V}(constant) + for i in 1:n + iszero(grad[i]) && continue + JuMP.add_to_expression!(result, grad[i], ref_map[vars[i]]) + end + return result +end + +# Unwrap a 1-element `Vector` to its scalar; scalars pass through +# (`extract_solution` returns length-1 vectors for scalar variables). +_unwrap_scalar(v::Real) = v +_unwrap_scalar(v::AbstractVector) = only(v) diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 198ada2a..c599102a 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -209,77 +209,6 @@ function test_loa_complement_indicator_nonlinear_disjunct() @test objective_value(model) ≈ 8.0 atol = 1e-3 end -function test_linearize_nonlinear_exp() - # exp(x) + y at (1, 2): - # f = e + 2, ∇f = [e, 1] - # linear: e*(x-1) + 1*(y-2) + (e+2) = e*x + y - model = GDPModel() - @variable(model, x) - @variable(model, y) - func = @expression(model, exp(x) + y) - xk = Dict{JuMP.AbstractVariableRef, Float64}( - x => 1.0, y => 2.0) - id_map = Dict(x => x, y => y) - lin = DP._linearize_at(func, xk, id_map) - @test JuMP.constant(lin) ≈ 0.0 atol = 1e-8 - @test JuMP.coefficient(lin, x) ≈ exp(1.0) atol = 1e-8 - @test JuMP.coefficient(lin, y) ≈ 1.0 atol = 1e-8 -end - -function test_linearize_nonlinear_sin() - # sin(x) at x = π/6: - # f = 0.5, f' = cos(π/6) = √3/2 - # linear: 0.5 + (√3/2)(x - π/6) - model = GDPModel() - @variable(model, x) - func = @expression(model, sin(x)) - xk = Dict{JuMP.AbstractVariableRef, Float64}( - x => π / 6) - id_map = Dict(x => x) - lin = DP._linearize_at(func, xk, id_map) - expected_const = 0.5 - (√3 / 2) * (π / 6) - @test JuMP.constant(lin) ≈ expected_const atol = 1e-8 - @test JuMP.coefficient(lin, x) ≈ √3 / 2 atol = 1e-8 -end - -function test_linearize_nonlinear_multivar() - # exp(x) * sin(y) at (1, π/2): - # f = e*1 = e, ∂f/∂x = e*sin(π/2) = e, ∂f/∂y = e*cos(π/2) = 0 - # linear: e + e*(x-1) + 0*(y-π/2) = e*x - model = GDPModel() - @variable(model, x) - @variable(model, y) - func = @expression(model, exp(x) * sin(y)) - xk = Dict{JuMP.AbstractVariableRef, Float64}( - x => 1.0, y => π / 2) - id_map = Dict(x => x, y => y) - lin = DP._linearize_at(func, xk, id_map) - @test JuMP.constant(lin) ≈ 0.0 atol = 1e-8 - @test JuMP.coefficient(lin, x) ≈ exp(1.0) atol = 1e-8 - @test JuMP.coefficient(lin, y) ≈ 0.0 atol = 1e-8 -end - -function test_to_nlp_expr() - model = GDPModel() - @variable(model, x) - @variable(model, y) - idx = Dict(x => 1, y => 2) - - # NonlinearExpr - nl = @expression(model, exp(x)) - e = DP._to_nlp_expr(nl, idx) - @test e == Expr(:call, :exp, MOI.VariableIndex(1)) - - # AffExpr - aff = @expression(model, 2x + 3y + 1) - e = DP._to_nlp_expr(aff, idx) - @test e isa Expr - @test e.head == :call && e.args[1] == :+ - - # Number - @test DP._to_nlp_expr(42, idx) == 42 -end - function test_loa_nlpf_infeasible_disjunct() # Y1 disjunct constraint x^2 >= 200 is NLP-infeasible against the # variable bound x in [0, 10] (max x^2 = 100). The primary NLP at @@ -561,10 +490,6 @@ end test_loa_nonlinear_global() test_loa_complement_indicator_nonlinear_disjunct() test_loa_nlpf_infeasible_disjunct() - test_linearize_nonlinear_exp() - test_linearize_nonlinear_sin() - test_linearize_nonlinear_multivar() - test_to_nlp_expr() test_loa_sense_primitives() test_loa_iteration_loop() test_loa_time_limits() diff --git a/test/utilities.jl b/test/utilities.jl index 2b87e6e1..2a98366d 100644 --- a/test/utilities.jl +++ b/test/utilities.jl @@ -238,6 +238,77 @@ function test_get_constant_variable() @test DP.get_constant(x) == 0.0 end +function test_linearize_nonlinear_exp() + # exp(x) + y at (1, 2): + # f = e + 2, ∇f = [e, 1] + # linear: e*(x-1) + 1*(y-2) + (e+2) = e*x + y + model = GDPModel() + @variable(model, x) + @variable(model, y) + func = @expression(model, exp(x) + y) + xk = Dict{JuMP.AbstractVariableRef, Float64}( + x => 1.0, y => 2.0) + id_map = Dict(x => x, y => y) + lin = DP._linearize_at(func, xk, id_map) + @test JuMP.constant(lin) ≈ 0.0 atol = 1e-8 + @test JuMP.coefficient(lin, x) ≈ exp(1.0) atol = 1e-8 + @test JuMP.coefficient(lin, y) ≈ 1.0 atol = 1e-8 +end + +function test_linearize_nonlinear_sin() + # sin(x) at x = π/6: + # f = 0.5, f' = cos(π/6) = √3/2 + # linear: 0.5 + (√3/2)(x - π/6) + model = GDPModel() + @variable(model, x) + func = @expression(model, sin(x)) + xk = Dict{JuMP.AbstractVariableRef, Float64}( + x => π / 6) + id_map = Dict(x => x) + lin = DP._linearize_at(func, xk, id_map) + expected_const = 0.5 - (√3 / 2) * (π / 6) + @test JuMP.constant(lin) ≈ expected_const atol = 1e-8 + @test JuMP.coefficient(lin, x) ≈ √3 / 2 atol = 1e-8 +end + +function test_linearize_nonlinear_multivar() + # exp(x) * sin(y) at (1, π/2): + # f = e*1 = e, ∂f/∂x = e*sin(π/2) = e, ∂f/∂y = e*cos(π/2) = 0 + # linear: e + e*(x-1) + 0*(y-π/2) = e*x + model = GDPModel() + @variable(model, x) + @variable(model, y) + func = @expression(model, exp(x) * sin(y)) + xk = Dict{JuMP.AbstractVariableRef, Float64}( + x => 1.0, y => π / 2) + id_map = Dict(x => x, y => y) + lin = DP._linearize_at(func, xk, id_map) + @test JuMP.constant(lin) ≈ 0.0 atol = 1e-8 + @test JuMP.coefficient(lin, x) ≈ exp(1.0) atol = 1e-8 + @test JuMP.coefficient(lin, y) ≈ 0.0 atol = 1e-8 +end + +function test_to_nlp_expr() + model = GDPModel() + @variable(model, x) + @variable(model, y) + idx = Dict(x => 1, y => 2) + + # NonlinearExpr + nl = @expression(model, exp(x)) + e = DP._to_nlp_expr(nl, idx) + @test e == Expr(:call, :exp, MOI.VariableIndex(1)) + + # AffExpr + aff = @expression(model, 2x + 3y + 1) + e = DP._to_nlp_expr(aff, idx) + @test e isa Expr + @test e.head == :call && e.args[1] == :+ + + # Number + @test DP._to_nlp_expr(42, idx) == 42 +end + @testset "Utility Functions" begin test_all_variables() test_collect_all_vars() @@ -245,4 +316,8 @@ end test_get_constant_quadratic() test_get_constant_number() test_get_constant_variable() + test_linearize_nonlinear_exp() + test_linearize_nonlinear_sin() + test_linearize_nonlinear_multivar() + test_to_nlp_expr() end From 531755261e63768c4573b721f8d0d73ab22c2308 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Wed, 1 Jul 2026 16:24:00 -0400 Subject: [PATCH 59/72] Reverting Project.toml --- Project.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/Project.toml b/Project.toml index db716b40..dbff0ec1 100644 --- a/Project.toml +++ b/Project.toml @@ -4,7 +4,6 @@ authors = ["hdavid16 "] version = "0.6.0" [deps] -Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" JuMP = "4076af6c-e467-56ae-b986-b466b2749572" Reexport = "189a3867-3050-52da-a836-e630ba90ab69" @@ -16,7 +15,6 @@ InfiniteDisjunctiveProgramming = "InfiniteOpt" [compat] Aqua = "0.8" -Distributions = "0.25.125" InfiniteOpt = "0.6.3" Ipopt = "1.9.0" JuMP = "1.18" From 7de44e73ceece172356010f61aa0a141cfcddd5e Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Thu, 2 Jul 2026 11:52:43 -0400 Subject: [PATCH 60/72] fixing Project.toml --- Project.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/Project.toml b/Project.toml index 618e92a0..bb1c3066 100644 --- a/Project.toml +++ b/Project.toml @@ -21,9 +21,6 @@ JuMP = "1.18" Juniper = "0.9.3" Reexport = "1" julia = "1.10" -Juniper = "0.9.3" -Ipopt = "1.9.0" -InfiniteOpt = "0.6.3" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" From ec73ec6b10628793111bc1fe90e9468192eed616 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Thu, 2 Jul 2026 12:59:54 -0400 Subject: [PATCH 61/72] _restore_logical_binaries --- Project.toml | 9 ++++----- src/reformulate.jl | 14 ++++++++++++++ test/constraints/loa.jl | 29 +++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/Project.toml b/Project.toml index bb1c3066..bdec9be4 100644 --- a/Project.toml +++ b/Project.toml @@ -15,20 +15,19 @@ InfiniteDisjunctiveProgramming = "InfiniteOpt" [compat] Aqua = "0.8" -InfiniteOpt = "0.6.3" -Ipopt = "1.9.0" JuMP = "1.18" -Juniper = "0.9.3" Reexport = "1" julia = "1.10" +Juniper = "0.9.3" +Ipopt = "1.9.0" +InfiniteOpt = "0.6.3" [extras] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" -InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" Ipopt = "b6b21f68-93f8-5de0-b562-5493be1d77c9" Juniper = "2ddba703-00a4-53a7-87a5-e8b9971dde84" -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] test = ["Aqua", "HiGHS", "Test", "Juniper", "Ipopt", "InfiniteOpt"] diff --git a/src/reformulate.jl b/src/reformulate.jl index 9a3b65cb..bb08f2b8 100644 --- a/src/reformulate.jl +++ b/src/reformulate.jl @@ -25,6 +25,20 @@ function _clear_reformulations(model::JuMP.AbstractModel) empty!(gdp_data(model).reformulation_constraints) empty!(gdp_data(model).reformulation_variables) empty!(gdp_data(model).variable_bounds) + _restore_logical_binaries(model) + return +end + +# An LOA solve leaves the logical binaries relaxed and fixed to its +# incumbent. Restore them to free binaries so a later reformulation of the +# model (or a copy of it) sees the integrality it expects. +function _restore_logical_binaries(model::JuMP.AbstractModel) + V = JuMP.variable_ref_type(typeof(model)) + for (_, bvar) in _indicator_to_binary(model) + bvar isa V || continue + JuMP.is_fixed(bvar) && JuMP.unfix(bvar) + JuMP.is_binary(bvar) || JuMP.set_binary(bvar) + end return end diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index c599102a..446ac756 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -478,6 +478,34 @@ function test_loa_hull_nested_sink() @test haskey(sink, (x, y[2])) end +function test_reformulate_after_loa_restores_binaries() + # LOA relaxes the logical binaries and fixes them to its incumbent, so + # a later reformulation of the same model must restore binary + # integrality. Regression: a nested disjunction's tight-M looked up a + # relaxed binary's bounds and threw a KeyError. + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 1 <= x <= 9) + @variable(model, Y[1:2], Logical) + @variable(model, W[1:2], Logical) + @constraint(model, 1 <= x <= 2, Disjunct(W[1])) + @constraint(model, 2 <= x <= 3, Disjunct(W[2])) + @constraint(model, x >= 8, Disjunct(Y[2])) + @disjunction(model, [W[1], W[2]], Disjunct(Y[1])) + @disjunction(model, [Y[1], Y[2]]) + @objective(model, Max, x) + + optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) + bins = [v for (_, v) in DP._indicator_to_binary(model) + if v isa JuMP.VariableRef] + @test all(!JuMP.is_binary, bins) # LOA left them relaxed + + @test optimize!(model, gdp_method = BigM()) isa Nothing + @test all(JuMP.is_binary, bins) # restored by reformulation + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 9 +end + @testset "LOA" begin test_loa_datatype() test_set_covering_combos() @@ -500,4 +528,5 @@ end test_loa_hull_nonlinear_disjunct() test_loa_hull_complement_nonlinear() test_loa_hull_nested_sink() + test_reformulate_after_loa_restores_binaries() end From 441a8066cbb6e9e90bec751f1d3648b0c7e65046 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Thu, 2 Jul 2026 14:20:58 -0400 Subject: [PATCH 62/72] Refactor LOA to solve the transcribed backend directly --- ext/InfiniteDisjunctiveProgramming.jl | 849 +++--------------- src/datatypes.jl | 41 +- src/hull.jl | 8 +- src/loa.jl | 638 ++++++------- src/utilities.jl | 11 +- test/constraints/loa.jl | 35 +- .../InfiniteDisjunctiveProgramming.jl | 110 +-- 7 files changed, 479 insertions(+), 1213 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 318e3d39..5a9426eb 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -253,7 +253,7 @@ function _interpolate_at( args::NTuple{N, <:Real} ) where {N} # lower-corner cell index per dimension - idx_lo = ntuple(d -> + idx_lo = ntuple(d -> clamp(searchsortedlast(grids[d], args[d]),1, length(grids[d]) - 1), N ) # max over the 2^N corners; bit d of k picks lower or upper @@ -331,23 +331,19 @@ function DP.copy_and_reformulate( return sub end -# InfiniteOpt-internal primitive: for an InfiniteOpt variable, return -# its per-support values as a Vector. `JuMP.value` returns a scalar -# (finite vars), Vector (1 infinite param), or N-D Array (multiple -# independent param groups). `vcat` lifts a scalar to a 1-element -# Vector; `vec` flattens any N-D Array to a Vector. -_per_support_values(variable::InfiniteOpt.GeneralVariableRef) = - vec(vcat(JuMP.value(variable))) - -# Read per-support values from the transformation backend, keyed by -# InfiniteOpt vars. Skips fixed vars. The objective-side translation -# (transcribe-then-AD when the objective has aggregate refs) lives in -# the `objective_linearization_point(::InfiniteModel, ...)` override -# below — base `extract_solution` doesn't need to anticipate it. +# Read per-support values from the transformation backend. function DP.extract_solution(model::InfiniteOpt.InfiniteModel) - return Dict(var => _per_support_values(var) - for var in DP.collect_all_vars(model) - if !JuMP.is_fixed(var)) + dvars = DP.collect_cutting_planes_vars(model) + V = eltype(dvars) + T = JuMP.value_type(typeof(model)) + sol = Dict{V, Vector{T}}() + for v in dvars + transcription_var = InfiniteOpt.transformation_variable(v) + var_prefs = InfiniteOpt.parameter_refs(v) + sol[v] = isempty(var_prefs) ? [JuMP.value(transcription_var)] : + JuMP.value.(vec(transcription_var)) + end + return sol end # Add a pointwise-sum cut directly to the transformation backend and mark @@ -380,759 +376,142 @@ function DP.add_cut( InfiniteOpt.set_transformation_backend_ready(model, true) return end + ################################################################################ # LOA FOR INFINITEMODEL ################################################################################ -# Dispatch overrides for InfiniteModel. The base LOA in src/loa.jl is -# written for finite (scalar) models. The InfiniteOpt master and feas -# submodel are themselves InfiniteModels, with per-support handling -# via point evaluation on infinite `GeneralVariableRef`s. - -# Supports of the infinite parameter group `v` depends on. For a 1-D -# parameter, `supports(p)` is a `Vector{Float64}`; for a dependent -# group of dimension k, it is a k × N_supports `Matrix`. Returns each -# joint support point as one element — scalar for 1-D, column view for -# multi-D — so callers can iterate uniformly. Pair with `_at_support`, -# which splats vector supports into the variable's call form. -function _supports_of(v::InfiniteOpt.GeneralVariableRef) - p = only(InfiniteOpt.parameter_refs(v)) - sup = InfiniteOpt.supports(p) - ndims(sup) == 1 && return sup - # Materialize each column as a concrete `Vector{Float64}`; - # `eachcol` yields `SubArray` views that InfiniteOpt's - # `VectorTuple` constructor refuses. - return [sup[:, k] for k in axes(sup, 2)] -end - -_is_point_var(v::InfiniteOpt.GeneralVariableRef) = - InfiniteOpt.dispatch_variable_ref(v) isa InfiniteOpt.PointVariableRef - -# A `MeasureRef` collapses `f(x, t)` over an infinite parameter into -# one ref; a `ParameterFunctionRef` collapses a parameter-dependent -# function value into one ref. Either hides decision-variable -# dependence from MOI Nonlinear AD, so LOA needs to transcribe the -# enclosing expression to recover correct gradients. -_is_aggregate_ref(v::InfiniteOpt.GeneralVariableRef) = - InfiniteOpt.dispatch_variable_ref(v) isa Union{ - InfiniteOpt.MeasureRef, InfiniteOpt.ParameterFunctionRef} - -function _has_aggregate_ref(expr) - found = Ref(false) - DP._interrogate_variables(expr) do v - found[] || (found[] = _is_aggregate_ref(v)) - end - return found[] -end - -# Resolve a `PointVariableRef` (e.g., `L(0)` from a boundary -# condition) to the master's corresponding point variable: look up -# the underlying infinite var in `var_map`, then point-evaluate at -# the same support values. For all other variable refs (parameters, -# infinite/finite decision vars), fall back to direct lookup. -function DP.replace_variables_in_constraint( - v::InfiniteOpt.GeneralVariableRef, var_map::AbstractDict - ) - if _is_point_var(v) - underlying = InfiniteOpt.infinite_variable_ref(v) - return var_map[underlying](InfiniteOpt.parameter_values(v)...) - end - return var_map[v] -end - -# Bool active arises from `_set_covering_combinations`, which keys -# combinations on `LogicalVariableRef -> Bool` regardless of whether -# the indicator is infinite. Broadcast over all supports for an -# infinite indicator; for a finite (or point-variable) ref, fall -# through to the base scalar dispatch. -function DP.add_no_good_terms( - cut, binary_ref::InfiniteOpt.GeneralVariableRef, active::Bool - ) - isempty(InfiniteOpt.parameter_refs(binary_ref)) && - return invoke(DP.add_no_good_terms, - Tuple{Any, Any, Bool}, cut, binary_ref, active) - for support in _supports_of(binary_ref) - DP.add_no_good_terms(cut, _at_support(binary_ref, support), active) - end - return -end - -# AbstractVector active arises from `combination_val` after a master -# solve, which yields per-support `Vector{Bool}`. -function DP.add_no_good_terms( - cut, binary_ref::InfiniteOpt.GeneralVariableRef, - actives::AbstractVector - ) - supports = _supports_of(binary_ref) - for (k, support) in enumerate(supports) - DP.add_no_good_terms( - cut, _at_support(binary_ref, support), actives[k]) - end - return -end - -# Complement-form AffExpr (`1 - y_underlying`) with per-support active -# descriptor — same fan-out as the variable-ref form, but the -# underlying var lives inside the AffExpr's terms. Use the underlying -# var's supports to drive the loop. -function DP.add_no_good_terms( - cut, binary_ref::JuMP.GenericAffExpr, - actives::AbstractVector - ) - underlying = only(keys(binary_ref.terms)) - underlying isa InfiniteOpt.GeneralVariableRef || return invoke( - DP.add_no_good_terms, - Tuple{Any, Any, AbstractVector}, cut, binary_ref, actives) - supports = _supports_of(underlying) - for (k, support) in enumerate(supports) - DP.add_no_good_terms( - cut, _at_support(binary_ref, support), actives[k]) - end - return -end - -# Fan out across supports if either the binary or the constraint -# expression involves an infinite variable; otherwise emit one -# un-sliced site. The chosen supports come from the first infinite -# variable found in either expression. -function _infinite_cut_info( - binary_ref, - active, - constraint_func, - linearization_point, - variable_map - ) - supports = _relevant_supports(binary_ref, constraint_func) - supports === nothing && - return ((binary_ref, linearization_point, variable_map),) - actives = active isa AbstractVector ? active : - fill(active, length(supports)) - return _cut_sites(binary_ref, supports, actives, - linearization_point, variable_map) -end - -# Supports governing per-support fan-out — pick the first infinite -# variable found in the binary expression, falling back to the -# constraint expression. `nothing` means everything is finite, so the -# caller emits a single un-sliced site. -function _relevant_supports(binary_ref, constraint_func) - var = _find_infinite_var(binary_ref) - var === nothing && (var = _find_infinite_var(constraint_func)) - var === nothing && return nothing - return _supports_of(var) -end - -_find_infinite_var(v::InfiniteOpt.GeneralVariableRef) = - isempty(InfiniteOpt.parameter_refs(v)) ? nothing : v - -function _find_infinite_var(expr) - found = Ref{Any}(nothing) - DP._interrogate_variables(expr) do v - found[] === nothing || return - v isa InfiniteOpt.GeneralVariableRef || return - isempty(InfiniteOpt.parameter_refs(v)) && return - found[] = v - end - return found[] -end - -function _cut_sites( - binary_ref, - supports::AbstractVector, - actives::AbstractVector, - linearization_point::AbstractDict, - variable_map::AbstractDict - ) - sites = Any[] - for (k, support) in enumerate(supports) - actives[k] || continue - point_var_map = Dict{ - InfiniteOpt.GeneralVariableRef, - InfiniteOpt.GeneralVariableRef - }() - for (variable, master_var) in variable_map - point_var_map[variable] = _at_support(master_var, support) - end - point = Dict{InfiniteOpt.GeneralVariableRef, Float64}() - for (variable, point_value) in linearization_point - point[variable] = _at(point_value, k) - end - push!(sites, ( - _at_support(binary_ref, support), - point, - point_var_map - )) - end - return sites -end - -# Slice a per-support container at index `k`. Length-1 vectors are -# finite-shape (one value applies at every support, per -# `_per_support_values` wrapping `JuMP.value` of a finite var as -# `[scalar]`) — return the single value regardless of `k`. Scalars -# pass through. -_at(values::AbstractArray, k::Integer) = +# LOA override for InfiniteModel: after the inner reformulation, build +# the transformation backend and hand the LOA loop the backend model — +# a plain scalar JuMP model whose variables, measures, and constraints +# are already transcribed per support. The loop runs entirely on base +# code and solves the backend directly, which never invalidates it, so +# the committed fixes and warm starts survive the final `optimize!`. + +# Per-support transcribed refs of an InfiniteOpt object, as a Vector +# (finite objects give one element). +_transcribed_refs(x::AbstractArray) = vec(x) +_transcribed_refs(x) = [x] + +# Index a per-support vector, broadcasting a single-element (finite) +# vector across all supports. +_at(values::AbstractVector, k::Int) = length(values) == 1 ? values[1] : values[k] -_at(scalar, ::Integer) = scalar - -# Point-evaluate an InfiniteOpt var at `support` if it's infinite; -# return the var as-is if it's finite. `support` is one joint support -# point — a scalar for 1-D parameters, a vector for a multi-D -# dependent group — and `v(support)` matches both call forms. -_at_support(v::InfiniteOpt.GeneralVariableRef, support) = - isempty(InfiniteOpt.parameter_refs(v)) ? v : v(support) -# Rebuild a complement-form AffExpr (`1 - y_underlying`) with its -# variables point-evaluated, so per-support fan-out can use the -# AffExpr directly in the gating term `M(1 - binary_at_support)`. -function _at_support( - expr::JuMP.GenericAffExpr{C, V}, support - ) where {C, V <: InfiniteOpt.GeneralVariableRef} - result = JuMP.GenericAffExpr{C, V}(expr.constant) - for (var, coef) in expr.terms - JuMP.add_to_expression!(result, coef, _at_support(var, support)) - end - return result +# Per-support transcribed binary references for an indicator: the +# transcribed underlying binary, complemented for a complement-form +# indicator. +function _transcribed_binary_refs(model::InfiniteOpt.InfiniteModel, indicator) + binary_ref = DP._indicator_to_binary(model)[indicator] + binaries = _transcribed_refs(InfiniteOpt.transformation_variable( + DP._underlying_binary(binary_ref))) + binary_ref isa JuMP.GenericAffExpr || return binaries + return [1.0 - binary for binary in binaries] end -# Map transcribed input JuMP var -> master point variable, used as -# `objective_ref_map`. Needed because MOI AD can't walk a `MeasureRef`, -# so a measure objective is transcribed, AD'd flat, and the gradient -# mapped back to master point vars. Per-support disjunct cuts skip this -# (they linearize natively via `_infinite_cut_info`). -function _transcribed_to_master_point( +# Transcribed combination for an InfiniteModel: every support of the +# underlying transcribed binary takes the indicator's value. +function _transcribed_combination( model::InfiniteOpt.InfiniteModel, - ref_map::AbstractDict + combination ) - result = Dict{JuMP.VariableRef, InfiniteOpt.GeneralVariableRef}() - for v in DP.collect_all_vars(model) - # Point vars share the infinite var's transcription, so skipping - # them loses no mappings. - _is_point_var(v) && continue - master_var = ref_map[v] - transcribed = InfiniteOpt.transformation_variable(v) - prefs = InfiniteOpt.parameter_refs(v) - if isempty(prefs) - result[transcribed] = master_var - else - # `_supports_of`/`_at_support` handle 1-D (scalar) and - # multi-D (vector) supports; `vec(supports(...))` would - # flatten multi-D and break point evaluation. - for (k, support) in enumerate(_supports_of(v)) - result[vec(transcribed)[k]] = - _at_support(master_var, support) - end + result = Dict{JuMP.VariableRef, Bool}() + binary_map = DP._indicator_to_binary(model) + for (indicator, active) in combination + binary_ref = binary_map[indicator] + value = DP._underlying_value(binary_ref, active) + for binary in _transcribed_refs(InfiniteOpt.transformation_variable( + DP._underlying_binary(binary_ref))) + result[binary] = value end end return result end -# Build the master from `JuMP.copy_model(model)`, keeping linear -# constraints and the InfiniteOpt vars (per-support handling is done -# downstream by point evaluation). The objective branches on -# `_has_aggregate_ref`: aggregate-free objectives AD the InfiniteOpt -# expression directly; aggregate ones are transcribed flat and mapped -# back via `_transcribed_to_master_point`. -function DP.build_loa_master( - model::InfiniteOpt.InfiniteModel, - method::DP.LOA, - sink = nothing - ) - variable_type = InfiniteOpt.GeneralVariableRef - master, copy_ref_map = JuMP.copy_model( - model; - filter_constraints = function (cref) - con = JuMP.constraint_object(cref) - F = typeof(con.func) - F === variable_type && return true - DP._is_linear_F(F) || return false - return !_has_aggregate_ref(con.func) - end - ) - JuMP.set_optimize_hook(master, nothing) - JuMP.set_optimizer(master, method.mip_optimizer) - JuMP.set_silent(master) - - ref_map = Dict{InfiniteOpt.GeneralVariableRef, - InfiniteOpt.GeneralVariableRef}() - for v in DP.collect_all_vars(model) - _is_point_var(v) && continue - ref_map[v] = copy_ref_map[v] - end - for p in InfiniteOpt.all_parameters(model) - ref_map[p] = copy_ref_map[p] - end - for pfunc in InfiniteOpt.all_parameter_functions(model) - ref_map[pfunc] = copy_ref_map[pfunc] - end - - raw_objective = JuMP.objective_function(model) - objective_sense = JuMP.objective_sense(model) - if _has_aggregate_ref(raw_objective) - InfiniteOpt.build_transformation_backend!(model) - transcribed_input = InfiniteOpt.transformation_model(model) - original_objective = JuMP.objective_function(transcribed_input) - objective_ref_map = _transcribed_to_master_point( - model, ref_map) - else - original_objective = raw_objective - objective_ref_map = ref_map - end - - alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") - JuMP.@objective(master, objective_sense, alpha_oa) - - binary_map = Dict{DP.LogicalVariableRef, Any}() - for (indicator, binary_ref) in DP._indicator_to_binary(model) - binary_map[indicator] = DP._remap_indicator_to_binary( - binary_ref, ref_map) - end - variable_map = Dict{InfiniteOpt.GeneralVariableRef, - InfiniteOpt.GeneralVariableRef}() - for v in DP.collect_all_vars(model) - _is_point_var(v) && continue - variable_map[v] = ref_map[v] - end - - # Aggregate-wrapped LINEAR constraints (e.g. `E(W, e) ≥ alpha`) were - # dropped at copy time and the OA-cut path skips linear `F`, so add - # each one to the master directly as a transcribed flat scalar. - _add_aggregate_linear_constraints( - master, model, ref_map, variable_type) - - return DP._LOAMaster(master, binary_map, variable_map, - objective_sense, original_objective, alpha_oa, - objective_ref_map, DP._build_disaggregator( - model, method.inner_method, variable_map, binary_map, sink)) -end - -function DP.record_disaggregation( - hull::DP._Hull, - ::InfiniteOpt.InfiniteModel, - variable, - binary, - disaggregated +# Transcribe the inner Hull disaggregation map per support. A finite +# variable under an infinite indicator keys its single disaggregated +# copy by each per-support binary reference, mirroring the per-support +# disjunct cuts that look it up. +_transcribed_disaggregation_map(::InfiniteOpt.InfiniteModel, ::Nothing) = + nothing +function _transcribed_disaggregation_map( + model::InfiniteOpt.InfiniteModel, + disaggregation_map ) - supports = _disaggregator_supports(variable, binary) - if supports === nothing - hull.disjunct_variables[(variable, binary)] = disaggregated - else - for support in supports - hull.disjunct_variables[(_at_support(variable, support), - _at_support(binary, support))] = - _at_support(disaggregated, support) + result = Dict{Any, Any}() + for ((variable, indicator), disaggregated) in disaggregation_map + binary_refs = _transcribed_binary_refs(model, indicator) + variables = _transcribed_refs( + InfiniteOpt.transformation_variable(variable)) + disaggregateds = _transcribed_refs(InfiniteOpt.transformation_variable( + disaggregated)) + n = max(length(binary_refs), length(variables), length(disaggregateds)) + for k in 1:n + result[(_at(variables, k), _at(binary_refs, k))] = + _at(disaggregateds, k) end end - return -end - -# Supports the per-support fan-out gates this disjunct on (mirrors -# `_relevant_supports`): the binary's if the indicator is infinite, else -# the variable's if it is infinite, else `nothing` (both finite, one -# unsliced key). Keying off the binary lets a finite variable under an -# infinite indicator match the sliced binary the emitter looks up. -function _disaggregator_supports(master_var, master_bin) - binary_var = _find_infinite_var(master_bin) - binary_var === nothing || return _supports_of(binary_var) - isempty(InfiniteOpt.parameter_refs(master_var)) && return nothing - return _supports_of(master_var) + return result end -# Transcribe each linear constraint containing a `MeasureRef` / -# `ParameterFunctionRef` and append the flat scalar form to the master. -# Already affine post-transcription, so `_linearize_at` just substitutes -# variables via `transcribed_to_master`. -function _add_aggregate_linear_constraints( - master::InfiniteOpt.InfiniteModel, +function DP.build_loa_problem( model::InfiniteOpt.InfiniteModel, - ref_map::AbstractDict, - variable_type::Type + method::DP.LOA, + disaggregation_map = nothing ) - has_any = false - for (F, S) in JuMP.list_of_constraint_types(model) - F === variable_type && continue - DP._is_linear_F(F) || continue - for cref in JuMP.all_constraints(model, F, S) - con = JuMP.constraint_object(cref) - _has_aggregate_ref(con.func) || continue - has_any = true - break - end - has_any && break - end - has_any || return - InfiniteOpt.build_transformation_backend!(model) - transcribed_to_master = _transcribed_to_master_point(model, ref_map) - # Finite parameters survive transcription as scalar JuMP vars and can - # appear in transcribed constraints, but `_transcribed_to_master_point` - # only walks decision vars. Map them to the master's parameters so the - # constraint stays parameter-relative (honors `set_value` without - # rebuild). - for p in InfiniteOpt.all_parameters(model) - InfiniteOpt.dispatch_variable_ref(p) isa - InfiniteOpt.FiniteParameterRef || continue - transcribed_p = InfiniteOpt.transformation_variable(p) - transcribed_p isa JuMP.VariableRef || continue - haskey(ref_map, p) || continue - transcribed_to_master[transcribed_p] = ref_map[p] - end - # `_linearize_at(::GenericAffExpr, ...)` ignores its - # `linearization_point` arg; pass any empty dict. - empty_point = Dict{JuMP.VariableRef, Float64}() - for (F, S) in JuMP.list_of_constraint_types(model) - F === variable_type && continue - DP._is_linear_F(F) || continue - for cref in JuMP.all_constraints(model, F, S) - con = JuMP.constraint_object(cref) - _has_aggregate_ref(con.func) || continue - con isa JuMP.ScalarConstraint || continue - transcribed_func = InfiniteOpt.transformation_expression( - con.func) - if transcribed_func isa AbstractArray - for tf in vec(transcribed_func) - master_expr = DP._linearize_at( - tf, empty_point, transcribed_to_master) - JuMP.@constraint(master, master_expr in con.set) + nlp = InfiniteOpt.transformation_model(model) + + binaries = JuMP.VariableRef[] + for (_, binary_ref) in DP._indicator_to_binary(model) + append!(binaries, _transcribed_refs( + InfiniteOpt.transformation_variable( + DP._underlying_binary(binary_ref)))) + end + unique!(binaries) + combinations = [_transcribed_combination(model, combination) + for combination in DP._set_covering_combinations(model)] + + disjunct_constraints = Tuple{Any, Any, Any}[] + for (_, disjunction) in DP._disjunctions(model) + for indicator in disjunction.constraint.indicators + haskey(DP._indicator_to_constraints(model), indicator) || + continue + binary_refs = _transcribed_binary_refs(model, indicator) + for cref in DP._indicator_to_constraints(model)[indicator] + cref isa DP.DisjunctConstraintRef || continue + constraint = DP._disjunct_constraints(model)[ + JuMP.index(cref)].constraint + constraint isa JuMP.ScalarConstraint || continue + DP._is_linear_F(typeof(constraint.func)) && continue + funcs = _transcribed_refs(InfiniteOpt.transformation_expression( + constraint.func)) + for k in 1:max(length(binary_refs), length(funcs)) + push!(disjunct_constraints, + (_at(binary_refs, k), _at(funcs, k), constraint.set)) end - else - master_expr = DP._linearize_at( - transcribed_func, empty_point, transcribed_to_master) - JuMP.@constraint(master, master_expr in con.set) end end end - return -end - -# Objective linearization point for `InfiniteModel` (the seam the shared -# base `add_oa_cuts` calls). The master objective is either the raw -# InfiniteOpt expression (non-aggregate, expects a scalar `xk[v]` keyed -# on InfiniteOpt vars) or the transcribed flat scalar expression -# (aggregate, expects a transcribed-`JuMP.VariableRef`-keyed scalar -# dict). Translate the base per-support `Vector`-valued point into -# whichever shape `original_objective` needs. -function DP.objective_linearization_point( - model::InfiniteOpt.InfiniteModel, - linearization_point::AbstractDict - ) - _has_aggregate_ref(JuMP.objective_function(model)) && - return _transcribe_linearization_point(model, linearization_point) - T = eltype(valtype(linearization_point)) - return Dict{InfiniteOpt.GeneralVariableRef, T}( - var => values[1] - for (var, values) in linearization_point - if isempty(InfiniteOpt.parameter_refs(var))) -end -# Global OA cuts for `InfiniteModel`: route through transcription so -# per-support / aggregate-ref expressions reach `_linearize_at` as flat -# scalars over `JuMP.VariableRef`s. Aggregate-containing constraints -# (e.g. `∫f(x,t)dt ≤ 0`) transcribe to a single scalar expression. -# Constraints with infinite-parameter dependence (e.g. `x(t) ≥ 0`) -# transcribe to a per-support `AbstractArray` of scalar expressions — -# one OA cut is emitted per support. -function DP.add_global_oa_cuts( - model::InfiniteOpt.InfiniteModel, - master::DP._LOAMaster, - result::NamedTuple, - method::DP.LOA - ) - penalty_sign = DP._penalty_sign(Val(master.objective_sense)) - variable_type = InfiniteOpt.GeneralVariableRef - reform_set = DP.is_gdp_model(model) ? - Set(DP._reformulation_constraints(model)) : Set() - transcribed_xk = Ref{Any}(nothing) - transcribed_to_master = Ref{Any}(nothing) - ensure_transcribed = function () - transcribed_xk[] === nothing || return - InfiniteOpt.build_transformation_backend!(model) - transcribed_xk[] = _transcribe_linearization_point( - model, result.linearization_point) - transcribed_to_master[] = _transcribed_to_master_point( - model, master.variable_map) - return - end + global_constraints = Tuple{Any, Any}[] + reform_set = Set(DP._reformulation_constraints(model)) for (F, S) in JuMP.list_of_constraint_types(model) - F === variable_type && continue + F === InfiniteOpt.GeneralVariableRef && continue DP._is_linear_F(F) && continue for cref in JuMP.all_constraints(model, F, S) cref in reform_set && continue - con = JuMP.constraint_object(cref) - con isa JuMP.ScalarConstraint || continue - ensure_transcribed() - transcribed_func = InfiniteOpt.transformation_expression( - con.func) - if transcribed_func isa AbstractArray - for tf in vec(transcribed_func) - lin = DP._linearize_at(tf, transcribed_xk[], - transcribed_to_master[]) - DP._add_global_oa_row(master, lin, con.set, - method, penalty_sign) - end - else - lin = DP._linearize_at(transcribed_func, - transcribed_xk[], transcribed_to_master[]) - DP._add_global_oa_row(master, lin, con.set, - method, penalty_sign) - end - end - end - return -end - -# Override the disjunct-OA-cut pass for `InfiniteModel`: own the per-pass -# transcription `cache` and delegate each active disjunct constraint to -# the emitter below. Reuses the base active-disjunct walk so only the -# per-constraint emission differs from base. -function DP.add_disjunct_oa_cuts( - model::InfiniteOpt.InfiniteModel, - master::DP._LOAMaster, - result::NamedTuple, - method::DP.LOA - ) - penalty_sign = DP._penalty_sign(Val(master.objective_sense)) - cache = Ref{Any}(nothing) - DP._each_active_disjunct_constraint(model, master, - result) do binary_ref, active, constraint - _add_infinite_disjunct_constraint_oa_cuts(model, constraint, - master, binary_ref, active, result, method, penalty_sign, - cache) - end - return -end - -# Emit the OA cut(s) for one active disjunct constraint on an -# `InfiniteModel`. Non-aggregate constraints fan out per support via -# `_infinite_cut_info`; aggregate ones (`MeasureRef`) are transcribed flat -# and handed to `_add_oa_cut_for_constraint`. `cache` memoizes the -# transcription maps once per pass (lazily, on the first aggregate -# constraint). -function _add_infinite_disjunct_constraint_oa_cuts( - model, constraint, master, binary_ref, active, result, method, - penalty_sign, cache - ) - if _has_aggregate_ref(constraint.func) - if cache[] === nothing - InfiniteOpt.build_transformation_backend!(model) - cache[] = (_transcribe_linearization_point( - model, result.linearization_point), - _transcribed_to_master_point(model, master.variable_map)) - end - transcribed_xk, transcribed_to_master = cache[] - transcribed_func = InfiniteOpt.transformation_expression( - constraint.func) - transcribed_constraint = JuMP.ScalarConstraint( - transcribed_func, constraint.set) - for (point_binary, _, _) in _infinite_cut_info(binary_ref, - active, transcribed_func, result.linearization_point, - master.variable_map) - DP._add_oa_cut_for_constraint(transcribed_constraint, master, - point_binary, transcribed_xk, transcribed_to_master, - method, penalty_sign) - end - return - end - for (point_binary, point, var_map) in _infinite_cut_info(binary_ref, - active, constraint.func, result.linearization_point, - master.variable_map) - DP._add_oa_cut_for_constraint(constraint, master, point_binary, - point, var_map, method, penalty_sign) - end - return -end - -# Apply the combination's fixes and return a closure that reverses them. -# A `Bool` delegates to base `fix_indicator`; a per-support `Vector{Bool}` -# pins each support of the infinite binary with a point-equality -# constraint. The closure (used by `with_fixed_combination` to tear down -# each iteration) deletes those pins and unfixes; `commit_combination` -# ignores it to persist the committed fix. -function DP.fix_combination( - model::InfiniteOpt.InfiniteModel, combination::AbstractDict - ) - constraint_refs = InfiniteOpt.InfOptConstraintRef[] - fixed_indicators = DP.LogicalVariableRef{ - InfiniteOpt.InfiniteModel}[] - for (indicator, value) in combination - if value isa AbstractVector - binary_ref = DP._indicator_to_binary(model)[indicator] - target, target_for_active = binary_ref isa JuMP.GenericAffExpr ? - (only(keys(binary_ref.terms)), 0.0) : - (binary_ref, 1.0) - target_for_inactive = 1.0 - target_for_active - for (k, support) in enumerate(_supports_of(target)) - push!(constraint_refs, JuMP.@constraint(model, - _at_support(target, support) == - (value[k] ? target_for_active : target_for_inactive))) + constraint = JuMP.constraint_object(cref) + constraint isa JuMP.ScalarConstraint || continue + for func in _transcribed_refs(InfiniteOpt.transformation_expression( + constraint.func)) + push!(global_constraints, (func, constraint.set)) end - else - DP.fix_indicator(model, indicator, value) - push!(fixed_indicators, indicator) end end - return function () - for ref in constraint_refs - JuMP.is_valid(model, ref) && JuMP.delete(model, ref) - end - for indicator in fixed_indicators - DP.unfix_indicator(model, indicator) - end - end -end - -# Per-support fix for an infinite logical's `Vector{Bool}` value: an -# equality at each support. `with_fixed_combination` undid the original's -# fix before the NLPF fall-through, so this is the only fix on the copy. -# Equalities (not `JuMP.fix`) avoid force-deleting the relaxed copy's -# bounds, whose refs may not survive `copy_model`. -function DP._fix_binary_on_copy(copy, binary, value::AbstractVector) - underlying = binary isa JuMP.GenericAffExpr ? - only(keys(binary.terms)) : binary - for (k, support) in enumerate(_supports_of(underlying)) - JuMP.@constraint(copy, - _at_support(binary, support) == (value[k] ? 1.0 : 0.0)) - end - return -end - -# Broadcast a per-support warm start across an infinite var's -# transcribed instances; for a finite var on an InfiniteModel, -# `transcription_variable` returns a single ref and `values` is -# length-1. -function DP.set_linearization_start( - variable::InfiniteOpt.GeneralVariableRef, - values::AbstractVector - ) - transcribed = InfiniteOpt.transformation_variable(variable) - if transcribed isa AbstractArray - for (k, ref) in enumerate(vec(transcribed)) - JuMP.set_start_value(ref, values[k]) - end - else - JuMP.set_start_value(transcribed, only(values)) - end - return -end - -# Convert an InfiniteModel-var-keyed per-support point into a -# transcribed-JuMP-var-keyed scalar point. Companion to -# `_transcribed_to_master_point`: feeds the AD walker for the -# objective OA cut. See the WHY note above -# `_transcribed_to_master_point` for the MeasureRef/AD constraint -# that motivates this whole transcribe-then-AD layer. -function _transcribe_linearization_point( - model::InfiniteOpt.InfiniteModel, - linearization_point::AbstractDict - ) - T = eltype(valtype(linearization_point)) - transcribed = Dict{JuMP.VariableRef, T}() - for (variable, values) in linearization_point - _add_to_transcribed_dict( - transcribed, - InfiniteOpt.transformation_variable(variable), - values - ) - end - return transcribed -end -# Infinite var: per-support transcribed array, values per-support -function _add_to_transcribed_dict( - d::AbstractDict, - ts::AbstractArray, - values::AbstractVector - ) - for (k, ref) in enumerate(vec(ts)) - d[ref] = values[k] - end - return -end - -# Finite var: single transcribed ref, values 1-element -_add_to_transcribed_dict( - d::AbstractDict, - ts::JuMP.AbstractVariableRef, - values::AbstractVector - ) = - (d[ts] = values[1]; nothing) - -################################################################################ - -# Build CP subproblem: reformulate the InfiniteModel in-place, transcribe, -# copy, and wrap in GDPSubmodel with forward variable map. -function DP.copy_and_reformulate( - model::InfiniteOpt.InfiniteModel, - decision_vars::Vector{InfiniteOpt.GeneralVariableRef}, - reform_method::DP.AbstractReformulationMethod, - method::DP.CuttingPlanes - ) - DP.reformulate_model(model, reform_method) - InfiniteOpt.build_transformation_backend!(model) - transcribed = InfiniteOpt.transformation_model(model) - transcription_fwd = Dict{InfiniteOpt.GeneralVariableRef, - Vector{JuMP.VariableRef}}() - for v in DP.collect_all_vars(model) - transcription_var = InfiniteOpt.transformation_variable(v) - var_prefs = InfiniteOpt.parameter_refs(v) - transcription_fwd[v] = isempty(var_prefs) ? - [transcription_var] : vec(transcription_var) - end - sub_copy, copy_map = JuMP.copy_model(transcribed) - fwd_map = Dict{InfiniteOpt.GeneralVariableRef, Vector{JuMP.VariableRef}}() - for v in decision_vars - haskey(transcription_fwd, v) || continue - fwd_map[v] = [copy_map[transcribed_var] for transcribed_var in transcription_fwd[v]] - end - sub = DP.GDPSubmodel(sub_copy, decision_vars, fwd_map) - JuMP.set_optimizer(sub.model, method.optimizer) - JuMP.set_silent(sub.model) - return sub -end - -# Read per-support values from the transformation backend. -function DP.extract_solution(model::InfiniteOpt.InfiniteModel) - dvars = DP.collect_cutting_planes_vars(model) - V = eltype(dvars) - T = JuMP.value_type(typeof(model)) - sol = Dict{V, Vector{T}}() - for v in dvars - transcription_var = InfiniteOpt.transformation_variable(v) - var_prefs = InfiniteOpt.parameter_refs(v) - sol[v] = isempty(var_prefs) ? [JuMP.value(transcription_var)] : - JuMP.value.(vec(transcription_var)) - end - return sol -end - -# Add a pointwise-sum cut directly to the transformation backend and mark -# it ready so the next optimize! doesn't re-transcribe and wipe the cut. -function DP.add_cut( - model::InfiniteOpt.InfiniteModel, - decision_vars::Vector{InfiniteOpt.GeneralVariableRef}, - rBM_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}}, - sep_sol::Dict{<:JuMP.AbstractVariableRef, <:Vector{<:Number}} - ) - transcribed = InfiniteOpt.transformation_model(model) - cut_expr = zero(JuMP.GenericAffExpr{ - JuMP.value_type(typeof(transcribed)), - JuMP.variable_ref_type(transcribed)}) - for var in decision_vars - haskey(rBM_sol, var) || continue - haskey(sep_sol, var) || continue - rbm_vals = rBM_sol[var] - sep_vals = sep_sol[var] - transcription_var = InfiniteOpt.transformation_variable(var) - transcribed_vars = transcription_var isa AbstractArray ? - vec(transcription_var) : [transcription_var] - for k in eachindex(transcribed_vars) - xi = 2 * (sep_vals[k] - rbm_vals[k]) - JuMP.add_to_expression!(cut_expr, xi, transcribed_vars[k]) - JuMP.add_to_expression!(cut_expr, -xi * sep_vals[k]) - end - end - JuMP.@constraint(transcribed, cut_expr >= 0) - InfiniteOpt.set_transformation_backend_ready(model, true) - return + return DP._LOAProblem(nlp, binaries, combinations, + disjunct_constraints, global_constraints, + _transcribed_disaggregation_map(model, disaggregation_map)) end end diff --git a/src/datatypes.jl b/src/datatypes.jl index 4d8c9208..93af3239 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -419,9 +419,9 @@ struct Hull{T} <: AbstractReformulationMethod value::T # Internal: LOA installs a Dict here to collect the disaggregation # map during reformulation; `nothing` for every other use. - sink::Any - function Hull(ϵ::T = 1e-6; sink = nothing) where {T} - new{T}(ϵ, sink) + disaggregation_map::Any + function Hull(ϵ::T = 1e-6; disaggregation_map = nothing) where {T} + new{T}(ϵ, disaggregation_map) end end @@ -430,13 +430,13 @@ mutable struct _Hull{V <: JuMP.AbstractVariableRef, T} <: AbstractReformulationM value::T disjunction_variables::Dict{V, Vector{V}} disjunct_variables::Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V} - sink::Any + disaggregation_map::Any function _Hull(method::Hull{T}, vrefs::Set{V}) where {T, V <: JuMP.AbstractVariableRef} new{V, T}( method.value, Dict{V, Vector{V}}(vref => V[] for vref in vrefs), Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V}(), - method.sink + method.disaggregation_map ) end end @@ -632,18 +632,33 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod end end -# The LOA master MILP plus maps from original- to master-model refs. -# `objective_ref_map` splits from `variable_map` so an extension can map -# objective vars separately (identical in base); `disaggregator` is the -# master `_Hull` for Hull cuts, `nothing` for Big-M / MBM. -mutable struct _LOAMaster{M <: JuMP.AbstractModel, OF, AO, BM, VM, RM, DG} +# The problem the LOA loop operates on: the model solved as the NLP +# subproblem, the binary variables backing the indicators, the +# set-covering seed combinations, the nonlinear disjunct +# `(binary_ref, function, set)` triples (`binary_ref` is the binary or +# its `1 - y` complement expression), the nonlinear global +# `(function, set)` pairs, and the `(variable, binary_ref) -> +# disaggregated variable` map from an inner Hull reformulation +# (`nothing` for Big-M / MBM). Built by `build_loa_problem`. +struct _LOAProblem{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef} + nlp::M + binaries::Vector{V} + covering_combinations::Vector{Dict{V, Bool}} + disjunct_constraints::Vector{Tuple{Any, Any, Any}} + global_constraints::Vector{Tuple{Any, Any}} + disaggregation_map::Any +end + +# The LOA master MILP plus the NLP-to-master variable map. +# `objective` is the NLP's objective, linearized per iteration for +# the objective cuts; `disaggregator` is the master `_Hull` for Hull +# cuts, `nothing` for Big-M / MBM. +struct _LOAMaster{M <: JuMP.AbstractModel, VM, OF, AO, DG} model::M - binary_map::BM variable_map::VM objective_sense::_MOI.OptimizationSense - original_objective::OF + objective::OF alpha_oa::AO - objective_ref_map::RM disaggregator::DG end diff --git a/src/hull.jl b/src/hull.jl index 9075799f..6e43daad 100644 --- a/src/hull.jl +++ b/src/hull.jl @@ -42,8 +42,9 @@ function _disaggregate_variable( #temp storage push!(method.disjunction_variables[vref], dvref) method.disjunct_variables[vref, bvref] = dvref - #record into the LOA sink when one is installed (LOA-Hull only) - method.sink === nothing || (method.sink[(vref, lvref)] = dvref) + #record into the LOA disaggregation map when installed (LOA-Hull only) + method.disaggregation_map === nothing || + (method.disaggregation_map[(vref, lvref)] = dvref) #create bounding constraints dvname = JuMP.name(dvref) lbname = isempty(dvname) ? "" : "$(dvname)_lower_bound" @@ -257,7 +258,8 @@ function reformulate_disjunction(model::JuMP.AbstractModel, disj::Disjunction, m end function reformulate_disjunction(model::JuMP.AbstractModel, disj::Disjunction, method::_Hull) return reformulate_disjunction(model, disj, - Hull(method.value; sink = method.sink)) + Hull(method.value; + disaggregation_map = method.disaggregation_map)) end function reformulate_disjunct_constraint( diff --git a/src/loa.jl b/src/loa.jl index 14d6e629..25fb9776 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -17,26 +17,29 @@ _gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best ################################################################################ # MAIN LOOP ################################################################################ -# LOA entry: set-covering seeds, the master/NLP loop, commit the best combo +# LOA entry: build the problem, seed with set-covering combinations, +# iterate the master/NLP loop, and commit the best combination. function reformulate_model(model::JuMP.AbstractModel, method::LOA) _clear_reformulations(model) - combinations = _set_covering_combinations(model) # Hull needs the disaggregation map if its used as inner function - inner, sink = _loa_inner_method(model, method.inner_method) + inner, disaggregation_map = _loa_inner_method(model, method.inner_method) reformulate_model(model, inner) - master = build_loa_master(model, method, sink) - # The original is only ever the NLP (build_loa_master keeps the - # master's binaries), so relax it once; fixing overwrites in place. - relax_logical_vars(model) - JuMP.set_optimizer(model, method.nlp_optimizer) - JuMP.set_silent(model) + problem = build_loa_problem(model, method, disaggregation_map) + # The master copies the NLP while its binaries still carry + # integrality; the NLP is then relaxed once and each iteration + # overwrites the binary fixes in place. + master = _build_loa_master(problem, method) + _relax_binaries(problem) + nlp = problem.nlp + JuMP.set_optimizer(nlp, method.nlp_optimizer) + JuMP.set_silent(nlp) t_start = time() overall_deadline = t_start + method.time_limit loop_deadline = min(t_start + method.iteration_time_limit, overall_deadline) - original_time_limit = JuMP.time_limit_sec(model) - sense_token = Val(JuMP.objective_sense(model)) + original_time_limit = JuMP.time_limit_sec(nlp) + sense_token = Val(master.objective_sense) best_objective = _worst_objective(sense_token) best_result = nothing master_bound = nothing @@ -44,13 +47,13 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) # Seed the master with an OA cut per set-covering combination, each # NLP warm-started from the last feasible primal. previous_result = nothing - for combination in combinations + for combination in problem.covering_combinations time() < loop_deadline || break _set_nlp_warm_start(previous_result) - result = _solve_nlp(model, combination, method; + result = _solve_nlp(problem, combination, method; deadline = loop_deadline) - avoid_combination(master.model, combination, master.binary_map) - add_oa_cuts(model, master, result, method) + avoid_combination(master.model, combination, master.variable_map) + _add_oa_cuts(problem, master, result, method) if result.feasible && _is_better(sense_token, result.objective, best_objective) best_objective = result.objective @@ -78,12 +81,12 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) break end end - combination = _extract_combination(model, master) + combination = _extract_combination(problem, master) _set_nlp_warm_start(previous_result) - result = _solve_nlp(model, combination, method; + result = _solve_nlp(problem, combination, method; deadline = loop_deadline) - avoid_combination(master.model, combination, master.binary_map) - add_oa_cuts(model, master, result, method) + avoid_combination(master.model, combination, master.variable_map) + _add_oa_cuts(problem, master, result, method) if result.feasible && _is_better(sense_token, result.objective, best_objective) best_objective = result.objective @@ -92,16 +95,13 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) result.feasible && (previous_result = result) end - if best_result !== nothing - commit_combination(model, best_result.combination, - best_result.linearization_point) - end + best_result === nothing || _commit_combination(best_result) if isfinite(method.time_limit) # Overall cap: the final committed solve gets the budget left. - JuMP.set_time_limit_sec(model, max(0.0, overall_deadline - time())) + JuMP.set_time_limit_sec(nlp, max(0.0, overall_deadline - time())) elseif isfinite(method.iteration_time_limit) # Loop-only budget: restore so the final solve isn't crippled. - _restore_time_limit(model, original_time_limit) + _restore_time_limit(nlp, original_time_limit) end _report_loa_gap(best_objective, best_result, master_bound, sense_token, converged) @@ -165,49 +165,155 @@ function _set_covering_combinations(model::JuMP.AbstractModel) end ################################################################################ -# MASTER CONSTRUCTION +# FINITE PROBLEM CONSTRUCTION ################################################################################ -# True for linear constraint function types (variable refs / affine). -# Nonlinear functions enter the master as OA cuts, not at copy time. -_is_linear_F(::Type{<:JuMP.AbstractVariableRef}) = true -_is_linear_F(::Type{<:JuMP.GenericAffExpr}) = true -_is_linear_F(::Type{<:AbstractVector{<:JuMP.AbstractVariableRef}}) = true -_is_linear_F(::Type{<:AbstractVector{<:JuMP.GenericAffExpr}}) = true -_is_linear_F(::Type) = false +# The concrete variable behind an indicator's binary reference (the +# underlying variable of a `1 - y` complement expression), and the value +# it takes when the indicator is active. +_underlying_binary(binary_ref::JuMP.AbstractVariableRef) = binary_ref +_underlying_binary(binary_ref::JuMP.GenericAffExpr) = + only(keys(binary_ref.terms)) +_underlying_value(::JuMP.AbstractVariableRef, active::Bool) = active +_underlying_value(::JuMP.GenericAffExpr, active::Bool) = !active + +# Translate an indicator-level combination onto the underlying binary +# variables, inverting the value for complement-form indicators. +function _binary_combination(model::JuMP.AbstractModel, combination) + V = JuMP.variable_ref_type(typeof(model)) + binary_map = _indicator_to_binary(model) + result = Dict{V, Bool}() + for (indicator, active) in combination + binary_ref = binary_map[indicator] + result[_underlying_binary(binary_ref)] = + _underlying_value(binary_ref, active) + end + return result +end """ - build_loa_master(model, method::LOA, sink = nothing)::_LOAMaster - -Build the LOA master MILP: copy `model`'s variables and linear -constraints, install the `alpha_oa` objective auxiliary, and record the -original-to-master reference maps. Nonlinear objective and disjunct -constraints are not copied; they enter later as OA cuts per NLP solve. -`sink` is the `(variable, indicator) -> disaggregated variable` map from -an inner Hull reformulation (`nothing` for Big-M / MBM). + build_loa_problem( + model::JuMP.AbstractModel, + method::LOA, + [disaggregation_map = nothing] + )::_LOAProblem + +Build the problem the LOA loop operates on from `model` (already +reformulated by the LOA inner method): the NLP subproblem, the binary +variables backing the indicators, the set-covering seed combinations, +the nonlinear disjunct `(binary_ref, function, set)` triples, the +nonlinear global `(function, set)` pairs, and the Hull disaggregation +map. The NLP is `model` itself; the InfiniteOpt extension overloads +this to build the problem from the transcribed backend instead. ## Returns -- `_LOAMaster`: the master model with its reference maps and - disaggregator. +- `_LOAProblem`: the problem. """ -function build_loa_master( +function build_loa_problem( model::JuMP.AbstractModel, method::LOA, - sink = nothing + disaggregation_map = nothing ) - original_objective = JuMP.objective_function(model) - objective_sense = JuMP.objective_sense(model) - variable_type = JuMP.variable_ref_type(typeof(model)) + V = JuMP.variable_ref_type(typeof(model)) + binary_map = _indicator_to_binary(model) + + binaries = V[_underlying_binary(binary_ref) + for (_, binary_ref) in binary_map] + unique!(binaries) + combinations = [_binary_combination(model, combination) + for combination in _set_covering_combinations(model)] + + disjunct_constraints = Tuple{Any, Any, Any}[] + for (_, disjunction) in _disjunctions(model) + for indicator in disjunction.constraint.indicators + haskey(_indicator_to_constraints(model), indicator) || continue + binary_ref = binary_map[indicator] + for cref in _indicator_to_constraints(model)[indicator] + cref isa DisjunctConstraintRef || continue + constraint = _disjunct_constraints(model)[ + JuMP.index(cref)].constraint + constraint isa JuMP.ScalarConstraint || continue + _is_linear_F(typeof(constraint.func)) && continue + push!(disjunct_constraints, + (binary_ref, constraint.func, constraint.set)) + end + end + end + + global_constraints = Tuple{Any, Any}[] + reform_set = Set(_reformulation_constraints(model)) + for (F, S) in JuMP.list_of_constraint_types(model) + F === V && continue + _is_linear_F(F) && continue + for cref in JuMP.all_constraints(model, F, S) + cref in reform_set && continue + constraint = JuMP.constraint_object(cref) + constraint isa JuMP.ScalarConstraint || continue + push!(global_constraints, (constraint.func, constraint.set)) + end + end + + binary_disaggregations = disaggregation_map === nothing ? nothing : + Dict((variable, binary_map[indicator]) => disaggregated + for ((variable, indicator), disaggregated) in disaggregation_map) + return _LOAProblem(model, binaries, combinations, disjunct_constraints, + global_constraints, binary_disaggregations) +end + +# The inner reformulation method LOA runs, plus the disaggregation map +# to collect (Big-M / MBM need none). For Hull, return a map-carrying +# copy so the reformulation records its `(variable, indicator) -> +# disaggregated variable` map into a fresh LOA-owned `Dict`. +_loa_inner_method(::JuMP.AbstractModel, inner::Union{BigM, MBM}) = + (inner, nothing) +function _loa_inner_method(model::JuMP.AbstractModel, inner::Hull) + V = JuMP.variable_ref_type(typeof(model)) + disaggregations = Dict{Tuple{V, LogicalVariableRef{typeof(model)}}, V}() + return (Hull(inner.value; disaggregation_map = disaggregations), + disaggregations) +end + +# Relax the binaries to continuous in [0, 1]; each NLP solve then +# overwrites their fixes in place. +function _relax_binaries(problem::_LOAProblem) + for binary in problem.binaries + JuMP.unset_binary(binary) + JuMP.set_lower_bound(binary, 0.0) + JuMP.set_upper_bound(binary, 1.0) + end + return +end + +################################################################################ +# MASTER CONSTRUCTION +################################################################################ +# True for linear constraint function types (variable refs / affine). +# Nonlinear functions enter the master as OA cuts, not at copy time. +_is_linear_F(::Type{<:JuMP.AbstractVariableRef}) = true +_is_linear_F(::Type{<:JuMP.GenericAffExpr}) = true +_is_linear_F(::Type{<:AbstractVector{<:JuMP.AbstractVariableRef}}) = true +_is_linear_F(::Type{<:AbstractVector{<:JuMP.GenericAffExpr}}) = true +_is_linear_F(::Type) = false - master = _copy_model(model) +# Build the LOA master MILP: copy the NLP's variables and linear +# constraints, install the `alpha_oa` objective auxiliary, and record +# the NLP-to-master variable map. Nonlinear constraints are not +# copied; they enter later as OA cuts per NLP solve. +function _build_loa_master(problem::_LOAProblem, method::LOA) + nlp = problem.nlp + objective = JuMP.objective_function(nlp) + objective_sense = JuMP.objective_sense(nlp) + variable_type = JuMP.variable_ref_type(typeof(nlp)) + + master = _copy_model(nlp) JuMP.set_optimizer(master, method.mip_optimizer) JuMP.set_silent(master) - variable_map = copy_variables_onto_model(master, model) + variable_map = copy_variables_onto_model(master, nlp) - for (F, S) in JuMP.list_of_constraint_types(model) + for (F, S) in JuMP.list_of_constraint_types(nlp) F === variable_type && continue _is_linear_F(F) || continue - for constraint_ref in JuMP.all_constraints(model, F, S) + for constraint_ref in JuMP.all_constraints(nlp, F, S) constraint = JuMP.constraint_object(constraint_ref) new_func = replace_variables_in_constraint( constraint.func, variable_map) @@ -215,71 +321,34 @@ function build_loa_master( end end - binary_map = Dict{LogicalVariableRef, Any}() - for (indicator, binary_ref) in _indicator_to_binary(model) - binary_map[indicator] = _remap_indicator_to_binary( - binary_ref, variable_map) - end - alpha_oa = JuMP.@variable(master, base_name = "alpha_oa") JuMP.@objective(master, objective_sense, alpha_oa) - disaggregator = _build_disaggregator(model, method.inner_method, - variable_map, binary_map, sink) - return _LOAMaster(master, binary_map, variable_map, objective_sense, - original_objective, alpha_oa, variable_map, disaggregator) -end - -# The inner reformulation method LOA runs, plus the disaggregation sink to -# collect (Big-M / MBM need none). -#For Hull, return a sink-carrying copy so the reformulation records its `(variable, indicator) -# -> disaggregated variable` map into a fresh LOA-owned `Dict`. -_loa_inner_method(::JuMP.AbstractModel, inner::Union{BigM, MBM}) = - (inner, nothing) -function _loa_inner_method(model::JuMP.AbstractModel, inner::Hull) - V = JuMP.variable_ref_type(typeof(model)) - sink = Dict{Tuple{V, LogicalVariableRef{typeof(model)}}, V}() - return (Hull(inner.value; sink = sink), sink) + disaggregator = _build_disaggregator(problem, variable_map, + method.inner_method) + return _LOAMaster(master, variable_map, objective_sense, objective, + alpha_oa, disaggregator) end # Master-space Hull disaggregator for the convex-hull cut emitter -# (`nothing` for Big-M / MBM). Remaps the collected `(variable, indicator) -# -> disaggregated variable` sink into master refs. -_build_disaggregator(::JuMP.AbstractModel, ::Union{BigM, MBM}, - variable_map, binary_map, sink) = nothing +# (`nothing` for Big-M / MBM): remap the `(variable, binary_ref) -> +# disaggregated variable` map into master refs. +_build_disaggregator(::_LOAProblem, ::AbstractDict, ::Union{BigM, MBM}) = + nothing function _build_disaggregator( - model::JuMP.AbstractModel, - inner::Hull, + problem::_LOAProblem, variable_map::AbstractDict, - binary_map::AbstractDict, - sink + inner::Hull ) hull = _Hull(inner, Set{valtype(variable_map)}()) - for ((variable, indicator), disaggregated) in sink - record_disaggregation(hull, model, variable_map[variable], - binary_map[indicator], variable_map[disaggregated]) + for ((variable, binary_ref), disaggregated) in problem.disaggregation_map + hull.disjunct_variables[(variable_map[variable], + _remap_indicator_to_binary(binary_ref, variable_map))] = + variable_map[disaggregated] end return hull end -""" - record_disaggregation(hull::_Hull, model, variable, binary, - disaggregated) - -Record one master-space disaggregation in the Hull `hull` used to gate -Hull OA cuts, keyed by `(variable, binary)`. -""" -function record_disaggregation( - hull::_Hull, - ::JuMP.AbstractModel, - variable, - binary, - disaggregated - ) - hull.disjunct_variables[(variable, binary)] = disaggregated - return -end - ################################################################################ # NLP SUBPROBLEM ################################################################################ @@ -293,52 +362,68 @@ _restore_time_limit(model::JuMP.AbstractModel, ::Nothing) = _restore_time_limit(model::JuMP.AbstractModel, seconds::Real) = JuMP.set_time_limit_sec(model, seconds) -# Solve the primary NLP at a fixed combination. If infeasible, fall +# Fix each binary at its combination value, overwriting any previous +# fix in place. +function _fix_combination(combination::AbstractDict) + for (binary, value) in combination + JuMP.fix(binary, value ? 1.0 : 0.0; force = true) + end + return +end + +# Primal values keyed by the NLP's variables, skipping fixed ones +# (the combination binaries and any user-fixed variables). +function _extract_solution(nlp::JuMP.AbstractModel) + V = JuMP.variable_ref_type(typeof(nlp)) + T = JuMP.value_type(typeof(nlp)) + return Dict{V, T}(v => JuMP.value(v) + for v in JuMP.all_variables(nlp) if !JuMP.is_fixed(v)) +end + +# Solve the NLP at a fixed combination. If infeasible, fall # through to NLPF (a slacked version that always solves) so the master # still gets a linearization site, not just a no-good cut. function _solve_nlp( - model::M, + problem::_LOAProblem, combination, method::LOA; deadline::Float64 = Inf - ) where {M <: JuMP.AbstractModel} - _cap_remaining_time(model, deadline) - primary = with_fixed_combination(model, combination) do - JuMP.optimize!(model, ignore_optimize_hook = true) - if JuMP.is_solved_and_feasible(model) - return (combination = combination, - linearization_point = extract_solution(model), - objective = JuMP.objective_value(model), feasible = true) - end - return nothing + ) + nlp = problem.nlp + _cap_remaining_time(nlp, deadline) + _fix_combination(combination) + JuMP.optimize!(nlp, ignore_optimize_hook = true) + if JuMP.is_solved_and_feasible(nlp) + return (combination = combination, + linearization_point = _extract_solution(nlp), + objective = JuMP.objective_value(nlp), feasible = true) end - primary === nothing || return primary # Primary NLP infeasible — try NLPF. - nlpf = _solve_nlpf(model, combination, method; deadline = deadline) + nlpf = _solve_nlpf(problem, combination, method; deadline = deadline) nlpf === nothing || return nlpf + V = JuMP.variable_ref_type(typeof(nlp)) + T = JuMP.value_type(typeof(nlp)) return (combination = combination, - linearization_point = Dict{JuMP.AbstractVariableRef, Any}(), + linearization_point = Dict{V, T}(), objective = Inf, feasible = false) end ################################################################################ # NLPF (FEASIBILITY SUBPROBLEM) ################################################################################ -# When the primary NLP is infeasible, copy the model, slack every scalar -# inequality with one nonnegative `u`, minimize `u`, and return the -# primal as the linearization site (equalities/bounds exact, inequalities -# violated by at most `u`). +# When the primary NLP is infeasible, copy the NLP (fixes included), +# slack every scalar inequality with one nonnegative `u`, minimize `u`, +# and return the primal as the linearization site (equalities/bounds +# exact, inequalities violated by at most `u`). function _solve_nlpf( - model::M, + problem::_LOAProblem, combination, method::LOA; deadline::Float64 = Inf - ) where {M <: JuMP.AbstractModel} - # The original's binaries are permanently relaxed, so this copy is - # continuous — any NLP solver (e.g. Ipopt) can solve NLPF. - copy, ref_map = JuMP.copy_model(model) + ) + copy, ref_map = JuMP.copy_model(problem.nlp) JuMP.set_optimizer(copy, method.nlp_optimizer) JuMP.set_silent(copy) _cap_remaining_time(copy, deadline) @@ -359,44 +444,20 @@ function _solve_nlpf( JuMP.@objective(copy, Min, u) - # Fix the combination on the copy so NLPF solves at the chosen - # binaries, not by leaning on the copy inheriting the original's fix. - fix_combination_on_copy(copy, model, combination, ref_map) - JuMP.optimize!(copy, ignore_optimize_hook = true) # Use the primal only at a genuine feasible point; a solver can report # `has_values` with a nonfeasible/NaN primal that would poison the cut. JuMP.is_solved_and_feasible(copy) || return nothing - linearization_point = _nlpf_extract_primal(model, ref_map) + V = JuMP.variable_ref_type(typeof(problem.nlp)) + T = JuMP.value_type(typeof(problem.nlp)) + linearization_point = Dict{V, T}(v => JuMP.value(ref_map[v]) + for v in JuMP.all_variables(problem.nlp) if !JuMP.is_fixed(v)) return (combination = combination, linearization_point = linearization_point, objective = Inf, feasible = false) end -# Fix each indicator's binary on the NLPF copy at its combination value, -# mapped into copy space. Explicit so NLPF is self-contained rather than -# relying on the copy inheriting the original's fix. -function fix_combination_on_copy(copy, model, combination, ref_map) - for (indicator, value) in combination - binary = _binary_on_copy( - _indicator_to_binary(model)[indicator], ref_map) - _fix_binary_on_copy(copy, binary, value) - end - return -end - -# Map an original binary (or its `1 - y` complement) into NLPF-copy space. -_binary_on_copy(binary::JuMP.AbstractVariableRef, ref_map) = ref_map[binary] -_binary_on_copy(binary::JuMP.GenericAffExpr, ref_map) = - 1.0 - ref_map[only(keys(binary.terms))] - -# OVERRIDABLE. Fix one binary on the copy at a scalar value via an -# equality (the copy is discarded, so the fix needs no teardown). The -# InfiniteOpt extension adds a per-support method for a `Vector{Bool}`. -_fix_binary_on_copy(copy, binary, value::Bool) = - JuMP.@constraint(copy, binary == (value ? 1.0 : 0.0)) - _nlpf_should_slack(::Type{<:_MOI.LessThan}) = true _nlpf_should_slack(::Type{<:_MOI.GreaterThan}) = true _nlpf_should_slack(::Type) = false @@ -404,150 +465,81 @@ _nlpf_should_slack(::Type) = false _nlpf_slacked_func(func, u, ::_MOI.LessThan) = func - u _nlpf_slacked_func(func, u, ::_MOI.GreaterThan) = func + u -# Read primal values from `copy` keyed by the original model's -# variables, in the same vector shape `extract_solution` would have -# produced on the original. -function _nlpf_extract_primal(model::JuMP.AbstractModel, ref_map) - V = JuMP.variable_ref_type(typeof(model)) - T = JuMP.value_type(typeof(model)) - result = Dict{V, Vector{T}}() - for v in collect_all_vars(model) - JuMP.is_fixed(v) && continue - target = ref_map[v] - val = JuMP.value(target) - result[v] = val isa AbstractArray ? vec(val) : [val] - end - return result -end - -# Fix `combination`, run `f()`, then undo. The finite undo is a no-op -# (fixes overwrite in place); the infinite `fix_combination` clears its -# per-support fixed values. - -# TODO: Refactor to avoid this. -function with_fixed_combination( - f, - model::JuMP.AbstractModel, - combination::AbstractDict - ) - undo = fix_combination(model, combination) - try - return f() - finally - undo() - end -end - # Iter-to-iter NLP warm start: seed the next NLP from the last FEASIBLE # primal (no-op on the first seed and after an NLPF fall-through, whose -# primal is slack-distorted). Routes through `set_linearization_start`. +# primal is slack-distorted). function _set_nlp_warm_start(previous) previous === nothing && return - for (variable, values) in previous.linearization_point - set_linearization_start(variable, values) + for (variable, value) in previous.linearization_point + JuMP.set_start_value(variable, value) end return end -# Finalize the model with the LOA-optimal combination: fix indicators -# at the committed values (in place; the model is already permanently -# relaxed by reformulate_model) and warm-start from the linearization -# point. After this, the model is no longer in a state suitable for -# re-running with a different `gdp_method`. -function commit_combination( - model::JuMP.AbstractModel, - combination::AbstractDict, - linearization_point::AbstractDict - ) - fix_combination(model, combination) - for (variable, values) in linearization_point - set_linearization_start(variable, values) +# Finalize the NLP with the LOA-optimal combination: fix the +# binaries at the committed values (in place; the NLP is already +# permanently relaxed) and warm-start from the linearization point. +# After this, the model is no longer in a state suitable for re-running +# with a different `gdp_method`. +function _commit_combination(result) + _fix_combination(result.combination) + for (variable, value) in result.linearization_point + JuMP.set_start_value(variable, value) end return end -""" - fix_combination(model, combination::AbstractDict)::Function - -Fix each indicator in `combination` to its value on `model`, in place, -and return a zero-argument closure that undoes the fixes. - -## Returns -- `Function`: an undo closure called by `with_fixed_combination`. -""" -function fix_combination(model::JuMP.AbstractModel, combination::AbstractDict) - for (indicator, value) in combination - fix_indicator(model, indicator, value) - end - return () -> nothing -end - -""" - set_linearization_start(variable, values::AbstractVector) - -Warm-start `variable` from the linearization point, unwrapping the single -element of `values` as its start value. -""" -set_linearization_start(variable, values::AbstractVector) = - JuMP.set_start_value(variable, only(values)) - ################################################################################ # COMBO EXTRACTION (master -> NLP) ################################################################################ -# Read each indicator's binary value from the master MILP solution. -# `combination_val` returns a `Bool` (finite indicator) or per-support -# `Vector{Bool}` (infinite); downstream dispatches on that. -function _extract_combination( - model::M, - master::_LOAMaster - ) where {M <: JuMP.AbstractModel} - combination = Dict{LogicalVariableRef{M}, Any}() - for (_, disjunction_data) in _disjunctions(model) - for indicator in disjunction_data.constraint.indicators - combination[indicator] = - combination_val(master.binary_map[indicator]) - end - end - return combination -end - -# Read the master binary and round to `Bool` (a MILP solver returns -# values within its integer tolerance, never exactly 0/1). Branches on -# the value: scalar (finite) or per-support array (infinite). -function combination_val(binary_ref) - val = JuMP.value(binary_ref) - return val isa AbstractArray ? vec(round.(Bool, val)) : round(Bool, val) +# Read each binary's master value, rounded to `Bool` (a MILP solver +# returns values within its integer tolerance, never exactly 0/1). +function _extract_combination(problem::_LOAProblem, master::_LOAMaster) + return Dict(binary => round(Bool, JuMP.value(master.variable_map[binary])) + for binary in problem.binaries) end ################################################################################ # OA CUT EMISSION ################################################################################ -function add_oa_cuts( - model::JuMP.AbstractModel, +# Whether the disjunct behind `binary_ref` is active in `combination` +# (a complement reference inverts its underlying binary's value). +_disjunct_active(combination::AbstractDict, + binary_ref::JuMP.AbstractVariableRef) = combination[binary_ref] +_disjunct_active(combination::AbstractDict, binary_ref::JuMP.GenericAffExpr) = + !combination[_underlying_binary(binary_ref)] + +# Emit all OA cuts for one NLP result: the objective cut, a slacked row +# per nonlinear global, and a gated cut per active nonlinear disjunct +# constraint (Big-M / MBM or Hull gating per the inner method). +function _add_oa_cuts( + problem::_LOAProblem, master::_LOAMaster, result::NamedTuple, method::LOA ) isempty(result.linearization_point) && return - linearization = _linearize_at(master.original_objective, - objective_linearization_point(model, result.linearization_point), - master.objective_ref_map) - _add_objective_cut( - Val(master.objective_sense), master, linearization, method) - add_global_oa_cuts(model, master, result, method) - add_disjunct_oa_cuts(model, master, result, method) + sense_token = Val(master.objective_sense) + penalty_sign = _penalty_sign(sense_token) + linearization = _linearize_at(master.objective, + result.linearization_point, master.variable_map) + _add_objective_cut(sense_token, master, linearization, method) + for (func, set) in problem.global_constraints + linearization = _linearize_at(func, result.linearization_point, + master.variable_map) + _add_global_oa_row(master, linearization, set, method, penalty_sign) + end + for (binary_ref, func, set) in problem.disjunct_constraints + _disjunct_active(result.combination, binary_ref) || continue + linearization = _linearize_at(func, result.linearization_point, + master.variable_map) + _emit_disjunct_oa_cut(method.inner_method, set, master, + _remap_indicator_to_binary(binary_ref, master.variable_map), + linearization, method, penalty_sign) + end return end -""" - objective_linearization_point(model, linearization_point) - -Return the point at which the master objective is linearized. Returns -`linearization_point` unchanged. -""" -objective_linearization_point(::JuMP.AbstractModel, linearization_point) = - linearization_point - # Slacked objective cut. MIN: `lin <= alpha_oa + sigma`; MAX symmetric. # The slack (like the disjunct/global cuts) keeps a nonconvex objective # linearization from making the master infeasible. @@ -566,83 +558,6 @@ _add_objective_cut_body(::Val{_MOI.MIN_SENSE}, master, lin, slack) = _add_objective_cut_body(::Val{_MOI.MAX_SENSE}, master, lin, slack) = JuMP.@constraint(master.model, lin >= master.alpha_oa - slack) -""" - add_global_oa_cuts(model, master::_LOAMaster, result::NamedTuple, - method::LOA) - -Add a slacked OA cut to `master` for every nonlinear global (non-disjunct) -constraint of `model`, linearized at `result.linearization_point`. -Variable bounds, linear functions, and reformulation constraints are -skipped. -""" -function add_global_oa_cuts( - model::JuMP.AbstractModel, - master::_LOAMaster, - result::NamedTuple, - method::LOA - ) - penalty_sign = _penalty_sign(Val(master.objective_sense)) - variable_type = JuMP.variable_ref_type(typeof(model)) - reform_set = is_gdp_model(model) ? - Set(_reformulation_constraints(model)) : Set() - for (F, S) in JuMP.list_of_constraint_types(model) - F === variable_type && continue - _is_linear_F(F) && continue - for cref in JuMP.all_constraints(model, F, S) - cref in reform_set && continue - con = JuMP.constraint_object(cref) - con isa JuMP.ScalarConstraint || continue - linearization = _linearize_at(con.func, - result.linearization_point, master.variable_map) - _add_global_oa_row(master, linearization, con.set, - method, penalty_sign) - end - end - return -end - -# Iterate each active disjunct's disjunct constraints, invoking -# `f(binary_ref, active, constraint)` per constraint. Shared by the base -# and InfiniteOpt `add_disjunct_oa_cuts` drivers so neither reimplements -# the active-disjunct walk. -function _each_active_disjunct_constraint(f, model, master, result) - for (indicator, active) in result.combination - is_active(active) || continue - haskey(_indicator_to_constraints(model), indicator) || continue - for cref in _indicator_to_constraints(model)[indicator] - cref isa DisjunctConstraintRef || continue - constraint = _disjunct_constraints(model)[ - JuMP.index(cref)].constraint - f(master.binary_map[indicator], active, constraint) - end - end - return -end - -""" - add_disjunct_oa_cuts(model, master::_LOAMaster, result::NamedTuple, - method::LOA) - -Add a slacked OA cut to `master` for each active disjunct's nonlinear -constraints, linearized at `result.linearization_point` and gated to -match the inner reformulation. Emits one cut per constraint. -""" -function add_disjunct_oa_cuts( - model::JuMP.AbstractModel, - master::_LOAMaster, - result::NamedTuple, - method::LOA - ) - penalty_sign = _penalty_sign(Val(master.objective_sense)) - _each_active_disjunct_constraint(model, master, - result) do binary_ref, _active, constraint - _add_oa_cut_for_constraint(constraint, master, binary_ref, - result.linearization_point, master.variable_map, method, - penalty_sign) - end - return -end - # Fresh penalized slack added to the master objective. Shared by the # disjunct, global, and objective cuts so a nonconvex (invalid) # linearization can't make the master infeasible. @@ -655,27 +570,6 @@ function _penalized_slack(master::_LOAMaster, method::LOA, penalty_sign::Int) return slack end -# Linearize the constraint at `linearization_point`, then emit slacked OA -# cut(s) gated to match the inner reformulation (Big-M / MBM or Hull). -# Linear constraints are exact via the reformulation and skipped. -function _add_oa_cut_for_constraint( - constraint::JuMP.AbstractConstraint, - master::_LOAMaster, - binary_ref, - linearization_point::AbstractDict, - var_map::AbstractDict, - method::LOA, - penalty_sign::Int - ) - _is_linear_F(typeof(constraint.func)) && return - linearization = _linearize_at( - constraint.func, linearization_point, var_map) - _emit_disjunct_oa_cut(method.inner_method, - constraint.set, master, binary_ref, linearization, method, - penalty_sign) - return -end - # Extract RHS from an MOI set. _set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = _MOI.constant(s) @@ -752,9 +646,3 @@ function _add_global_oa_row(master::_LOAMaster, lin, set, ::LOA, ::Int) JuMP.@constraint(master.model, lin in set) return end - -# Is this indicator active anywhere? Finite: a `Bool`; infinite: a -# per-support `Vector{Bool}`. Skips disjuncts that are off everywhere. -is_active(active::Bool) = active -is_active(active::AbstractVector{Bool}) = any(active) - diff --git a/src/utilities.jl b/src/utilities.jl index 7e61fdec..6f402a44 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -152,11 +152,12 @@ solution. The added constraint Σ_{j active} (1 - y_j) + Σ_{j inactive} y_j ≥ 1 forces at least one indicator to differ from `combination`. -`combination` maps `LogicalVariableRef` → `Bool` (whether each -indicator was active). The 3-arg form lets you supply an explicit -`binary_map` (defaulting to `_indicator_to_binary(model)`) when the -binaries used in the cut belong to a copy of the original model -(e.g. an LOA master). +`combination` maps `LogicalVariableRef` (or a binary variable) → +`Bool` (whether it was active). The 3-arg form lets you supply an +explicit `binary_map` (defaulting to `_indicator_to_binary(model)`) +translating each combination key to the binary used in the cut, e.g. +when the binaries belong to a copy of the original model (an LOA +master). Returns the constraint reference of the added cut. """ diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 446ac756..e8855671 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -58,16 +58,17 @@ function test_no_good_cut() @disjunction(model, Y) DP.reformulate_model(model, BigM(1e9)) - master = DP.build_loa_master( - model, LOA(HiGHS.Optimizer)) + method = LOA(HiGHS.Optimizer) + problem = DP.build_loa_problem(model, method) + master = DP._build_loa_master(problem, method) master_model = master.model - combo = Dict(Y[1] => true, Y[2] => false) + combo = DP._binary_combination(model, Dict(Y[1] => true, Y[2] => false)) num_cons_before = length(JuMP.all_constraints( master_model; include_variable_in_set_constraints = false)) - DP.avoid_combination(master.model, combo, master.binary_map) + DP.avoid_combination(master.model, combo, master.variable_map) num_cons_after = length(JuMP.all_constraints( master_model; include_variable_in_set_constraints = false)) @@ -158,7 +159,7 @@ end function test_loa_nonlinear_global() # max x s.t. x^2 <= 25 (global), (x <= 3) ∨ (x <= 8), 0 <= x <= 10. # Disjunct Y[2] permits x up to 8 but the global x^2 <= 25 bounds - # x to 5. Verifies that `add_global_oa_cuts` emits the + # x to 5. Verifies that the global-cut pass emits the # linearization of the global into the master without breaking # the loop. Result must hit the global-binding optimum. ipopt = optimizer_with_attributes(Ipopt.Optimizer, @@ -181,9 +182,9 @@ function test_loa_nonlinear_global() end function test_loa_complement_indicator_nonlinear_disjunct() - # Regression: complement-form indicators store `1 - y_base` (an - # AffExpr) in `binary_map`. When the complement disjunct has a - # nonlinear constraint, `add_disjunct_oa_cuts` feeds that AffExpr + # Regression: complement-form indicators carry `1 - y_base` (an + # AffExpr) as their binary reference. When the complement disjunct has a + # nonlinear constraint, the disjunct-cut pass feeds that AffExpr # straight into the OA cut gating term `M(1 - binary)`, which must # accept an AffExpr binary (not just a plain variable ref). # @@ -458,11 +459,11 @@ function test_loa_hull_complement_nonlinear() @test objective_value(model) ≈ 8.0 atol = 1e-3 end -function test_loa_hull_nested_sink() +function test_loa_hull_nested_disaggregation_map() # The inner disjunction (over y) is a disjunct of the outer (gated by - # z[1]). Hull's nested redispatch must propagate the LOA sink so the - # inner disaggregations are recorded, else LOA-Hull emits the nested - # cut on the aggregated variable. + # z[1]). Hull's nested redispatch must propagate the LOA + # disaggregation map so the inner disaggregations are recorded, else + # LOA-Hull emits the nested cut on the aggregated variable. model = GDPModel() @variable(model, 0 <= x <= 10) @variable(model, y[1:2], Logical) @@ -472,10 +473,10 @@ function test_loa_hull_nested_sink() @disjunction(model, y, Disjunct(z[1])) @constraint(model, x <= 1, Disjunct(z[2])) @disjunction(model, z) - sink = Dict{Any, Any}() - DP.reformulate_model(model, Hull(; sink = sink)) - @test haskey(sink, (x, y[1])) - @test haskey(sink, (x, y[2])) + disaggregations = Dict{Any, Any}() + DP.reformulate_model(model, Hull(; disaggregation_map = disaggregations)) + @test haskey(disaggregations, (x, y[1])) + @test haskey(disaggregations, (x, y[2])) end function test_reformulate_after_loa_restores_binaries() @@ -527,6 +528,6 @@ end test_loa_hull_nonlinear_global() test_loa_hull_nonlinear_disjunct() test_loa_hull_complement_nonlinear() - test_loa_hull_nested_sink() + test_loa_hull_nested_disaggregation_map() test_reformulate_after_loa_restores_binaries() end diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index c34b27e6..d6551498 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -866,8 +866,8 @@ function test_loa_infinite_nonlinear_global() # (x <= 3) ∨ (x <= 8), 0 <= x <= 10 over t ∈ [0, 1]. # Disjunct Y[2] permits x up to 8 but the global x^2 <= 25 caps # x at 5. The per-support global transcribes to an `AbstractArray` - # of scalar constraints, so this exercises the array branch of - # `add_global_oa_cuts`. Without the global cut the + # of scalar constraints, so this exercises the per-support fan-out + # of the transcribed global constraints. Without the global cut the # master would allow x = 8 and report 8.0; the binding optimum # is ∫5 dt = 5. ipopt = optimizer_with_attributes(Ipopt.Optimizer, @@ -891,15 +891,9 @@ end function test_loa_infinite_complement_indicator() # Regression: `_indicator_to_binary(model)[Y2]` returns the AffExpr - # `1 - binary(Y1)` for a logical-complement indicator. Earlier the - # extension's `fix_combination` called `JuMP.fix` directly on that - # AffExpr and crashed; now it delegates to base `fix_indicator` - # which unwraps and inverts. - # - # Linear disjuncts only — a nonlinear disjunct constraint on an - # infinite variable would also hit a separate gap where - # `_infinite_cut_info` for a finite indicator does not slice the - # per-support linearization point. Out of scope for this regression. + # `1 - binary(Y1)` for a logical-complement indicator. Fixing and + # gating must resolve it onto the underlying binary's transcription + # with the value inverted. # # max ∫ x dt with 0 ≤ x ≤ 10 over t ∈ [0,1]: # Y1: x ≤ 3 Y2 ≡ ¬Y1: x ≤ 8 @@ -923,14 +917,11 @@ function test_loa_infinite_complement_indicator() end function test_loa_infinite_complement_nonlinear_disjunct() - # Regression: a finite (or complement-form AffExpr) indicator - # gating a NONLINEAR constraint on an infinite variable. Earlier - # `_infinite_cut_info` decided fan-out from the indicator only — finite - # indicator → single un-sliced site → `_linearize_at` received - # per-support `Vector{Float64}` values and tripped on - # `_unwrap_scalar`'s `only(v)`. Fixed by inspecting the constraint - # expression too: any infinite var found there governs the fan-out - # supports, even when the indicator is scalar. + # Regression: a finite (complement-form AffExpr) indicator gating a + # NONLINEAR constraint on an infinite variable. The constraint + # transcribes to one scalar row per support while the binary + # reference is a single expression, so the fan-out must broadcast + # it across the constraint's supports. # # max ∫ x dt with 0 ≤ x ≤ 10 over t ∈ [0,1]: # Y1: x ≤ 3 Y2 ≡ ¬Y1: x² ≤ 64 @@ -956,12 +947,9 @@ function test_loa_infinite_complement_nonlinear_disjunct() end function test_loa_infinite_multidim_parameter() - # Regression: multi-D dependent parameter group. `supports(ξ)` is - # a 2 × N matrix, not a vector. Earlier the extension called - # `vec(supports(ξ))`, flattened to 2·N scalars, then called - # `binary_ref(scalar)` on a 2-D infinite var — dim mismatch. - # Fixed by returning `eachcol(supports(ξ))` and splatting vector - # supports through `_at_support`. + # Regression: multi-D dependent parameter group. Transcription + # expands a variable over ξ[1:2] across its joint supports; the + # transcribed binaries and constraint rows must stay aligned. # # max w with 0 ≤ x ≤ 10 over ξ[1:2] ∈ [0,1]², w ≤ x at every joint # support: @@ -991,9 +979,9 @@ function test_loa_infinite_nlpf_infeasible_disjunct() # NLP-infeasible against the bound x in [0, 10]. With infinite # indicators `Y[1:2], InfiniteLogical(t)`, the infeasible # Y[1]-everywhere seed makes the primary NLP fail, so NLPF runs: it - # copies the model, re-pins the binaries on the copy via - # `nlpf_fix_on_copy`, slacks the inequality, and returns a - # linearization point. The disjunct is deliberately LINEAR: + # copies the transcribed NLP (per-support binary fixes included), + # slacks the inequality, and returns a linearization point. The + # disjunct is deliberately LINEAR: # the model is then convex, so LOA's master bound is rigorous and # it converges to Y[2] active everywhere (x = 5), giving ∫x dt = 5. # (A nonconvex infeasible disjunct such as x^2 >= 200 would make LOA @@ -1023,8 +1011,8 @@ function test_loa_infinite_aggregate_global() # min ∫x dt s.t. ∫(x^2, t) <= 4 (aggregate global), x >= y, # (y >= 1) ∨ (y >= 3), 0 <= x, y <= 10 over t ∈ [0, 1]. # The aggregate global transcribes to a single scalar (the - # measure is flattened), exercising the non-array branch of - # `add_global_oa_cuts`. Y[1] (y >= 1) is the cheaper + # measure is expanded), exercising the single-row branch of + # the transcribed global constraints. Y[1] (y >= 1) is the cheaper # disjunct: x = 1 satisfies x >= y and ∫x^2 = 1 <= 4, giving # objective ∫1 dt = 1. ipopt = optimizer_with_attributes(Ipopt.Optimizer, @@ -1049,13 +1037,11 @@ function test_loa_infinite_aggregate_global() end function test_loa_infinite_iteration_loop() - # Force the InfiniteModel LOA main loop to fix per-support - # Vector{Bool} combinations, exercising the in-place fix-constraint - # stash: create on the first main-loop fix, update via - # set_normalized_rhs on the committed re-fix. Two disjunctions over - # disjoint x/z half-lines put the optimum on an off-diagonal - # combination the scalar set-covering seeds never try, so the master - # stays feasible and the loop body runs with per-support values. + # Force the InfiniteModel LOA main loop to fix combinations that + # vary per support. Two disjunctions over disjoint x/z half-lines + # put the optimum on an off-diagonal combination the set-covering + # seeds never try, so the master stays feasible and the loop body + # runs with master-extracted per-support values. # min ∫(x - z) dt # D1: (x <= 4) [Y1] ∨ (x >= 6) [Y2] # D2: (z <= 4) [W1] ∨ (z >= 6) [W2] @@ -1104,7 +1090,7 @@ function test_loa_infinite_hull_nonlinear_disjunct() # The infinite Hull case big-M differs from: a NONLINEAR constraint # on an infinite variable inside a disjunct, under a plain infinite # indicator. The convex-hull OA cut is built per support over the - # disaggregated infinite variable (point-evaluated). max ∫x dt, + # transcribed disaggregated infinite variable. max ∫x dt, # Y1: x <= 3, Y2: x^2 <= 64. Optimum: x = 8 via Y2. ipopt = optimizer_with_attributes(Ipopt.Optimizer, "print_level" => 0, "sb" => "yes") @@ -1128,7 +1114,7 @@ end function test_loa_infinite_hull_complement_nonlinear() # Hull with a complement indicator Y2 ≡ ¬Y1 gating a nonlinear # constraint on an infinite variable. The per-support disaggregator - # is keyed by the point-evaluated complement binary `1 - y1(t_k)` + # is keyed by the transcribed complement binary `1 - y1_k` # (an AffExpr). max ∫x dt, Y1: x <= 3, Y2: x^2 <= 64. Optimum 8. ipopt = optimizer_with_attributes(Ipopt.Optimizer, "print_level" => 0, "sb" => "yes") @@ -1154,11 +1140,12 @@ function test_loa_infinite_hull_finite_var() # A FINITE variable disaggregated inside a nonlinear disjunct # constraint gated by an INFINITE indicator. The fan-out slices the # binary per support, so the disaggregator must key the finite - # variable's copy by the per-support binary `y(t_k)` (driven off the + # variable's single copy by each per-support binary (driven off the # binary, not the variable). max ∫x dt + w over t ∈ [0,1]: - # Y1: x^2 + w^2 <= 25 Y2: x <= 0 - # Y1 is active; x(t) = w = sqrt(12.5), so the optimum is - # ∫sqrt(12.5) dt + sqrt(12.5) = 2 sqrt(12.5) ≈ 7.0711. + # Y1: x^2 + w^2 <= 25 Y2: x <= 0, w <= 3 + # Y1 is active (Y2 caps the objective at 3); x(t) = w = sqrt(12.5), + # so the optimum is ∫sqrt(12.5) dt + sqrt(12.5) = 2 sqrt(12.5) + # ≈ 7.0711. ipopt = optimizer_with_attributes(Ipopt.Optimizer, "print_level" => 0, "sb" => "yes") juniper = optimizer_with_attributes(Juniper.Optimizer, @@ -1171,6 +1158,7 @@ function test_loa_infinite_hull_finite_var() @variable(model, Y[1:2], InfiniteLogical(t)) @constraint(model, x^2 + w^2 <= 25, Disjunct(Y[1])) @constraint(model, x <= 0, Disjunct(Y[2])) + @constraint(model, w <= 3, Disjunct(Y[2])) @disjunction(model, Y) @objective(model, Max, ∫(x, t) + w) optimize!(model, gdp_method = LOA(juniper; @@ -1243,29 +1231,21 @@ end test_CuttingPlanes_multiparameter() end - # LOA on InfiniteOpt needs `JuMP.copy_model(::InfiniteModel)` - # (build_loa_master). That lives in the InfiniteOpt fork and is - # not in the registered release, so guard the suite: it runs - # (and validates the global-cut paths) when the fork is dev'd - # into the env, and skips cleanly otherwise. + # LOA runs on the transcribed backend, a plain JuMP model, so it + # needs no `JuMP.copy_model(::InfiniteModel)` from the InfiniteOpt + # fork (unlike MBM). @testset "LOA" begin - if hasmethod(JuMP.copy_model, Tuple{InfiniteModel}) - test_loa_infinite_nonlinear_global() - test_loa_infinite_aggregate_global() - test_loa_infinite_complement_indicator() - test_loa_infinite_complement_nonlinear_disjunct() - test_loa_infinite_multidim_parameter() - test_loa_infinite_nlpf_infeasible_disjunct() - test_loa_infinite_iteration_loop() - test_loa_infinite_hull_linear() - test_loa_infinite_hull_nonlinear_disjunct() - test_loa_infinite_hull_complement_nonlinear() - test_loa_infinite_hull_finite_var() - else - @info "Skipping InfiniteOpt LOA tests: " * - "JuMP.copy_model(::InfiniteModel) unavailable " * - "(registered InfiniteOpt; dev the fork to run)." - end + test_loa_infinite_nonlinear_global() + test_loa_infinite_aggregate_global() + test_loa_infinite_complement_indicator() + test_loa_infinite_complement_nonlinear_disjunct() + test_loa_infinite_multidim_parameter() + test_loa_infinite_nlpf_infeasible_disjunct() + test_loa_infinite_iteration_loop() + test_loa_infinite_hull_linear() + test_loa_infinite_hull_nonlinear_disjunct() + test_loa_infinite_hull_complement_nonlinear() + test_loa_infinite_hull_finite_var() end end From 70cda3d781ff15a7284964651d71e179bae2976e Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Thu, 2 Jul 2026 15:10:33 -0400 Subject: [PATCH 63/72] Add LOA coverage tests --- test/constraints/loa.jl | 162 +++++++++++++++++- .../InfiniteDisjunctiveProgramming.jl | 45 ++++- 2 files changed, 193 insertions(+), 14 deletions(-) diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index e8855671..e9d1dd1e 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -39,14 +39,24 @@ function test_set_covering_combos() combos = DP._set_covering_combinations(model) - # Should cover both Y[1] and Y[2] - all_active = Set() - for combo in combos - for (ind, active) in combo - active && push!(all_active, ind) - end - end - @test length(all_active) == 2 + # K = 2 combinations, each activating exactly one indicator, and + # together covering both. + @test length(combos) == 2 + @test all(count(values(combo)) == 1 for combo in combos) + @test combos[1][Y[1]] && !combos[1][Y[2]] + @test combos[2][Y[2]] && !combos[2][Y[1]] +end + +function test_oa_cut_terms() + # The `<= 0` cut directions per set, at a scalar linearization value + # of 5: LessThan/GreaterThan give one signed direction, EqualTo and + # Interval give both, unknown sets fall back to RHS 0. + @test DP._oa_cut_terms(MOI.LessThan(2.0), 5.0) == (3.0,) + @test DP._oa_cut_terms(MOI.GreaterThan(2.0), 5.0) == (-3.0,) + @test DP._oa_cut_terms(MOI.EqualTo(2.0), 5.0) == (3.0, -3.0) + @test DP._oa_cut_terms(MOI.Interval(1.0, 4.0), 5.0) == (1.0, -4.0) + @test DP._set_rhs(MOI.ZeroOne()) == 0.0 + @test DP._oa_cut_terms(MOI.ZeroOne(), 5.0) == (5.0,) end function test_no_good_cut() @@ -68,16 +78,25 @@ function test_no_good_cut() num_cons_before = length(JuMP.all_constraints( master_model; include_variable_in_set_constraints = false)) - DP.avoid_combination(master.model, combo, master.variable_map) + cref = DP.avoid_combination(master.model, combo, master.variable_map) num_cons_after = length(JuMP.all_constraints( master_model; include_variable_in_set_constraints = false)) @test num_cons_after == num_cons_before + 1 + # The cut is (1 - y1) + y2 >= 1, i.e. normalized -y1 + y2 >= 0: + # excludes exactly the (Y1 active, Y2 inactive) combination. + binary_map = DP._indicator_to_binary(model) + y1 = master.variable_map[binary_map[Y[1]]] + y2 = master.variable_map[binary_map[Y[2]]] + @test JuMP.normalized_coefficient(cref, y1) == -1.0 + @test JuMP.normalized_coefficient(cref, y2) == 1.0 + @test JuMP.normalized_rhs(cref) == 0.0 end function test_loa_reformulate_simple() model = GDPModel(HiGHS.Optimizer) + set_silent(model) @variable(model, 0 <= x <= 10) @variable(model, Y[1:2], Logical) @constraint(model, x <= 3, Disjunct(Y[1])) @@ -89,6 +108,10 @@ function test_loa_reformulate_simple() DP.reformulate_model(model, method) @test DP._ready_to_optimize(model) + # The committed model solves to the LOA incumbent: x = 7 via Y[2]. + JuMP.optimize!(model, ignore_optimize_hook = true) + @test objective_value(model) ≈ 7.0 atol = 1e-6 + @test value(x) ≈ 7.0 atol = 1e-6 end function test_loa_solve_simple() @@ -181,6 +204,121 @@ function test_loa_nonlinear_global() @test objective_value(model) ≈ 5.0 atol = 1e-3 end +function test_loa_nonlinear_equality_global() + # max x s.t. x^2 == 25 (global nonlinear equality), (x <= 3) ∨ + # (x <= 8), 0 <= x <= 10. The Y[1] seed is NLP-infeasible (x <= 3 + # contradicts x = 5), so NLPF slacks the inequality while keeping + # the equality exact. The equality emits BOTH cut directions into + # the master. Optimum: x = 5 via Y[2]. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + model = GDPModel(ipopt) + set_silent(model) + @variable(model, 0 <= x <= 10) + @constraint(model, x^2 == 25) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 8, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + optimize!(model, + gdp_method = LOA(ipopt; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 5.0 atol = 1e-3 + @test value(x) ≈ 5.0 atol = 1e-3 + @test value(Y[2]) ≈ 1.0 atol = 1e-6 +end + +function test_loa_nonlinear_equality_disjunct() + # Nonlinear equality inside a disjunct: Y1: x <= 3, Y2: x^2 == 64. + # The disjunct cut emits both gated directions. Optimum: x = 8. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = GDPModel(juniper) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x^2 == 64, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + optimize!(model, + gdp_method = LOA(juniper; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-3 + @test value(Y[2]) ≈ 1.0 atol = 1e-6 +end + +function test_loa_nonlinear_interval_disjunct() + # Nonlinear Interval constraint inside a disjunct: Y1: x <= 3, + # Y2: 36 <= x^2 <= 64. The disjunct cut emits both gated + # directions. Optimum: x = 8 via Y2. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = GDPModel(juniper) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, 36 <= x^2 <= 64, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + optimize!(model, + gdp_method = LOA(juniper; mip_optimizer = HiGHS.Optimizer)) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 8.0 atol = 1e-3 + @test value(Y[2]) ≈ 1.0 atol = 1e-6 +end + +function test_loa_restores_prior_time_limit() + # A time limit set by the user before LOA must survive the loop's + # per-solve caps when only `iteration_time_limit` is finite (the + # `_restore_time_limit(::Real)` path). + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + set_time_limit_sec(model, 90.0) + optimize!(model, gdp_method = LOA(HiGHS.Optimizer; + iteration_time_limit = 60.0, time_limit = Inf)) + @test time_limit_sec(model) == 90.0 + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 7.0 atol = 1e-4 +end + +function test_loa_limit_hit_report() + # Stop the main loop on `max_iter = 1` after the master has produced + # a bound: the report must label the run "limit hit" (not converged), + # and the single loop iteration still finds the off-diagonal optimum. + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, 0 <= z <= 10) + @variable(model, Y[1:2], Logical) + @variable(model, W[1:2], Logical) + @constraint(model, x <= 4, Disjunct(Y[1])) + @constraint(model, x >= 6, Disjunct(Y[2])) + @disjunction(model, Y) + @constraint(model, z <= 4, Disjunct(W[1])) + @constraint(model, z >= 6, Disjunct(W[2])) + @disjunction(model, W) + @objective(model, Min, x - z) + method = LOA(HiGHS.Optimizer; max_iter = 1) + @test_logs (:info, r"limit hit") match_mode = :any begin + DP.reformulate_model(model, method) + end + JuMP.optimize!(model, ignore_optimize_hook = true) + @test objective_value(model) ≈ -10.0 atol = 1e-4 +end + function test_loa_complement_indicator_nonlinear_disjunct() # Regression: complement-form indicators carry `1 - y_base` (an # AffExpr) as their binary reference. When the complement disjunct has a @@ -510,6 +648,7 @@ end @testset "LOA" begin test_loa_datatype() test_set_covering_combos() + test_oa_cut_terms() test_no_good_cut() test_loa_reformulate_simple() test_loa_solve_simple() @@ -517,6 +656,11 @@ end test_loa_solve_two_disjunctions() test_loa_error_fallback() test_loa_nonlinear_global() + test_loa_nonlinear_equality_global() + test_loa_nonlinear_equality_disjunct() + test_loa_nonlinear_interval_disjunct() + test_loa_restores_prior_time_limit() + test_loa_limit_hit_report() test_loa_complement_indicator_nonlinear_disjunct() test_loa_nlpf_infeasible_disjunct() test_loa_sense_primitives() diff --git a/test/extensions/InfiniteDisjunctiveProgramming.jl b/test/extensions/InfiniteDisjunctiveProgramming.jl index d6551498..5fe4186a 100644 --- a/test/extensions/InfiniteDisjunctiveProgramming.jl +++ b/test/extensions/InfiniteDisjunctiveProgramming.jl @@ -852,13 +852,13 @@ function test_CuttingPlanes_multiparameter() @objective(model, Min, ∫(∫(x, t), s)) - # Should not throw - @test optimize!(model, - gdp_method = CuttingPlanes( - HiGHS.Optimizer; max_iter = 5) - ) isa Nothing + optimize!(model, + gdp_method = CuttingPlanes(HiGHS.Optimizer; max_iter = 5)) @test termination_status(model) in [MOI.OPTIMAL, MOI.LOCALLY_SOLVED] + # Y[2] (x <= 3) is the minimizer: x = 0 across (t, s) → ∫∫x = 0. + @test objective_value(model) ≈ 0.0 atol = 1e-4 + @test all(isapprox.(value(x), 0.0; atol = 1e-6)) end function test_loa_infinite_nonlinear_global() @@ -887,6 +887,8 @@ function test_loa_infinite_nonlinear_global() @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) @test objective_value(model) ≈ 5.0 atol = 1e-2 + @test all(value(Y[2])) + @test all(isapprox.(value(x), 5.0; atol = 1e-2)) end function test_loa_infinite_complement_indicator() @@ -1036,6 +1038,32 @@ function test_loa_infinite_aggregate_global() @test objective_value(model) ≈ 1.0 atol = 1e-2 end +function test_loa_infinite_aggregate_disjunct() + # An aggregate (measure) constraint INSIDE a disjunct: it + # transcribes to a single scalar row that must be gated by the + # indicator's binary. max ∫x dt with Y[1]: ∫(x^2, t) <= 4 and + # Y[2]: x <= 0. Y[1] is optimal: constant x = 2 gives ∫x^2 = 4 + # and ∫x dt = 2 (Y[2] caps the objective at 0). Big-M tightening + # is disabled: it needs bounds of every constraint variable and a + # `MeasureRef` has none. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + model = InfiniteGDPModel(ipopt) + set_silent(model) + @infinite_parameter(model, t ∈ [0, 1], num_supports = 10) + @variable(model, 0 <= x <= 10, Infinite(t)) + @variable(model, Y[1:2], Logical) + @constraint(model, ∫(x^2, t) <= 4, Disjunct(Y[1])) + @constraint(model, x <= 0, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, ∫(x, t)) + optimize!(model, gdp_method = LOA(ipopt; + mip_optimizer = HiGHS.Optimizer, inner_method = BigM(1e4, false))) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 2.0 atol = 1e-2 + @test value(Y[1]) +end + function test_loa_infinite_iteration_loop() # Force the InfiniteModel LOA main loop to fix combinations that # vary per support. Two disjunctions over disjoint x/z half-lines @@ -1064,6 +1092,10 @@ function test_loa_infinite_iteration_loop() @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) @test objective_value(model) ≈ -10.0 atol = 1e-3 + @test all(value(Y[1])) + @test all(value(W[2])) + @test all(isapprox.(value(x), 0.0; atol = 1e-6)) + @test all(isapprox.(value(z), 10.0; atol = 1e-6)) end function test_loa_infinite_hull_linear() @@ -1109,6 +1141,8 @@ function test_loa_infinite_hull_nonlinear_disjunct() mip_optimizer = HiGHS.Optimizer, inner_method = Hull())) @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) @test objective_value(model) ≈ 8.0 atol = 1e-2 + @test all(value(Y[2])) + @test all(isapprox.(value(x), 8.0; atol = 1e-2)) end function test_loa_infinite_hull_complement_nonlinear() @@ -1237,6 +1271,7 @@ end @testset "LOA" begin test_loa_infinite_nonlinear_global() test_loa_infinite_aggregate_global() + test_loa_infinite_aggregate_disjunct() test_loa_infinite_complement_indicator() test_loa_infinite_complement_nonlinear_disjunct() test_loa_infinite_multidim_parameter() From 025f9f8af60ee882d4bc955c99d46356cbf04848 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Thu, 2 Jul 2026 17:15:55 -0400 Subject: [PATCH 64/72] Adding utilities tests --- src/loa.jl | 12 +++------- src/utilities.jl | 7 +----- test/constraints/loa.jl | 4 +--- test/utilities.jl | 50 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 18 deletions(-) diff --git a/src/loa.jl b/src/loa.jl index 25fb9776..416c661d 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -573,16 +573,15 @@ end # Extract RHS from an MOI set. _set_rhs(s::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo}) = _MOI.constant(s) -_set_rhs(::Any) = 0.0 # The `<= 0` directions of an OA cut for `set`: `lin - rhs` for LessThan, # `rhs - lin` for GreaterThan, both for EqualTo / Interval. The caller # adds the gating, slack, and (for Hull) disaggregation per direction. +_oa_cut_terms(set::_MOI.LessThan, lin) = (lin - _set_rhs(set),) _oa_cut_terms(set::_MOI.GreaterThan, lin) = (_set_rhs(set) - lin,) _oa_cut_terms(set::_MOI.EqualTo, lin) = (lin - _MOI.constant(set), _MOI.constant(set) - lin) _oa_cut_terms(set::_MOI.Interval, lin) = (lin - set.upper, set.lower - lin) -_oa_cut_terms(set, lin) = (lin - _set_rhs(set),) # Big-M / MBM disjunct cut: each direction slacked and gated by # `M(1 - binary)`. @@ -627,12 +626,11 @@ function _emit_disjunct_oa_cut( end # Slacked global OA row(s): each direction carries a penalized slack so a -# nonconvex linearization can't make the master infeasible. Unknown sets -# fall back to a hard cut. +# nonconvex linearization can't make the master infeasible. function _add_global_oa_row( master::_LOAMaster, lin, - set::Union{_MOI.LessThan, _MOI.GreaterThan, _MOI.EqualTo, _MOI.Interval}, + set, method::LOA, penalty_sign::Int ) @@ -642,7 +640,3 @@ function _add_global_oa_row( end return end -function _add_global_oa_row(master::_LOAMaster, lin, set, ::LOA, ::Int) - JuMP.@constraint(master.model, lin in set) - return -end diff --git a/src/utilities.jl b/src/utilities.jl index 6f402a44..2af2cc12 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -612,7 +612,7 @@ function _linearize_at( nlp, _MOI.Nonlinear.SparseReverseMode(), ord) _MOI.initialize(evaluator, [:Grad]) - xk_vec = [_unwrap_scalar(get(xk, v, zero(T))) for v in vars] + xk_vec = [get(xk, v, zero(T)) for v in vars] f_xk = _MOI.eval_objective(evaluator, xk_vec) grad = zeros(T, n) _MOI.eval_objective_gradient(evaluator, grad, xk_vec) @@ -629,8 +629,3 @@ function _linearize_at( end return result end - -# Unwrap a 1-element `Vector` to its scalar; scalars pass through -# (`extract_solution` returns length-1 vectors for scalar variables). -_unwrap_scalar(v::Real) = v -_unwrap_scalar(v::AbstractVector) = only(v) diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index e9d1dd1e..8dc4d132 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -50,13 +50,11 @@ end function test_oa_cut_terms() # The `<= 0` cut directions per set, at a scalar linearization value # of 5: LessThan/GreaterThan give one signed direction, EqualTo and - # Interval give both, unknown sets fall back to RHS 0. + # Interval give both. @test DP._oa_cut_terms(MOI.LessThan(2.0), 5.0) == (3.0,) @test DP._oa_cut_terms(MOI.GreaterThan(2.0), 5.0) == (-3.0,) @test DP._oa_cut_terms(MOI.EqualTo(2.0), 5.0) == (3.0, -3.0) @test DP._oa_cut_terms(MOI.Interval(1.0, 4.0), 5.0) == (1.0, -4.0) - @test DP._set_rhs(MOI.ZeroOne()) == 0.0 - @test DP._oa_cut_terms(MOI.ZeroOne(), 5.0) == (5.0,) end function test_no_good_cut() diff --git a/test/utilities.jl b/test/utilities.jl index 2a98366d..463bbb88 100644 --- a/test/utilities.jl +++ b/test/utilities.jl @@ -309,6 +309,54 @@ function test_to_nlp_expr() @test DP._to_nlp_expr(42, idx) == 42 end +function test_fix_unfix_indicator() + # `fix_indicator` drives the backing binary: a plain indicator fixes + # it at the value, a complement indicator fixes the underlying + # binary at the inverse. `unfix_indicator` undoes both forms. + model = GDPModel() + @variable(model, x) + @variable(model, Y1, Logical) + @variable(model, Y2, Logical, logical_complement = Y1) + @constraint(model, x <= 3, Disjunct(Y1)) + @constraint(model, x >= 5, Disjunct(Y2)) + @disjunction(model, [Y1, Y2]) + DP.reformulate_model(model, BigM()) + y1 = DP._indicator_to_binary(model)[Y1] + + DP.fix_indicator(model, Y1, true) + @test JuMP.is_fixed(y1) && JuMP.fix_value(y1) == 1.0 + DP.fix_indicator(model, Y1, false) + @test JuMP.fix_value(y1) == 0.0 + DP.unfix_indicator(model, Y1) + @test !JuMP.is_fixed(y1) + + DP.fix_indicator(model, Y2, true) # complement: underlying -> 0 + @test JuMP.fix_value(y1) == 0.0 + DP.fix_indicator(model, Y2, false) + @test JuMP.fix_value(y1) == 1.0 + DP.unfix_indicator(model, Y2) + @test !JuMP.is_fixed(y1) +end + +function test_avoid_combination_default_map() + # The 2-arg form resolves binaries via the model's own + # indicator-to-binary map. Excluding (Y1 active, Y2 inactive) adds + # (1 - y1) + y2 >= 1, i.e. normalized -y1 + y2 >= 0. + model = GDPModel() + @variable(model, x) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x >= 5, Disjunct(Y[2])) + @disjunction(model, Y) + DP.reformulate_model(model, BigM()) + binary_map = DP._indicator_to_binary(model) + + cref = DP.avoid_combination(model, Dict(Y[1] => true, Y[2] => false)) + @test JuMP.normalized_coefficient(cref, binary_map[Y[1]]) == -1.0 + @test JuMP.normalized_coefficient(cref, binary_map[Y[2]]) == 1.0 + @test JuMP.normalized_rhs(cref) == 0.0 +end + @testset "Utility Functions" begin test_all_variables() test_collect_all_vars() @@ -320,4 +368,6 @@ end test_linearize_nonlinear_sin() test_linearize_nonlinear_multivar() test_to_nlp_expr() + test_fix_unfix_indicator() + test_avoid_combination_default_map() end From 5adee47d064b96bfb15851ff8a64969de813606b Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Fri, 3 Jul 2026 10:19:04 -0400 Subject: [PATCH 65/72] _is_linear test coverage --- ext/InfiniteDisjunctiveProgramming.jl | 23 +---- src/datatypes.jl | 18 ++-- src/loa.jl | 128 +++++++++++++++++--------- test/constraints/loa.jl | 54 ++++++++--- 4 files changed, 137 insertions(+), 86 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 5a9426eb..3d791bf6 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -408,25 +408,6 @@ function _transcribed_binary_refs(model::InfiniteOpt.InfiniteModel, indicator) return [1.0 - binary for binary in binaries] end -# Transcribed combination for an InfiniteModel: every support of the -# underlying transcribed binary takes the indicator's value. -function _transcribed_combination( - model::InfiniteOpt.InfiniteModel, - combination - ) - result = Dict{JuMP.VariableRef, Bool}() - binary_map = DP._indicator_to_binary(model) - for (indicator, active) in combination - binary_ref = binary_map[indicator] - value = DP._underlying_value(binary_ref, active) - for binary in _transcribed_refs(InfiniteOpt.transformation_variable( - DP._underlying_binary(binary_ref))) - result[binary] = value - end - end - return result -end - # Transcribe the inner Hull disaggregation map per support. A finite # variable under an infinite indicator keys its single disaggregated # copy by each per-support binary reference, mirroring the per-support @@ -468,8 +449,6 @@ function DP.build_loa_problem( DP._underlying_binary(binary_ref)))) end unique!(binaries) - combinations = [_transcribed_combination(model, combination) - for combination in DP._set_covering_combinations(model)] disjunct_constraints = Tuple{Any, Any, Any}[] for (_, disjunction) in DP._disjunctions(model) @@ -509,7 +488,7 @@ function DP.build_loa_problem( end end - return DP._LOAProblem(nlp, binaries, combinations, + return DP._LOAProblem(nlp, binaries, disjunct_constraints, global_constraints, _transcribed_disaggregation_map(model, disaggregation_map)) end diff --git a/src/datatypes.jl b/src/datatypes.jl index 93af3239..741a168e 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -587,6 +587,8 @@ iteration) and a master MILP accumulating OA and no-good cuts. - `mip_optimizer::P`: solver for the master MILP (default `nlp_optimizer`). - `inner_method::R`: NLP reformulation — `BigM`, `MBM`, or `Hull`. - `max_iter::Int`: max iterations after set-covering seeding. +- `set_cover_max_iter::Int`: max iterations of the set-covering seed + loop. - `M_value::T`: big-M for the disjunct OA cut gating term. - `max_slack::T`: upper bound per slack variable. - `oa_penalty::T`: penalty on slacks in the master objective. @@ -602,6 +604,7 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod mip_optimizer::P inner_method::R max_iter::Int + set_cover_max_iter::Int M_value::T max_slack::T oa_penalty::T @@ -613,6 +616,7 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod nlp_optimizer::O; mip_optimizer::P = nlp_optimizer, max_iter::Int = 10, + set_cover_max_iter::Int = 8, M_value::T = 1e9, max_slack::T = 1e3, oa_penalty::T = 1e3, @@ -626,7 +630,7 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod "LOA inner_method must be BigM, MBM, or Hull (got $R). " * "PSplit is not yet supported.") new{O, P, R, T}(nlp_optimizer, mip_optimizer, inner_method, - max_iter, M_value, max_slack, oa_penalty, + max_iter, set_cover_max_iter, M_value, max_slack, oa_penalty, convergence_tol, slack_tol, iteration_time_limit, time_limit) end @@ -634,16 +638,16 @@ end # The problem the LOA loop operates on: the model solved as the NLP # subproblem, the binary variables backing the indicators, the -# set-covering seed combinations, the nonlinear disjunct -# `(binary_ref, function, set)` triples (`binary_ref` is the binary or -# its `1 - y` complement expression), the nonlinear global -# `(function, set)` pairs, and the `(variable, binary_ref) -> +# nonlinear disjunct `(binary_ref, function, set)` triples (`binary_ref` +# is the binary or its `1 - y` complement expression), the nonlinear +# global `(function, set)` pairs, and the `(variable, binary_ref) -> # disaggregated variable` map from an inner Hull reformulation -# (`nothing` for Big-M / MBM). Built by `build_loa_problem`. +# (`nothing` for Big-M / MBM). Built by `build_loa_problem`. The +# set-covering seed generates its combinations on the fly from the +# master, so none are stored here. struct _LOAProblem{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef} nlp::M binaries::Vector{V} - covering_combinations::Vector{Dict{V, Bool}} disjunct_constraints::Vector{Tuple{Any, Any, Any}} global_constraints::Vector{Tuple{Any, Any}} disaggregation_map::Any diff --git a/src/loa.jl b/src/loa.jl index 416c661d..7e73f34a 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -44,11 +44,33 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) best_result = nothing master_bound = nothing - # Seed the master with an OA cut per set-covering combination, each - # NLP warm-started from the last feasible primal. + # Set-covering seed. This mimics Pyomo GDPopt's set-covering + # initialization: borrow the master as a covering MILP (only its + # objective changes), let it pick a combination that activates the + # nonlinear disjuncts still lacking a linearization, solve the NLP + # there, and seed the resulting OA and no-good cuts. Each NLP + # warm-starts from the last feasible primal. previous_result = nothing - for combination in problem.covering_combinations + cover_disjuncts = _cover_disjuncts(problem) + needs_cover = trues(length(cover_disjuncts)) + num_covered = 0 + for iteration in 1:method.set_cover_max_iter + (iteration == 1 || any(needs_cover)) || break time() < loop_deadline || break + # Swap in the covering objective, solve, read off the combination, + # then restore the OA objective (with its accumulated slack + # penalties) before emitting cuts against it. + oa_objective = JuMP.objective_function(master.model) + JuMP.@objective(master.model, Max, + _cover_objective(master, cover_disjuncts, needs_cover, + num_covered)) + _cap_remaining_time(master.model, loop_deadline) + JuMP.optimize!(master.model) + solved = JuMP.is_solved_and_feasible(master.model) + combination = solved ? _extract_combination(problem, master) : nothing + JuMP.set_objective_sense(master.model, master.objective_sense) + JuMP.set_objective_function(master.model, oa_objective) + solved || break _set_nlp_warm_start(previous_result) result = _solve_nlp(problem, combination, method; deadline = loop_deadline) @@ -60,6 +82,17 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) best_result = result end result.feasible && (previous_result = result) + # A disjunct counts as covered only once it is active in a + # feasible NLP. An infeasible combination still contributes its + # no-good cut above but leaves the coverage targets untouched. + if result.feasible + for i in eachindex(cover_disjuncts) + needs_cover[i] || continue + _disjunct_active(result.combination, cover_disjuncts[i]) && + (needs_cover[i] = false) + end + num_covered = count(!, needs_cover) + end end # Master/NLP loop: `alpha_oa` is the bound, the NLP refines the @@ -143,25 +176,50 @@ function reformulate_model(::M, ::LOA) where {M} end ################################################################################ -# SET-COVERING INITIALIZATION (simple version from pyomo) +# SET-COVERING INITIALIZATION ################################################################################ -# `K = max disjunction size` combinations that activate every indicator -# at least once: combination `k` activates the `k`-th indicator of each -# disjunction, cycling via `mod1`. Inconsistent nested combinations are -# caught by the no-good cut from the infeasible NLP. -function _set_covering_combinations(model::JuMP.AbstractModel) - LogicalRef = LogicalVariableRef{typeof(model)} - indicator_lists = [collect(d.constraint.indicators) - for (_, d) in _disjunctions(model)] - isempty(indicator_lists) && return Dict{LogicalRef, Bool}[] - K = maximum(length, indicator_lists) - return [ - Dict{LogicalRef, Bool}( - indicator => (indicator == indicators[mod1(k, length(indicators))]) - for indicators in indicator_lists - for indicator in indicators) - for k in 1:K - ] +# The nonlinear disjuncts to cover: one entry per distinct indicator that +# owns a nonlinear disjunct constraint, carrying that indicator's +# `binary_ref`. Keyed by `(underlying binary, active value)` so the two +# disjuncts of a single-binary disjunction (`y` and its `1 - y` +# complement) stay distinct. Purely linear disjuncts need no cover: the +# inner reformulation already places them in the master exactly. +function _cover_disjuncts(problem::_LOAProblem) + V = eltype(problem.binaries) + seen = Set{Tuple{V, Bool}}() + disjuncts = Any[] + for (binary_ref, _, _) in problem.disjunct_constraints + key = (_underlying_binary(binary_ref), + _underlying_value(binary_ref, true)) + key in seen && continue + push!(seen, key) + push!(disjuncts, binary_ref) + end + return disjuncts +end + +# The set-covering objective (master space), mimicking Pyomo GDPopt: +# maximize active disjuncts weighted `num_covered + 1` if still uncovered +# else `1`, so one uncovered disjunct outweighs every covered one and each +# solve must activate a new disjunct when the logic allows. Empty +# `disjuncts` gives the zero expression: a constant objective that still +# seeds one feasible combination (needed to bound `alpha_oa`). +function _cover_objective( + master::_LOAMaster, + disjuncts, + needs_cover, + num_covered::Int + ) + T = JuMP.value_type(typeof(master.model)) + V = JuMP.variable_ref_type(typeof(master.model)) + expr = JuMP.GenericAffExpr{T, V}(zero(T)) + for i in eachindex(disjuncts) + weight = needs_cover[i] ? num_covered + 1 : 1 + activation = _remap_indicator_to_binary(disjuncts[i], + master.variable_map) + JuMP.add_to_expression!(expr, T(weight), activation) + end + return expr end ################################################################################ @@ -176,20 +234,6 @@ _underlying_binary(binary_ref::JuMP.GenericAffExpr) = _underlying_value(::JuMP.AbstractVariableRef, active::Bool) = active _underlying_value(::JuMP.GenericAffExpr, active::Bool) = !active -# Translate an indicator-level combination onto the underlying binary -# variables, inverting the value for complement-form indicators. -function _binary_combination(model::JuMP.AbstractModel, combination) - V = JuMP.variable_ref_type(typeof(model)) - binary_map = _indicator_to_binary(model) - result = Dict{V, Bool}() - for (indicator, active) in combination - binary_ref = binary_map[indicator] - result[_underlying_binary(binary_ref)] = - _underlying_value(binary_ref, active) - end - return result -end - """ build_loa_problem( model::JuMP.AbstractModel, @@ -199,11 +243,11 @@ end Build the problem the LOA loop operates on from `model` (already reformulated by the LOA inner method): the NLP subproblem, the binary -variables backing the indicators, the set-covering seed combinations, -the nonlinear disjunct `(binary_ref, function, set)` triples, the -nonlinear global `(function, set)` pairs, and the Hull disaggregation -map. The NLP is `model` itself; the InfiniteOpt extension overloads -this to build the problem from the transcribed backend instead. +variables backing the indicators, the nonlinear disjunct +`(binary_ref, function, set)` triples, the nonlinear global +`(function, set)` pairs, and the Hull disaggregation map. The NLP is +`model` itself; the InfiniteOpt extension overloads this to build the +problem from the transcribed backend instead. ## Returns - `_LOAProblem`: the problem. @@ -219,8 +263,6 @@ function build_loa_problem( binaries = V[_underlying_binary(binary_ref) for (_, binary_ref) in binary_map] unique!(binaries) - combinations = [_binary_combination(model, combination) - for combination in _set_covering_combinations(model)] disjunct_constraints = Tuple{Any, Any, Any}[] for (_, disjunction) in _disjunctions(model) @@ -255,7 +297,7 @@ function build_loa_problem( binary_disaggregations = disaggregation_map === nothing ? nothing : Dict((variable, binary_map[indicator]) => disaggregated for ((variable, indicator), disaggregated) in disaggregation_map) - return _LOAProblem(model, binaries, combinations, disjunct_constraints, + return _LOAProblem(model, binaries, disjunct_constraints, global_constraints, binary_disaggregations) end diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 8dc4d132..f864fb71 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -5,6 +5,7 @@ function test_loa_datatype() @test method.nlp_optimizer == HiGHS.Optimizer @test method.mip_optimizer == HiGHS.Optimizer @test method.max_iter == 10 + @test method.set_cover_max_iter == 8 @test method.M_value == 1e9 @test method.max_slack == 1000.0 @test method.oa_penalty == 1000.0 @@ -29,22 +30,31 @@ function test_loa_datatype() @test method.inner_method isa MBM end -function test_set_covering_combos() +function test_cover_disjuncts() + # Only disjuncts owning a nonlinear constraint need covering: the two + # `x^2` disjuncts do, the two linear `x` disjuncts do not. model = GDPModel() - @variable(model, x) + @variable(model, -5 <= x <= 5) @variable(model, Y[1:2], Logical) - @constraint(model, x <= 3, Disjunct(Y[1])) - @constraint(model, x >= 5, Disjunct(Y[2])) + @constraint(model, x^2 <= 3, Disjunct(Y[1])) + @constraint(model, x^2 >= 5, Disjunct(Y[2])) @disjunction(model, Y) + @variable(model, W[1:2], Logical) + @constraint(model, x <= 1, Disjunct(W[1])) + @constraint(model, x >= -1, Disjunct(W[2])) + @disjunction(model, W) - combos = DP._set_covering_combinations(model) + DP.reformulate_model(model, BigM(1e9)) + method = LOA(HiGHS.Optimizer) + problem = DP.build_loa_problem(model, method) + disjuncts = DP._cover_disjuncts(problem) - # K = 2 combinations, each activating exactly one indicator, and - # together covering both. - @test length(combos) == 2 - @test all(count(values(combo)) == 1 for combo in combos) - @test combos[1][Y[1]] && !combos[1][Y[2]] - @test combos[2][Y[2]] && !combos[2][Y[1]] + # Two nonlinear disjuncts to cover; the linear W disjuncts omitted. + @test length(disjuncts) == 2 + binary_map = DP._indicator_to_binary(model) + underlying = Set(DP._underlying_binary(dref) for dref in disjuncts) + @test underlying == Set([DP._underlying_binary(binary_map[Y[1]]), + DP._underlying_binary(binary_map[Y[2]])]) end function test_oa_cut_terms() @@ -57,6 +67,17 @@ function test_oa_cut_terms() @test DP._oa_cut_terms(MOI.Interval(1.0, 4.0), 5.0) == (1.0, -4.0) end +function test_is_linear_F() + # Scalar and vector variable-ref / affine function types are linear + # (copied into the master at build time); anything else, e.g. a + # quadratic, is nonlinear and enters only as an OA cut. + @test DP._is_linear_F(JuMP.VariableRef) + @test DP._is_linear_F(JuMP.AffExpr) + @test DP._is_linear_F(Vector{JuMP.VariableRef}) + @test DP._is_linear_F(Vector{JuMP.AffExpr}) + @test !DP._is_linear_F(JuMP.QuadExpr) +end + function test_no_good_cut() model = GDPModel() @variable(model, x) @@ -71,7 +92,12 @@ function test_no_good_cut() master = DP._build_loa_master(problem, method) master_model = master.model - combo = DP._binary_combination(model, Dict(Y[1] => true, Y[2] => false)) + binary_map = DP._indicator_to_binary(model) + combo = Dict( + DP._underlying_binary(binary_map[Y[1]]) => + DP._underlying_value(binary_map[Y[1]], true), + DP._underlying_binary(binary_map[Y[2]]) => + DP._underlying_value(binary_map[Y[2]], false)) num_cons_before = length(JuMP.all_constraints( master_model; @@ -84,7 +110,6 @@ function test_no_good_cut() @test num_cons_after == num_cons_before + 1 # The cut is (1 - y1) + y2 >= 1, i.e. normalized -y1 + y2 >= 0: # excludes exactly the (Y1 active, Y2 inactive) combination. - binary_map = DP._indicator_to_binary(model) y1 = master.variable_map[binary_map[Y[1]]] y2 = master.variable_map[binary_map[Y[2]]] @test JuMP.normalized_coefficient(cref, y1) == -1.0 @@ -645,8 +670,9 @@ end @testset "LOA" begin test_loa_datatype() - test_set_covering_combos() + test_cover_disjuncts() test_oa_cut_terms() + test_is_linear_F() test_no_good_cut() test_loa_reformulate_simple() test_loa_solve_simple() From 5b9dca193f65f971bb8a542332440e4af6290e8b Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Fri, 3 Jul 2026 10:44:33 -0400 Subject: [PATCH 66/72] Infeasible NLP test --- test/constraints/loa.jl | 44 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index f864fb71..62517cbf 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -401,6 +401,49 @@ function test_loa_nlpf_infeasible_disjunct() @test value(Y[1]) ≈ 0.0 atol = 1e-6 end +function test_loa_solve_nlp_infeasible_fallback() + # `_solve_nlp` fallback: both the primary NLP and NLPF are + # infeasible at the fixed combination, so it returns the infeasible + # sentinel (objective = Inf, feasible = false, empty linearization + # point, combination echoed back unchanged). + # + # The global x^2 == 400 is unsatisfiable under 0 <= x <= 10 (needs + # x = 20). It is NONLINEAR, so it never enters the master and the + # primary NLP is the one that catches it as infeasible. It is an + # EQUALITY, so NLPF does not slack it and NLPF is infeasible too. + # This is what distinguishes the path from + # test_loa_no_feasible_incumbent, whose LINEAR equality makes the + # master infeasible first, short-circuiting the NLP solve entirely. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + model = GDPModel() + @variable(model, 0 <= x <= 10) + @constraint(model, x^2 == 400) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + + DP.reformulate_model(model, BigM(1e9)) + method = LOA(ipopt) + problem = DP.build_loa_problem(model, method) + DP._relax_binaries(problem) + JuMP.set_optimizer(problem.nlp, method.nlp_optimizer) + JuMP.set_silent(problem.nlp) + + binary_map = DP._indicator_to_binary(model) + combination = Dict( + DP._underlying_binary(binary_map[Y[1]]) => false, + DP._underlying_binary(binary_map[Y[2]]) => true) + + result = DP._solve_nlp(problem, combination, method) + @test result.feasible == false + @test result.objective == Inf + @test isempty(result.linearization_point) + @test result.combination === combination +end + function test_loa_sense_primitives() # Sense-dispatched primitives the main loop reads through. The # finite/infinite solve tests cover every branch except the @@ -687,6 +730,7 @@ end test_loa_limit_hit_report() test_loa_complement_indicator_nonlinear_disjunct() test_loa_nlpf_infeasible_disjunct() + test_loa_solve_nlp_infeasible_fallback() test_loa_sense_primitives() test_loa_iteration_loop() test_loa_time_limits() From 1f38951697850302cb8e8162c573610754106a80 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Fri, 3 Jul 2026 11:54:00 -0400 Subject: [PATCH 67/72] Option to forgo nlpf --- ext/InfiniteDisjunctiveProgramming.jl | 11 +++-- src/datatypes.jl | 34 ++++++++++++---- src/loa.jl | 34 +++++++++------- test/constraints/loa.jl | 58 ++++++++++++++++++++++++++- 4 files changed, 111 insertions(+), 26 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 3d791bf6..d9dc7b71 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -394,7 +394,7 @@ _transcribed_refs(x) = [x] # Index a per-support vector, broadcasting a single-element (finite) # vector across all supports. -_at(values::AbstractVector, k::Int) = +_at(values::Vector, k::Int) = length(values) == 1 ? values[1] : values[k] # Per-support transcribed binary references for an indicator: the @@ -418,7 +418,8 @@ function _transcribed_disaggregation_map( model::InfiniteOpt.InfiniteModel, disaggregation_map ) - result = Dict{Any, Any}() + result = Dict{Tuple{JuMP.VariableRef, + Union{JuMP.VariableRef, JuMP.AffExpr}}, JuMP.VariableRef}() for ((variable, indicator), disaggregated) in disaggregation_map binary_refs = _transcribed_binary_refs(model, indicator) variables = _transcribed_refs( @@ -450,7 +451,8 @@ function DP.build_loa_problem( end unique!(binaries) - disjunct_constraints = Tuple{Any, Any, Any}[] + disjunct_constraints = Tuple{Union{JuMP.VariableRef, JuMP.AffExpr}, + JuMP.AbstractJuMPScalar, _MOI.AbstractScalarSet}[] for (_, disjunction) in DP._disjunctions(model) for indicator in disjunction.constraint.indicators haskey(DP._indicator_to_constraints(model), indicator) || @@ -472,7 +474,8 @@ function DP.build_loa_problem( end end - global_constraints = Tuple{Any, Any}[] + global_constraints = Tuple{JuMP.AbstractJuMPScalar, + _MOI.AbstractScalarSet}[] reform_set = Set(DP._reformulation_constraints(model)) for (F, S) in JuMP.list_of_constraint_types(model) F === InfiniteOpt.GeneralVariableRef && continue diff --git a/src/datatypes.jl b/src/datatypes.jl index 741a168e..ac4c5b16 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -419,7 +419,7 @@ struct Hull{T} <: AbstractReformulationMethod value::T # Internal: LOA installs a Dict here to collect the disaggregation # map during reformulation; `nothing` for every other use. - disaggregation_map::Any + disaggregation_map::Union{Nothing, AbstractDict} function Hull(ϵ::T = 1e-6; disaggregation_map = nothing) where {T} new{T}(ϵ, disaggregation_map) end @@ -430,7 +430,7 @@ mutable struct _Hull{V <: JuMP.AbstractVariableRef, T} <: AbstractReformulationM value::T disjunction_variables::Dict{V, Vector{V}} disjunct_variables::Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V} - disaggregation_map::Any + disaggregation_map::Union{Nothing, AbstractDict} function _Hull(method::Hull{T}, vrefs::Set{V}) where {T, V <: JuMP.AbstractVariableRef} new{V, T}( method.value, @@ -592,6 +592,9 @@ iteration) and a master MILP accumulating OA and no-good cuts. - `M_value::T`: big-M for the disjunct OA cut gating term. - `max_slack::T`: upper bound per slack variable. - `oa_penalty::T`: penalty on slacks in the master objective. +- `use_nlpf::Bool`: solve the NLPF feasibility subproblem when the + primary NLP is infeasible (default `true`). When `false`, an + infeasible combination contributes only its no-good cut, no OA cuts. - `convergence_tol::Float64`: relative gap tolerance for the early stop. - `slack_tol::Float64`: max total slack for which the bound still counts as converged (positive slack = nonconvex crossing, keep iterating). @@ -608,6 +611,7 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod M_value::T max_slack::T oa_penalty::T + use_nlpf::Bool convergence_tol::Float64 slack_tol::Float64 iteration_time_limit::Float64 @@ -621,6 +625,7 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod max_slack::T = 1e3, oa_penalty::T = 1e3, inner_method::R = BigM(M_value), + use_nlpf::Bool = true, convergence_tol::Float64 = 1e-6, slack_tol::Float64 = 1e-4, iteration_time_limit::Float64 = Inf, @@ -631,7 +636,7 @@ struct LOA{O, P, R, T} <: AbstractReformulationMethod "PSplit is not yet supported.") new{O, P, R, T}(nlp_optimizer, mip_optimizer, inner_method, max_iter, set_cover_max_iter, M_value, max_slack, oa_penalty, - convergence_tol, slack_tol, + use_nlpf, convergence_tol, slack_tol, iteration_time_limit, time_limit) end end @@ -645,12 +650,27 @@ end # (`nothing` for Big-M / MBM). Built by `build_loa_problem`. The # set-covering seed generates its combinations on the fly from the # master, so none are stored here. -struct _LOAProblem{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef} +struct _LOAProblem{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, T} nlp::M binaries::Vector{V} - disjunct_constraints::Vector{Tuple{Any, Any, Any}} - global_constraints::Vector{Tuple{Any, Any}} - disaggregation_map::Any + disjunct_constraints::Vector{Tuple{Union{V, JuMP.GenericAffExpr{T, V}}, + JuMP.AbstractJuMPScalar, _MOI.AbstractScalarSet}} + global_constraints::Vector{Tuple{JuMP.AbstractJuMPScalar, + _MOI.AbstractScalarSet}} + disaggregation_map::Union{Nothing, + Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V}} +end + +# Outer constructor supplying the value type `T` from the NLP model. +function _LOAProblem( + nlp::M, + binaries::Vector{V}, + disjunct_constraints::Vector, + global_constraints::Vector, + disaggregation_map + ) where {M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef} + return _LOAProblem{M, V, JuMP.value_type(M)}(nlp, binaries, + disjunct_constraints, global_constraints, disaggregation_map) end # The LOA master MILP plus the NLP-to-master variable map. diff --git a/src/loa.jl b/src/loa.jl index 7e73f34a..3030305d 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -184,10 +184,9 @@ end # disjuncts of a single-binary disjunction (`y` and its `1 - y` # complement) stay distinct. Purely linear disjuncts need no cover: the # inner reformulation already places them in the master exactly. -function _cover_disjuncts(problem::_LOAProblem) - V = eltype(problem.binaries) +function _cover_disjuncts(problem::_LOAProblem{M, V, T}) where {M, V, T} seen = Set{Tuple{V, Bool}}() - disjuncts = Any[] + disjuncts = Union{V, JuMP.GenericAffExpr{T, V}}[] for (binary_ref, _, _) in problem.disjunct_constraints key = (_underlying_binary(binary_ref), _underlying_value(binary_ref, true)) @@ -258,13 +257,15 @@ function build_loa_problem( disaggregation_map = nothing ) V = JuMP.variable_ref_type(typeof(model)) + T = JuMP.value_type(typeof(model)) binary_map = _indicator_to_binary(model) binaries = V[_underlying_binary(binary_ref) for (_, binary_ref) in binary_map] unique!(binaries) - disjunct_constraints = Tuple{Any, Any, Any}[] + disjunct_constraints = Tuple{Union{V, JuMP.GenericAffExpr{T, V}}, + JuMP.AbstractJuMPScalar, _MOI.AbstractScalarSet}[] for (_, disjunction) in _disjunctions(model) for indicator in disjunction.constraint.indicators haskey(_indicator_to_constraints(model), indicator) || continue @@ -281,7 +282,8 @@ function build_loa_problem( end end - global_constraints = Tuple{Any, Any}[] + global_constraints = Tuple{JuMP.AbstractJuMPScalar, + _MOI.AbstractScalarSet}[] reform_set = Set(_reformulation_constraints(model)) for (F, S) in JuMP.list_of_constraint_types(model) F === V && continue @@ -295,7 +297,8 @@ function build_loa_problem( end binary_disaggregations = disaggregation_map === nothing ? nothing : - Dict((variable, binary_map[indicator]) => disaggregated + Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V}( + (variable, binary_map[indicator]) => disaggregated for ((variable, indicator), disaggregated) in disaggregation_map) return _LOAProblem(model, binaries, disjunct_constraints, global_constraints, binary_disaggregations) @@ -375,13 +378,12 @@ end # Master-space Hull disaggregator for the convex-hull cut emitter # (`nothing` for Big-M / MBM): remap the `(variable, binary_ref) -> # disaggregated variable` map into master refs. -_build_disaggregator(::_LOAProblem, ::AbstractDict, ::Union{BigM, MBM}) = - nothing +_build_disaggregator(::_LOAProblem, ::Dict, ::Union{BigM, MBM}) = nothing function _build_disaggregator( problem::_LOAProblem, - variable_map::AbstractDict, + variable_map::Dict{V, V}, inner::Hull - ) + ) where {V <: JuMP.AbstractVariableRef} hull = _Hull(inner, Set{valtype(variable_map)}()) for ((variable, binary_ref), disaggregated) in problem.disaggregation_map hull.disjunct_variables[(variable_map[variable], @@ -424,7 +426,8 @@ end # Solve the NLP at a fixed combination. If infeasible, fall # through to NLPF (a slacked version that always solves) so the master -# still gets a linearization site, not just a no-good cut. +# still gets a linearization site, not just a no-good cut. NLPF can be +# switched off via `use_nlpf = false`. function _solve_nlp( problem::_LOAProblem, combination, @@ -441,9 +444,12 @@ function _solve_nlp( objective = JuMP.objective_value(nlp), feasible = true) end - # Primary NLP infeasible — try NLPF. - nlpf = _solve_nlpf(problem, combination, method; deadline = deadline) - nlpf === nothing || return nlpf + # Primary NLP infeasible — try NLPF (unless disabled, in which case + # the combination contributes only its no-good cut). + if method.use_nlpf + nlpf = _solve_nlpf(problem, combination, method; deadline = deadline) + nlpf === nothing || return nlpf + end V = JuMP.variable_ref_type(typeof(nlp)) T = JuMP.value_type(typeof(nlp)) diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 62517cbf..a6ca14bc 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -9,18 +9,20 @@ function test_loa_datatype() @test method.M_value == 1e9 @test method.max_slack == 1000.0 @test method.oa_penalty == 1000.0 + @test method.use_nlpf == true @test method.convergence_tol == 1e-6 @test method.slack_tol == 1e-4 @test method.inner_method isa BigM @test method.inner_method.value == 1e9 method = LOA(HiGHS.Optimizer; max_iter = 50, M_value = 1e6, - max_slack = 500.0, oa_penalty = 200.0, + max_slack = 500.0, oa_penalty = 200.0, use_nlpf = false, convergence_tol = 1e-4, slack_tol = 1e-3) @test method.max_iter == 50 @test method.M_value == 1e6 @test method.max_slack == 500.0 @test method.oa_penalty == 200.0 + @test method.use_nlpf == false @test method.convergence_tol == 1e-4 @test method.slack_tol == 1e-3 @test method.inner_method isa BigM @@ -401,6 +403,59 @@ function test_loa_nlpf_infeasible_disjunct() @test value(Y[1]) ≈ 0.0 atol = 1e-6 end +function test_loa_nlpf_disabled() + # `use_nlpf = false` skips the NLPF fall-through entirely: an + # NLP-infeasible combination returns the bare infeasible sentinel + # (empty linearization point), so it contributes only its no-good + # cut. Same setup as test_loa_nlpf_infeasible_disjunct, where NLPF + # WOULD succeed (x = 10, u = 100) and return a nonempty + # linearization point — the empty point proves NLPF was skipped. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + model = GDPModel() + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x^2 >= 200, Disjunct(Y[1])) + @constraint(model, x <= 5, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + + DP.reformulate_model(model, BigM(1e9)) + method = LOA(ipopt; use_nlpf = false) + problem = DP.build_loa_problem(model, method) + DP._relax_binaries(problem) + JuMP.set_optimizer(problem.nlp, method.nlp_optimizer) + JuMP.set_silent(problem.nlp) + + binary_map = DP._indicator_to_binary(model) + combination = Dict( + DP._underlying_binary(binary_map[Y[1]]) => true, + DP._underlying_binary(binary_map[Y[2]]) => false) + + result = DP._solve_nlp(problem, combination, method) + @test result.feasible == false + @test result.objective == Inf + @test isempty(result.linearization_point) + + # End-to-end: with NLPF off, LOA still reaches the Y2 optimum x = 5 + # off no-good cuts alone. + juniper = optimizer_with_attributes(Juniper.Optimizer, + "nl_solver" => ipopt, "log_levels" => []) + model = GDPModel(juniper) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x^2 >= 200, Disjunct(Y[1])) + @constraint(model, x <= 5, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + optimize!(model, gdp_method = LOA(juniper; + mip_optimizer = HiGHS.Optimizer, use_nlpf = false)) + @test termination_status(model) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + @test objective_value(model) ≈ 5.0 atol = 1e-3 + @test value(Y[2]) ≈ 1.0 atol = 1e-6 +end + function test_loa_solve_nlp_infeasible_fallback() # `_solve_nlp` fallback: both the primary NLP and NLPF are # infeasible at the fixed combination, so it returns the infeasible @@ -730,6 +785,7 @@ end test_loa_limit_hit_report() test_loa_complement_indicator_nonlinear_disjunct() test_loa_nlpf_infeasible_disjunct() + test_loa_nlpf_disabled() test_loa_solve_nlp_infeasible_fallback() test_loa_sense_primitives() test_loa_iteration_loop() From 4c31ca6c819552e94b5d8b99a1ce3d947d5eda6c Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Fri, 3 Jul 2026 12:16:47 -0400 Subject: [PATCH 68/72] bound T in _LOAProblem --- src/datatypes.jl | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/datatypes.jl b/src/datatypes.jl index ac4c5b16..d14defb8 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -659,18 +659,21 @@ struct _LOAProblem{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, T} _MOI.AbstractScalarSet}} disaggregation_map::Union{Nothing, Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V}} -end -# Outer constructor supplying the value type `T` from the NLP model. -function _LOAProblem( - nlp::M, - binaries::Vector{V}, - disjunct_constraints::Vector, - global_constraints::Vector, - disaggregation_map - ) where {M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef} - return _LOAProblem{M, V, JuMP.value_type(M)}(nlp, binaries, - disjunct_constraints, global_constraints, disaggregation_map) + # Inner constructor deriving the value type `T` from the NLP model. + # `T` only appears inside `Union` field types, so the default + # constructors could be called without binding it (Aqua + # unbound_args); this replaces them. + function _LOAProblem( + nlp::M, + binaries::Vector{V}, + disjunct_constraints::Vector, + global_constraints::Vector, + disaggregation_map + ) where {M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef} + return new{M, V, JuMP.value_type(M)}(nlp, binaries, + disjunct_constraints, global_constraints, disaggregation_map) + end end # The LOA master MILP plus the NLP-to-master variable map. From 049f4e7ee4574c59fc267c15083cd77f8eea9d04 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Wed, 8 Jul 2026 13:30:19 -0400 Subject: [PATCH 69/72] _optimize_hook dispatch on AbstractReformulationMethod --- README.md | 5 +- docs/src/index.md | 2 +- ext/InfiniteDisjunctiveProgramming.jl | 14 +-- src/datatypes.jl | 40 ++++---- src/hull.jl | 9 +- src/loa.jl | 130 +++++++++++++++----------- src/model.jl | 30 +++++- src/reformulate.jl | 15 ++- src/utilities.jl | 6 ++ test/constraints/cuttingplanes.jl | 4 +- test/constraints/loa.jl | 47 +++++----- test/solve.jl | 7 ++ test/utilities.jl | 1 + 13 files changed, 184 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 543d1f7a..ea8d3f70 100644 --- a/README.md +++ b/README.md @@ -184,13 +184,12 @@ The following reformulation methods are currently supported: All variables must be included in exactly one partition. For manual partitioning, ensure each variable appears in exactly one group. For automatic partitioning, variables are divided as evenly as possible among the specified number of partitions. -6. [Cutting Planes](https://pubsonline.informs.org/doi/10.1287/ijoc.2015.0669): This method iteratively generates cutting planes using a separation problem and a relaxed Big-M formulation, then applies a final reformulation method. The `CuttingPlanes` struct is created with the following arguments: +6. [Cutting Planes](https://pubsonline.informs.org/doi/10.1287/ijoc.2015.0669): This method iteratively generates cutting planes using a separation problem and a relaxed Big-M formulation. The `CuttingPlanes` struct is created with the following arguments: - `optimizer`: Optimizer to use when solving the separation and relaxed Big-M subproblems. This is a required value. - `max_iter`: Maximum number of cutting plane iterations. Default: `3`. - `seperation_tolerance`: Convergence tolerance for the separation problem objective. Default: `1e-6`. - - `final_reform_method`: Reformulation method to apply after cutting plane iterations. Default: `BigM()`. - - `M_value`: Big-M value to use in the relaxed Big-M reformulation during iterations. Default: `1e9`. + - `M_value`: Big-M value to use in the relaxed Big-M reformulation. Default: `1e9`. ## Infinite-Dimensional GDP To model disjunctions, logical variables, and logical constraints with infinite-dimensional optimization problems (e.g., dynamic and stochastic optimization), DisjunctiveProgramming is also compatible with [InfiniteOpt.jl](https://github.com/infiniteopt/InfiniteOpt.jl). For this, the syntax is largely the same, users simply need to import `InfiniteOpt` and use `InfiniteGDPModel`. They also can use `InfiniteLogical` to declare infinite logical variables as shown below: diff --git a/docs/src/index.md b/docs/src/index.md index 615d598e..d342515a 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -171,7 +171,7 @@ The following reformulation methods are currently supported: 5. [Indicator](https://jump.dev/JuMP.jl/stable/manual/constraints/#Indicator-constraints): This method reformulates each disjunct constraint into an indicator constraint with the Boolean reformulation counterpart of the Logical variable used to define the disjunct constraint. This is invoked with [`Indicator`](@ref). -6. Logic-based Outer Approximation (LOA): An iterative solution method for nonlinear GDPs, rather than a single-shot reformulation. It alternates between a primal NLP (the model reformulated by an inner method — `BigM`, `MBM`, or `Hull` — with the disjunct binaries fixed at the current selection) and a master MILP that accumulates outer-approximation and no-good cuts until the bound meets the incumbent. This is invoked with the [`LOA`](@ref) struct, e.g. `optimize!(m, gdp_method = LOA(Ipopt.Optimizer))`. +6. Logic-based Outer Approximation (LOA): An iterative solution method for nonlinear GDPs, rather than a single-shot reformulation. It alternates between a primal NLP (the model reformulated by an inner method — `BigM`, `MBM`, or `Hull` — with the disjunct binaries fixed at the current selection) and a master MILP that accumulates outer-approximation and no-good cuts until the bound meets the incumbent. This is invoked with the [`LOA`](@ref) struct, e.g. `optimize!(m, gdp_method = LOA(Ipopt.Optimizer))`. Unlike the reformulation methods above, `LOA` runs the full algorithm on every `optimize!` call and cannot be passed to `reformulate_model`. ## Release Notes diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index d9dc7b71..facbc226 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -412,15 +412,10 @@ end # variable under an infinite indicator keys its single disaggregated # copy by each per-support binary reference, mirroring the per-support # disjunct cuts that look it up. -_transcribed_disaggregation_map(::InfiniteOpt.InfiniteModel, ::Nothing) = - nothing -function _transcribed_disaggregation_map( - model::InfiniteOpt.InfiniteModel, - disaggregation_map - ) +function _transcribed_disaggregation_map(model::InfiniteOpt.InfiniteModel) result = Dict{Tuple{JuMP.VariableRef, Union{JuMP.VariableRef, JuMP.AffExpr}}, JuMP.VariableRef}() - for ((variable, indicator), disaggregated) in disaggregation_map + for ((variable, indicator), disaggregated) in DP._disaggregation_map(model) binary_refs = _transcribed_binary_refs(model, indicator) variables = _transcribed_refs( InfiniteOpt.transformation_variable(variable)) @@ -437,8 +432,7 @@ end function DP.build_loa_problem( model::InfiniteOpt.InfiniteModel, - method::DP.LOA, - disaggregation_map = nothing + method::DP.LOA ) InfiniteOpt.build_transformation_backend!(model) nlp = InfiniteOpt.transformation_model(model) @@ -493,7 +487,7 @@ function DP.build_loa_problem( return DP._LOAProblem(nlp, binaries, disjunct_constraints, global_constraints, - _transcribed_disaggregation_map(model, disaggregation_map)) + _transcribed_disaggregation_map(model)) end end diff --git a/src/datatypes.jl b/src/datatypes.jl index d14defb8..b5c57619 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -417,11 +417,8 @@ constraints. """ struct Hull{T} <: AbstractReformulationMethod value::T - # Internal: LOA installs a Dict here to collect the disaggregation - # map during reformulation; `nothing` for every other use. - disaggregation_map::Union{Nothing, AbstractDict} - function Hull(ϵ::T = 1e-6; disaggregation_map = nothing) where {T} - new{T}(ϵ, disaggregation_map) + function Hull(ϵ::T = 1e-6) where {T} + new{T}(ϵ) end end @@ -430,13 +427,11 @@ mutable struct _Hull{V <: JuMP.AbstractVariableRef, T} <: AbstractReformulationM value::T disjunction_variables::Dict{V, Vector{V}} disjunct_variables::Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V} - disaggregation_map::Union{Nothing, AbstractDict} function _Hull(method::Hull{T}, vrefs::Set{V}) where {T, V <: JuMP.AbstractVariableRef} new{V, T}( method.value, Dict{V, Vector{V}}(vref => V[] for vref in vrefs), - Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V}(), - method.disaggregation_map + Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V}() ) end end @@ -450,24 +445,20 @@ A type for using the cutting planes approach for disjunctive constraints. - `optimizer::O`: Optimizer to use when solving mini-models (required). - `max_iter::Int`: Number of iterations (default = `3`). - `seperation_tolerance::T`: Tolerance for the separation problem (default = `1e-6`). -- `final_reform_method::AbstractReformulationMethod`: Final reformulation -method to use after cutting planes (default = `BigM()`). - `M_value::T`: Big-M value to use in the final reformulation (default = `1e9`). """ struct CuttingPlanes{O, T} <: AbstractReformulationMethod optimizer::O; max_iter::Int seperation_tolerance::T - final_reform_method::AbstractReformulationMethod M_value::T function CuttingPlanes( optimizer::O; max_iter::Int = 3, seperation_tolerance::T = 1e-6, - final_reform_method = BigM(), M_value::T = 1e9 ) where {O, T} - new{O, T}(optimizer, max_iter, seperation_tolerance, final_reform_method, M_value) + new{O, T}(optimizer, max_iter, seperation_tolerance, M_value) end end @@ -575,13 +566,18 @@ struct Indicator <: AbstractReformulationMethod end # LOA ################################################################################ """ - LOA{O, P, R, T} <: AbstractReformulationMethod + LOA{O, P, R, T} <: AbstractSolutionMethod Logic-based Outer Approximation solver for GDP models. Iterates a primary NLP (original model reformulated by `inner_method`, binaries fixed per iteration) and a master MILP accumulating OA and no-good cuts. `inner_method` is `BigM` (default), `MBM`, or `Hull`. +LOA is a solution algorithm, not a reformulation: it is invoked with +`optimize!(model, gdp_method = LOA(...))`, always re-runs on `optimize!`, +and is not accepted by [`reformulate_model`](@ref). After the run the +incumbent is loaded into `model`, so JuMP solution queries work directly. + ## Fields - `nlp_optimizer::O`: solver for the primary NLP. - `mip_optimizer::P`: solver for the master MILP (default `nlp_optimizer`). @@ -599,10 +595,10 @@ iteration) and a master MILP accumulating OA and no-good cuts. - `slack_tol::Float64`: max total slack for which the bound still counts as converged (positive slack = nonconvex crossing, keep iterating). - `iteration_time_limit::Float64`: budget (s) for the iteration loop. -- `time_limit::Float64`: overall budget (s) incl. the final solve - (default 3600; `Inf` disables). +- `time_limit::Float64`: overall budget (s) for the algorithm; the + final loading solve is not capped (default 3600; `Inf` disables). """ -struct LOA{O, P, R, T} <: AbstractReformulationMethod +struct LOA{O, P, R, T} <: AbstractSolutionMethod nlp_optimizer::O mip_optimizer::P inner_method::R @@ -647,7 +643,7 @@ end # is the binary or its `1 - y` complement expression), the nonlinear # global `(function, set)` pairs, and the `(variable, binary_ref) -> # disaggregated variable` map from an inner Hull reformulation -# (`nothing` for Big-M / MBM). Built by `build_loa_problem`. The +# (empty for Big-M / MBM). Built by `build_loa_problem`. The # set-covering seed generates its combinations on the fly from the # master, so none are stored here. struct _LOAProblem{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, T} @@ -657,8 +653,7 @@ struct _LOAProblem{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, T} JuMP.AbstractJuMPScalar, _MOI.AbstractScalarSet}} global_constraints::Vector{Tuple{JuMP.AbstractJuMPScalar, _MOI.AbstractScalarSet}} - disaggregation_map::Union{Nothing, - Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V}} + disaggregation_map::Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V} # Inner constructor deriving the value type `T` from the NLP model. # `T` only appears inside `Union` field types, so the default @@ -715,6 +710,10 @@ mutable struct GDPData{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, C # Helpful metadata for most reformulations (not just one of them) variable_bounds::Dict{V, Tuple{T, T}} + # Hull disaggregations recorded during reformulation, consumed by + # solution algorithms (LOA): (variable, indicator) => disaggregated + disaggregation_map::Dict{Tuple{V, LogicalVariableRef{M}}, V} + # Reformulation variables and constraints reformulation_variables::Vector{V} reformulation_constraints::Vector{C} @@ -735,6 +734,7 @@ mutable struct GDPData{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, C Dict{LogicalVariableRef{M}, Vector{Union{DisjunctConstraintRef{M}, DisjunctionRef{M}}}}(), Dict{Union{DisjunctConstraintRef{M}, DisjunctionRef{M}}, LogicalVariableRef{M}}(), Dict{V, Tuple{T, T}}(), + Dict{Tuple{V, LogicalVariableRef{M}}, V}(), Vector{V}(), Vector{C}(), nothing, diff --git a/src/hull.jl b/src/hull.jl index 6e43daad..8824dc0f 100644 --- a/src/hull.jl +++ b/src/hull.jl @@ -42,9 +42,8 @@ function _disaggregate_variable( #temp storage push!(method.disjunction_variables[vref], dvref) method.disjunct_variables[vref, bvref] = dvref - #record into the LOA disaggregation map when installed (LOA-Hull only) - method.disaggregation_map === nothing || - (method.disaggregation_map[(vref, lvref)] = dvref) + #record the disaggregation for solution algorithms (LOA) + _disaggregation_map(model)[(vref, lvref)] = dvref #create bounding constraints dvname = JuMP.name(dvref) lbname = isempty(dvname) ? "" : "$(dvname)_lower_bound" @@ -257,9 +256,7 @@ function reformulate_disjunction(model::JuMP.AbstractModel, disj::Disjunction, m return ref_cons end function reformulate_disjunction(model::JuMP.AbstractModel, disj::Disjunction, method::_Hull) - return reformulate_disjunction(model, disj, - Hull(method.value; - disaggregation_map = method.disaggregation_map)) + return reformulate_disjunction(model, disj, Hull(method.value)) end function reformulate_disjunct_constraint( diff --git a/src/loa.jl b/src/loa.jl index 3030305d..449ca901 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -14,18 +14,25 @@ _is_better(::Val{_MOI.MAX_SENSE}, new, best) = new > best _gap(::Val{_MOI.MIN_SENSE}, best, bound) = best - bound _gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best +# One progress record per NLP solve, for external convergence traces. +function _log_loa_progress(t_start::Float64, best_objective, master_bound) + bound = master_bound === nothing ? NaN : master_bound + @info "LOA progress: elapsed=$(time() - t_start) " * + "incumbent=$best_objective bound=$bound" + return +end + ################################################################################ # MAIN LOOP ################################################################################ -# LOA entry: build the problem, seed with set-covering combinations, -# iterate the master/NLP loop, and commit the best combination. -function reformulate_model(model::JuMP.AbstractModel, method::LOA) +# LOA optimize hook: build the problem, seed with set-covering +# combinations, iterate the master/NLP loop, commit the best +# combination, and load it with a final direct solve. +function _optimize_hook(model::JuMP.AbstractModel, method::LOA; kwargs...) _clear_reformulations(model) - # Hull needs the disaggregation map if its used as inner function - inner, disaggregation_map = _loa_inner_method(model, method.inner_method) - reformulate_model(model, inner) + reformulate_model(model, method.inner_method) - problem = build_loa_problem(model, method, disaggregation_map) + problem = build_loa_problem(model, method) # The master copies the NLP while its binaries still carry # integrality; the NLP is then relaxed once and each iteration # overwrites the binary fixes in place. @@ -82,6 +89,7 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) best_result = result end result.feasible && (previous_result = result) + _log_loa_progress(t_start, best_objective, master_bound) # A disjunct counts as covered only once it is active in a # feasible NLP. An infeasible combination still contributes its # no-good cut above but leaves the coverage targets untouched. @@ -126,20 +134,33 @@ function reformulate_model(model::JuMP.AbstractModel, method::LOA) best_result = result end result.feasible && (previous_result = result) + _log_loa_progress(t_start, best_objective, master_bound) end - best_result === nothing || _commit_combination(best_result) - if isfinite(method.time_limit) - # Overall cap: the final committed solve gets the budget left. - JuMP.set_time_limit_sec(nlp, max(0.0, overall_deadline - time())) - elseif isfinite(method.iteration_time_limit) - # Loop-only budget: restore so the final solve isn't crippled. - _restore_time_limit(nlp, original_time_limit) - end + pins = best_result === nothing ? [] : + _commit_combination(model, best_result) + # The loading solve is a pinned formality, so an exhausted loop + # budget must not starve it: restore the model's own time limit. + _restore_time_limit(nlp, original_time_limit) _report_loa_gap(best_objective, best_result, master_bound, sense_token, converged) + # The committed model (binaries relaxed and fixed) is not a clean + # reformulation, so any later reformulation method must rebuild. _set_solution_method(model, method) - _set_ready_to_optimize(model, true) + _set_ready_to_optimize(model, false) + # Loading solve: the incumbent is fully pinned, so this is a + # formality that makes JuMP solution queries on `model` return it. + JuMP.optimize!(model; ignore_optimize_hook = true, kwargs...) + if best_result !== nothing && !JuMP.has_values(nlp) + # Observed with presolve-off Gurobi: the tiny pin boxes can be + # rejected numerically. Drop them and load by re-solving warm + # at the fixed combination instead. + _remove_pins(model, pins) + for (variable, value) in best_result.linearization_point + JuMP.set_start_value(variable, value) + end + JuMP.optimize!(model; ignore_optimize_hook = true, kwargs...) + end return end @@ -170,11 +191,6 @@ function _report_loa_gap( return end -# Error fallback for unsupported model types. -function reformulate_model(::M, ::LOA) where {M} - error("reformulate_model not implemented for model type `$(M)` with LOA.") -end - ################################################################################ # SET-COVERING INITIALIZATION ################################################################################ @@ -236,26 +252,22 @@ _underlying_value(::JuMP.GenericAffExpr, active::Bool) = !active """ build_loa_problem( model::JuMP.AbstractModel, - method::LOA, - [disaggregation_map = nothing] + method::LOA )::_LOAProblem Build the problem the LOA loop operates on from `model` (already reformulated by the LOA inner method): the NLP subproblem, the binary variables backing the indicators, the nonlinear disjunct `(binary_ref, function, set)` triples, the nonlinear global -`(function, set)` pairs, and the Hull disaggregation map. The NLP is -`model` itself; the InfiniteOpt extension overloads this to build the -problem from the transcribed backend instead. +`(function, set)` pairs, and the Hull disaggregation map recorded in +the GDP data during reformulation. The NLP is `model` itself; the +InfiniteOpt extension overloads this to build the problem from the +transcribed backend instead. ## Returns - `_LOAProblem`: the problem. """ -function build_loa_problem( - model::JuMP.AbstractModel, - method::LOA, - disaggregation_map = nothing - ) +function build_loa_problem(model::JuMP.AbstractModel, method::LOA) V = JuMP.variable_ref_type(typeof(model)) T = JuMP.value_type(typeof(model)) binary_map = _indicator_to_binary(model) @@ -296,27 +308,15 @@ function build_loa_problem( end end - binary_disaggregations = disaggregation_map === nothing ? nothing : + binary_disaggregations = Dict{Tuple{V, Union{V, JuMP.GenericAffExpr{T, V}}}, V}( (variable, binary_map[indicator]) => disaggregated - for ((variable, indicator), disaggregated) in disaggregation_map) + for ((variable, indicator), disaggregated) + in _disaggregation_map(model)) return _LOAProblem(model, binaries, disjunct_constraints, global_constraints, binary_disaggregations) end -# The inner reformulation method LOA runs, plus the disaggregation map -# to collect (Big-M / MBM need none). For Hull, return a map-carrying -# copy so the reformulation records its `(variable, indicator) -> -# disaggregated variable` map into a fresh LOA-owned `Dict`. -_loa_inner_method(::JuMP.AbstractModel, inner::Union{BigM, MBM}) = - (inner, nothing) -function _loa_inner_method(model::JuMP.AbstractModel, inner::Hull) - V = JuMP.variable_ref_type(typeof(model)) - disaggregations = Dict{Tuple{V, LogicalVariableRef{typeof(model)}}, V}() - return (Hull(inner.value; disaggregation_map = disaggregations), - disaggregations) -end - # Relax the binaries to continuous in [0, 1]; each NLP solve then # overwrites their fixes in place. function _relax_binaries(problem::_LOAProblem) @@ -524,16 +524,40 @@ function _set_nlp_warm_start(previous) return end -# Finalize the NLP with the LOA-optimal combination: fix the -# binaries at the committed values (in place; the NLP is already -# permanently relaxed) and warm-start from the linearization point. -# After this, the model is no longer in a state suitable for re-running -# with a different `gdp_method`. -function _commit_combination(result) +# Finalize the NLP with the LOA incumbent: fix the binaries at the +# committed combination and pin each linearization-point variable +# inside a tight interval, so the loading solve only polishes the +# incumbent instead of re-solving the NLP. Pins are interval rows +# rather than `JuMP.fix` or equalities: variable bounds survive, the +# rows stay out of Ipopt's degrees-of-freedom count (equality pins can +# exceed it and are rejected as INVALID_MODEL), and the box is kept +# comfortably wider than typical solver feasibility tolerances. On a +# GDP model they register as reformulation constraints and the next +# reformulation deletes them; on a transcription backend the next +# rebuild discards them. Returns the pins for the loading-solve +# fallback. +function _commit_combination(model::JuMP.AbstractModel, result) _fix_combination(result.combination) + pins = [] for (variable, value) in result.linearization_point - JuMP.set_start_value(variable, value) + owner = JuMP.owner_model(variable) + tol = 1e-5 * max(1.0, abs(value)) + pin = JuMP.@constraint(owner, + value - tol <= variable <= value + tol) + owner === model && push!(_reformulation_constraints(model), pin) + push!(pins, pin) + end + return pins +end + +# Loading-solve fallback: delete the pins (and untrack them so the next +# clear does not double-delete) ahead of a warm re-solve. +function _remove_pins(model::JuMP.AbstractModel, pins::Vector) + pin_set = Set(pins) + for pin in pins + JuMP.delete(JuMP.owner_model(pin), pin) end + filter!(c -> !(c in pin_set), _reformulation_constraints(model)) return end diff --git a/src/model.jl b/src/model.jl index 0f26a4f0..b5397880 100644 --- a/src/model.jl +++ b/src/model.jl @@ -39,17 +39,38 @@ end # Define what should happen to solve a GDPModel # See https://github.com/jump-dev/JuMP.jl/blob/9ea1df38fd320f864ab4c93c78631d0f15939c0b/src/JuMP.jl#L718-L745 +# The kwarg form is the installed hook; it redispatches on the method so +# solution algorithms (e.g. LOA) can own their entire solve. function _optimize_hook( - model::JuMP.AbstractModel; + model::JuMP.AbstractModel; gdp_method::AbstractSolutionMethod = BigM(), kwargs... - ) # can add more kwargs if wanted - if !_ready_to_optimize(model) || _solution_method(model) != gdp_method - reformulate_model(model, gdp_method) + ) + return _optimize_hook(model, gdp_method; kwargs...) +end + +# Reformulation methods: reformulate if stale, then solve in place. +function _optimize_hook( + model::JuMP.AbstractModel, + method::AbstractReformulationMethod; + kwargs... + ) + if !_ready_to_optimize(model) || _solution_method(model) != method + reformulate_model(model, method) end return JuMP.optimize!(model; ignore_optimize_hook = true, kwargs...) end +# Fallback for solution algorithms without an optimize hook. +function _optimize_hook( + model::JuMP.AbstractModel, + method::AbstractSolutionMethod; + kwargs... + ) + error("Solution method `$(nameof(typeof(method)))` is not " * + "supported; LOA is currently the only solution algorithm.") +end + ################################################################################ # GDP DATA ################################################################################ @@ -84,6 +105,7 @@ _constraint_to_indicator(model::JuMP.AbstractModel) = gdp_data(model).constraint _reformulation_variables(model::JuMP.AbstractModel) = gdp_data(model).reformulation_variables _reformulation_constraints(model::JuMP.AbstractModel) = gdp_data(model).reformulation_constraints _variable_bounds(model::JuMP.AbstractModel) = gdp_data(model).variable_bounds +_disaggregation_map(model::JuMP.AbstractModel) = gdp_data(model).disaggregation_map _solution_method(model::JuMP.AbstractModel) = gdp_data(model).solution_method # Get the current solution method _ready_to_optimize(model::JuMP.AbstractModel) = gdp_data(model).ready_to_optimize # Determine if the model is ready to call `optimize!` without a optimize hook diff --git a/src/reformulate.jl b/src/reformulate.jl index bb08f2b8..39b50b36 100644 --- a/src/reformulate.jl +++ b/src/reformulate.jl @@ -2,12 +2,20 @@ # REFORMULATE ################################################################################ """ - reformulate_model(model::JuMP.AbstractModel, method::AbstractSolutionMethod = BigM()) + reformulate_model( + model::JuMP.AbstractModel, + method::AbstractReformulationMethod = BigM() + ) Reformulate a `GDPModel` using the specified `method`. Prior to reformulation, -all previous reformulation variables and constraints are deleted. +all previous reformulation variables and constraints are deleted. Solution +algorithms such as [`LOA`](@ref) are not reformulation methods and are run +via `optimize!(model, gdp_method = ...)` instead. """ -function reformulate_model(model::JuMP.AbstractModel, method::AbstractSolutionMethod = BigM()) +function reformulate_model( + model::JuMP.AbstractModel, + method::AbstractReformulationMethod = BigM() + ) #clear all previous reformulations _clear_reformulations(model) #reformulate @@ -25,6 +33,7 @@ function _clear_reformulations(model::JuMP.AbstractModel) empty!(gdp_data(model).reformulation_constraints) empty!(gdp_data(model).reformulation_variables) empty!(gdp_data(model).variable_bounds) + empty!(gdp_data(model).disaggregation_map) _restore_logical_binaries(model) return end diff --git a/src/utilities.jl b/src/utilities.jl index 2af2cc12..2d66e377 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -325,6 +325,7 @@ function copy_gdp_data( # - indicator_to_constraints # - constraint_to_indicator # - variable_bounds + # - disaggregation_map # SINGLE VALUES (copy directly) # - solution_method # - ready_to_optimize @@ -433,6 +434,11 @@ function copy_gdp_data( new_gdp.variable_bounds[var_map[v]] = bounds end + # Copying the disaggregation map + for ((v, lv_ref), dv) in old_gdp.disaggregation_map + new_gdp.disaggregation_map[(var_map[v], lv_map[lv_ref])] = var_map[dv] + end + # Copying solution method and ready to optimize new_gdp.solution_method = old_gdp.solution_method new_gdp.ready_to_optimize = old_gdp.ready_to_optimize diff --git a/test/constraints/cuttingplanes.jl b/test/constraints/cuttingplanes.jl index 9f3a7931..04ecba59 100644 --- a/test/constraints/cuttingplanes.jl +++ b/test/constraints/cuttingplanes.jl @@ -5,15 +5,13 @@ function test_CuttingPlanes_datatype() @test method.optimizer == HiGHS.Optimizer @test method.max_iter == 3 @test method.seperation_tolerance == 1e-6 - @test method.final_reform_method isa BigM @test method.M_value == 1e9 method = CuttingPlanes(HiGHS.Optimizer;max_iter=10, - seperation_tolerance=1e-4, final_reform_method=Indicator(), M_value=1e6 + seperation_tolerance=1e-4, M_value=1e6 ) @test method.max_iter == 10 @test method.seperation_tolerance == 1e-4 - @test method.final_reform_method isa Indicator @test method.M_value == 1e6 end diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index a6ca14bc..0d1d151b 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -119,7 +119,7 @@ function test_no_good_cut() @test JuMP.normalized_rhs(cref) == 0.0 end -function test_loa_reformulate_simple() +function test_loa_hook_simple() model = GDPModel(HiGHS.Optimizer) set_silent(model) @variable(model, 0 <= x <= 10) @@ -130,11 +130,12 @@ function test_loa_reformulate_simple() @objective(model, Max, x) method = LOA(HiGHS.Optimizer) - DP.reformulate_model(model, method) + optimize!(model, gdp_method = method) - @test DP._ready_to_optimize(model) - # The committed model solves to the LOA incumbent: x = 7 via Y[2]. - JuMP.optimize!(model, ignore_optimize_hook = true) + # The committed model is not a clean reformulation. + @test !DP._ready_to_optimize(model) + @test DP._solution_method(model) === method + # The hook's loading solve returns the LOA incumbent: x = 7 via Y[2]. @test objective_value(model) ≈ 7.0 atol = 1e-6 @test value(x) ≈ 7.0 atol = 1e-6 end @@ -199,9 +200,11 @@ function test_loa_solve_two_disjunctions() @test objective_value(model) ≈ 12.0 atol=1e-4 end -function test_loa_error_fallback() +function test_loa_not_reformulation() + # LOA is a solution algorithm: reformulate_model rejects it. method = LOA(HiGHS.Optimizer) - @test_throws ErrorException DP.reformulate_model(42, method) + model = GDPModel(HiGHS.Optimizer) + @test_throws MethodError DP.reformulate_model(model, method) end function test_loa_nonlinear_global() @@ -338,9 +341,8 @@ function test_loa_limit_hit_report() @objective(model, Min, x - z) method = LOA(HiGHS.Optimizer; max_iter = 1) @test_logs (:info, r"limit hit") match_mode = :any begin - DP.reformulate_model(model, method) + optimize!(model, gdp_method = method) end - JuMP.optimize!(model, ignore_optimize_hook = true) @test objective_value(model) ≈ -10.0 atol = 1e-4 end @@ -555,11 +557,10 @@ end function test_loa_time_limits() # Exercise the wall-clock budget paths. A finite `time_limit` # (overall cap) drives `_cap_remaining_time` during the subproblem - # solves and the final-solve budget reset; a finite - # `iteration_time_limit` (loop-only budget) drives the post-loop - # time-limit restore from no prior limit (the `::Nothing` path). - # Both limits are generous: they govern the path taken, not the - # result. + # solves; a finite `iteration_time_limit` (loop-only budget) covers + # the post-loop time-limit restore from no prior limit (the + # `::Nothing` path). Both limits are generous: they govern the + # path taken, not the result. function build_model() model = GDPModel(HiGHS.Optimizer) set_silent(model) @@ -603,9 +604,9 @@ function test_loa_no_feasible_incumbent() @objective(model, Max, x) method = LOA(HiGHS.Optimizer) @test_logs (:warn, r"no feasible incumbent") match_mode = :any begin - DP.reformulate_model(model, method) + optimize!(model, gdp_method = method) end - @test DP._ready_to_optimize(model) + @test !DP._ready_to_optimize(model) end function test_loa_hull_linear() @@ -720,9 +721,9 @@ end function test_loa_hull_nested_disaggregation_map() # The inner disjunction (over y) is a disjunct of the outer (gated by - # z[1]). Hull's nested redispatch must propagate the LOA - # disaggregation map so the inner disaggregations are recorded, else - # LOA-Hull emits the nested cut on the aggregated variable. + # z[1]). Hull's nested redispatch must record the inner + # disaggregations in the GDP data map, else LOA-Hull emits the + # nested cut on the aggregated variable. model = GDPModel() @variable(model, 0 <= x <= 10) @variable(model, y[1:2], Logical) @@ -732,8 +733,8 @@ function test_loa_hull_nested_disaggregation_map() @disjunction(model, y, Disjunct(z[1])) @constraint(model, x <= 1, Disjunct(z[2])) @disjunction(model, z) - disaggregations = Dict{Any, Any}() - DP.reformulate_model(model, Hull(; disaggregation_map = disaggregations)) + DP.reformulate_model(model, Hull()) + disaggregations = DP._disaggregation_map(model) @test haskey(disaggregations, (x, y[1])) @test haskey(disaggregations, (x, y[2])) end @@ -772,11 +773,11 @@ end test_oa_cut_terms() test_is_linear_F() test_no_good_cut() - test_loa_reformulate_simple() + test_loa_hook_simple() test_loa_solve_simple() test_loa_solve_simple_with_mbm() test_loa_solve_two_disjunctions() - test_loa_error_fallback() + test_loa_not_reformulation() test_loa_nonlinear_global() test_loa_nonlinear_equality_global() test_loa_nonlinear_equality_disjunct() diff --git a/test/solve.jl b/test/solve.jl index c53cf04a..7c5df5a7 100644 --- a/test/solve.jl +++ b/test/solve.jl @@ -175,6 +175,12 @@ function test_generic_model(m) # TODO add meaningful tests to check the constraints/variables end +function test_unsupported_solution_method() + m = GDPModel(HiGHS.Optimizer) + @test_throws ErrorException optimize!( + m, gdp_method = DummySolutionMethod()) +end + @testset "Solve Linear GDP" begin test_linear_gdp_example(GDPModel(HiGHS.Optimizer)) test_linear_gdp_example(GDPModel(HiGHS.Optimizer), true) @@ -184,4 +190,5 @@ end ) test_quadratic_gdp_example() test_generic_model(GDPModel{Float32}(mockoptimizer)) + test_unsupported_solution_method() end diff --git a/test/utilities.jl b/test/utilities.jl index 463bbb88..aa12a1cd 100644 --- a/test/utilities.jl +++ b/test/utilities.jl @@ -59,6 +59,7 @@ end # Prepare helpful test types struct BadVarRef <: JuMP.AbstractVariableRef end struct DummyReformulation <: AbstractReformulationMethod end +struct DummySolutionMethod <: AbstractSolutionMethod end # Define types/methods to test using DP with extension models struct MyVar{I} <: JuMP.AbstractVariable From 76e1f5a67929380e83b348ffe5226971961705ec Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Wed, 15 Jul 2026 12:15:39 -0400 Subject: [PATCH 70/72] MockOptimizer workflow --- ext/InfiniteDisjunctiveProgramming.jl | 19 ++++ src/datatypes.jl | 4 + src/loa.jl | 135 ++++++++++++++++---------- src/model.jl | 11 +++ src/reformulate.jl | 4 +- test/constraints/loa.jl | 53 +++++++++- 6 files changed, 170 insertions(+), 56 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index facbc226..34291131 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -490,4 +490,23 @@ function DP.build_loa_problem( _transcribed_disaggregation_map(model)) end +# Inject the incumbent into the transcription backend and mark it ready so +# InfiniteOpt value/objective queries return it. No binary restore (the +# relaxed binaries are backend-transcribed and discarded on rebuild) and no +# solver stash (a later reformulation mutates the InfiniteModel, invalidates +# the backend, and rebuilds it fresh with the real optimizer). +function DP._load_solution( + model::InfiniteOpt.InfiniteModel, + problem::DP._LOAProblem, + best_result, + method::DP.LOA, + solve_time::Real + ) + primal = DP._incumbent_primal(problem, best_result) + DP._inject_solution(problem.nlp, primal, best_result.objective, + best_result.status, solve_time) + InfiniteOpt.set_transformation_backend_ready(model, true) + return +end + end diff --git a/src/datatypes.jl b/src/datatypes.jl index b5c57619..99b25501 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -721,6 +721,9 @@ mutable struct GDPData{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, C # Solution data solution_method::Union{Nothing, AbstractSolutionMethod} ready_to_optimize::Bool + # Real solver stashed by an LOA solve so a later reformulation + # re-solve can replace the injected solution optimizer. + loa_solver::Any # Default constructor function GDPData{M, V, C}() where {M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, C} @@ -739,6 +742,7 @@ mutable struct GDPData{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, C Vector{C}(), nothing, false, + nothing, ) end end diff --git a/src/loa.jl b/src/loa.jl index 449ca901..5cd102cf 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -26,8 +26,8 @@ end # MAIN LOOP ################################################################################ # LOA optimize hook: build the problem, seed with set-covering -# combinations, iterate the master/NLP loop, commit the best -# combination, and load it with a final direct solve. +# combinations, iterate the master/NLP loop, then load the best +# combination into the model by injecting its solution. function _optimize_hook(model::JuMP.AbstractModel, method::LOA; kwargs...) _clear_reformulations(model) reformulate_model(model, method.inner_method) @@ -137,30 +137,17 @@ function _optimize_hook(model::JuMP.AbstractModel, method::LOA; kwargs...) _log_loa_progress(t_start, best_objective, master_bound) end - pins = best_result === nothing ? [] : - _commit_combination(model, best_result) - # The loading solve is a pinned formality, so an exhausted loop - # budget must not starve it: restore the model's own time limit. + # Clear the loop's leftover time-limit cap before handing the model + # back so it does not leak into a later re-solve. _restore_time_limit(nlp, original_time_limit) _report_loa_gap(best_objective, best_result, master_bound, sense_token, converged) - # The committed model (binaries relaxed and fixed) is not a clean - # reformulation, so any later reformulation method must rebuild. + # The injected solution is not a clean reformulation, so any later + # reformulation method must rebuild. _set_solution_method(model, method) _set_ready_to_optimize(model, false) - # Loading solve: the incumbent is fully pinned, so this is a - # formality that makes JuMP solution queries on `model` return it. - JuMP.optimize!(model; ignore_optimize_hook = true, kwargs...) - if best_result !== nothing && !JuMP.has_values(nlp) - # Observed with presolve-off Gurobi: the tiny pin boxes can be - # rejected numerically. Drop them and load by re-solving warm - # at the fixed combination instead. - _remove_pins(model, pins) - for (variable, value) in best_result.linearization_point - JuMP.set_start_value(variable, value) - end - JuMP.optimize!(model; ignore_optimize_hook = true, kwargs...) - end + best_result === nothing || + _load_solution(model, problem, best_result, method, time() - t_start) return end @@ -441,7 +428,8 @@ function _solve_nlp( if JuMP.is_solved_and_feasible(nlp) return (combination = combination, linearization_point = _extract_solution(nlp), - objective = JuMP.objective_value(nlp), feasible = true) + objective = JuMP.objective_value(nlp), feasible = true, + status = JuMP.termination_status(nlp)) end # Primary NLP infeasible — try NLPF (unless disabled, in which case @@ -524,40 +512,83 @@ function _set_nlp_warm_start(previous) return end -# Finalize the NLP with the LOA incumbent: fix the binaries at the -# committed combination and pin each linearization-point variable -# inside a tight interval, so the loading solve only polishes the -# incumbent instead of re-solving the NLP. Pins are interval rows -# rather than `JuMP.fix` or equalities: variable bounds survive, the -# rows stay out of Ipopt's degrees-of-freedom count (equality pins can -# exceed it and are rejected as INVALID_MODEL), and the box is kept -# comfortably wider than typical solver feasibility tolerances. On a -# GDP model they register as reformulation constraints and the next -# reformulation deletes them; on a transcription backend the next -# rebuild discards them. Returns the pins for the loading-solve -# fallback. -function _commit_combination(model::JuMP.AbstractModel, result) - _fix_combination(result.combination) - pins = [] - for (variable, value) in result.linearization_point - owner = JuMP.owner_model(variable) - tol = 1e-5 * max(1.0, abs(value)) - pin = JuMP.@constraint(owner, - value - tol <= variable <= value + tol) - owner === model && push!(_reformulation_constraints(model), pin) - push!(pins, pin) +################################################################################ +# LOAD SOLUTION BY INJECTION +################################################################################ +# Complete primal for every NLP variable: the loop's linearization point +# (non-fixed vars), a user fix value for the rest, overlaid with the +# incumbent combination for the binaries (fixed during the loop, so absent +# from the linearization point). +function _incumbent_primal(problem::_LOAProblem, best_result) + nlp = problem.nlp + V = JuMP.variable_ref_type(typeof(nlp)) + T = JuMP.value_type(typeof(nlp)) + primal = Dict{V, T}() + for v in JuMP.all_variables(nlp) + if haskey(best_result.linearization_point, v) + primal[v] = best_result.linearization_point[v] + elseif JuMP.is_fixed(v) + primal[v] = JuMP.fix_value(v) + else + primal[v] = zero(T) + end + end + for (binary, active) in best_result.combination + primal[binary] = active ? one(T) : zero(T) + end + return primal +end + +# Attach a MockOptimizer preloaded with the incumbent so native JuMP +# queries (value, objective_value, termination_status) return it with no +# solve and no added constraints. `is_model_dirty = false` unlocks the +# result queries (JuMP otherwise guards them and throws OptimizeNotCalled). +function _inject_solution( + nlp::JuMP.AbstractModel, + primal::AbstractDict, + objective, + status::_MOI.TerminationStatusCode, + solve_time::Real + ) + T = JuMP.value_type(typeof(nlp)) + JuMP.set_optimizer(nlp, () -> _MOI.Utilities.MockOptimizer( + _MOI.Utilities.UniversalFallback(_MOI.Utilities.Model{T}())); + add_bridges = false) + _MOI.Utilities.attach_optimizer(JuMP.backend(nlp)) + mock = JuMP.unsafe_backend(nlp) + _MOI.set(mock, _MOI.TerminationStatus(), status) + _MOI.set(mock, _MOI.PrimalStatus(), _MOI.FEASIBLE_POINT) + _MOI.set(mock, _MOI.ResultCount(), 1) + _MOI.set(mock, _MOI.ObjectiveValue(), objective) + _MOI.set(mock, _MOI.SolveTimeSec(), Float64(solve_time)) + _MOI.set(mock, _MOI.RawStatusString(), "LOA incumbent (injected)") + for (variable, value) in primal + _MOI.set(mock, _MOI.VariablePrimal(), + JuMP.optimizer_index(variable), value) end - return pins + nlp.is_model_dirty = false + return end -# Loading-solve fallback: delete the pins (and untrack them so the next -# clear does not double-delete) ahead of a warm re-solve. -function _remove_pins(model::JuMP.AbstractModel, pins::Vector) - pin_set = Set(pins) - for pin in pins - JuMP.delete(JuMP.owner_model(pin), pin) +# Finalize a finite GDP: drop the loop's incumbent binary fixes so the +# model carries no leftover fixes, then inject the incumbent. The binaries +# stay relaxed; a later reformulation restores their integrality. +# Dispatchable so extensions (InfiniteOpt) override for their own type. +function _load_solution( + model::JuMP.AbstractModel, + problem::_LOAProblem, + best_result, + method::LOA, + solve_time::Real + ) + V = JuMP.variable_ref_type(typeof(model)) + for (_, bvar) in _indicator_to_binary(model) + bvar isa V && JuMP.is_fixed(bvar) && JuMP.unfix(bvar) end - filter!(c -> !(c in pin_set), _reformulation_constraints(model)) + primal = _incumbent_primal(problem, best_result) + _inject_solution(problem.nlp, primal, best_result.objective, + best_result.status, solve_time) + gdp_data(model).loa_solver = method.nlp_optimizer return end diff --git a/src/model.jl b/src/model.jl index b5397880..225b4805 100644 --- a/src/model.jl +++ b/src/model.jl @@ -55,6 +55,17 @@ function _optimize_hook( method::AbstractReformulationMethod; kwargs... ) + # A prior LOA solve left an injected solution optimizer in the slot; + # swap the real solver back before a genuine re-solve, but only if the + # user has not already set their own optimizer since. + solver = gdp_data(model).loa_solver + if solver !== nothing + backend = JuMP.backend(model) + backend isa _MOI.Utilities.CachingOptimizer && + backend.optimizer isa _MOI.Utilities.MockOptimizer && + JuMP.set_optimizer(model, solver) + gdp_data(model).loa_solver = nothing + end if !_ready_to_optimize(model) || _solution_method(model) != method reformulate_model(model, method) end diff --git a/src/reformulate.jl b/src/reformulate.jl index 39b50b36..1e7f3726 100644 --- a/src/reformulate.jl +++ b/src/reformulate.jl @@ -38,8 +38,8 @@ function _clear_reformulations(model::JuMP.AbstractModel) return end -# An LOA solve leaves the logical binaries relaxed and fixed to its -# incumbent. Restore them to free binaries so a later reformulation of the +# An LOA solve relaxes the logical binaries (and fixes them per +# iteration). Restore them to free binaries so a later reformulation of the # model (or a copy of it) sees the integrality it expects. function _restore_logical_binaries(model::JuMP.AbstractModel) V = JuMP.variable_ref_type(typeof(model)) diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 0d1d151b..32f1f34f 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -132,12 +132,59 @@ function test_loa_hook_simple() method = LOA(HiGHS.Optimizer) optimize!(model, gdp_method = method) - # The committed model is not a clean reformulation. + # LOA marks the model as needing a rebuild before a reformulation solve. @test !DP._ready_to_optimize(model) @test DP._solution_method(model) === method - # The hook's loading solve returns the LOA incumbent: x = 7 via Y[2]. + # The incumbent is injected, not solved: queries return x = 7 via Y[2]. @test objective_value(model) ≈ 7.0 atol = 1e-6 @test value(x) ≈ 7.0 atol = 1e-6 + + # Injection leaves a clean model: the incumbent is loaded with no fixed + # binaries and no pin constraints. + @test !is_fixed(DP._indicator_to_binary(model)[Y[2]]) +end + +function test_loa_reoptimize() + # After an LOA solve the model must re-solve cleanly with an ordinary + # reformulation method (the injected mock optimizer is swapped back). + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + + optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) + @test value(x) ≈ 7.0 atol = 1e-6 + + optimize!(model, gdp_method = Hull()) + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 7.0 atol = 1e-4 +end + +function test_loa_reoptimize_respects_user_optimizer() + # If the user sets their own optimizer after LOA, a re-solve must keep + # it (the stashed solver only restores when the injected mock is still + # attached). Regression: an unconditional restore clobbered the choice. + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + + optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) + set_optimizer(model, HiGHS.Optimizer) + set_attribute(model, "time_limit", 123.0) + set_silent(model) + optimize!(model, gdp_method = BigM()) + # A clobbering restore would rebuild a fresh solver, losing the setting. + @test get_attribute(model, "time_limit") == 123.0 + @test value(x) ≈ 7.0 atol = 1e-6 end function test_loa_solve_simple() @@ -774,6 +821,8 @@ end test_is_linear_F() test_no_good_cut() test_loa_hook_simple() + test_loa_reoptimize() + test_loa_reoptimize_respects_user_optimizer() test_loa_solve_simple() test_loa_solve_simple_with_mbm() test_loa_solve_two_disjunctions() From ec74744431f4da5120442e914e68804b1e8659a4 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Sun, 19 Jul 2026 21:26:14 -0400 Subject: [PATCH 71/72] LOA result cache --- ext/InfiniteDisjunctiveProgramming.jl | 11 +- src/datatypes.jl | 20 +- src/loa.jl | 397 ++++++++++++++++++++------ src/model.jl | 2 +- src/reformulate.jl | 14 - test/constraints/loa.jl | 102 +++++-- test/solve.jl | 2 +- 7 files changed, 424 insertions(+), 124 deletions(-) diff --git a/ext/InfiniteDisjunctiveProgramming.jl b/ext/InfiniteDisjunctiveProgramming.jl index 34291131..7c9e6734 100644 --- a/ext/InfiniteDisjunctiveProgramming.jl +++ b/ext/InfiniteDisjunctiveProgramming.jl @@ -490,7 +490,7 @@ function DP.build_loa_problem( _transcribed_disaggregation_map(model)) end -# Inject the incumbent into the transcription backend and mark it ready so +# Inject the outcome into the transcription backend and mark it ready so # InfiniteOpt value/objective queries return it. No binary restore (the # relaxed binaries are backend-transcribed and discarded on rebuild) and no # solver stash (a later reformulation mutates the InfiniteModel, invalidates @@ -498,13 +498,10 @@ end function DP._load_solution( model::InfiniteOpt.InfiniteModel, problem::DP._LOAProblem, - best_result, - method::DP.LOA, - solve_time::Real + outcome::NamedTuple, + method::DP.LOA ) - primal = DP._incumbent_primal(problem, best_result) - DP._inject_solution(problem.nlp, primal, best_result.objective, - best_result.status, solve_time) + DP._inject_solution(problem.nlp, problem, outcome) InfiniteOpt.set_transformation_backend_ready(model, true) return end diff --git a/src/datatypes.jl b/src/datatypes.jl index 99b25501..ae9f29fc 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -576,7 +576,14 @@ iteration) and a master MILP accumulating OA and no-good cuts. LOA is a solution algorithm, not a reformulation: it is invoked with `optimize!(model, gdp_method = LOA(...))`, always re-runs on `optimize!`, and is not accepted by [`reformulate_model`](@ref). After the run the -incumbent is loaded into `model`, so JuMP solution queries work directly. +results are loaded into `model`, so the standard JuMP queries answer +directly: `termination_status` reports the LOA-level outcome +(`LOCALLY_SOLVED` on convergence or combination exhaustion, the binding +limit otherwise), `objective_bound` and `relative_gap` report the master +bound, `raw_status` carries the run summary, and every feasible +combination visited is exposed as a solution-pool result +(`value(x; result = k)`, best first). Duals are not available, matching +other MI(N)LP solvers. ## Fields - `nlp_optimizer::O`: solver for the primary NLP. @@ -684,6 +691,17 @@ struct _LOAMaster{M <: JuMP.AbstractModel, VM, OF, AO, DG} disaggregator::DG end +# The optimizer left in a model's backend slot after an LOA solve: a +# `MockOptimizer` preloaded with the run's results so every standard +# JuMP query answers with no solve, plus the composed solver name (the +# mock's own is hardcoded to "Mock"). `model.jl` detects this type to +# swap the real solver back before a later reformulation solve. +struct _LOAResultCache{O <: _MOI.Utilities.MockOptimizer} <: + _MOI.AbstractOptimizer + mock::O + name::String +end + ################################################################################ # GDP Data ################################################################################ diff --git a/src/loa.jl b/src/loa.jl index 5cd102cf..b5ad1a4f 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -13,6 +13,9 @@ _is_better(::Val{_MOI.MIN_SENSE}, new, best) = new < best _is_better(::Val{_MOI.MAX_SENSE}, new, best) = new > best _gap(::Val{_MOI.MIN_SENSE}, best, bound) = best - bound _gap(::Val{_MOI.MAX_SENSE}, best, bound) = bound - best +# A valid overall bound is the weaker of two partial bounds. +_loosest_bound(::Val{_MOI.MIN_SENSE}, best, bound) = min(best, bound) +_loosest_bound(::Val{_MOI.MAX_SENSE}, best, bound) = max(best, bound) # One progress record per NLP solve, for external convergence traces. function _log_loa_progress(t_start::Float64, best_objective, master_bound) @@ -26,8 +29,8 @@ end # MAIN LOOP ################################################################################ # LOA optimize hook: build the problem, seed with set-covering -# combinations, iterate the master/NLP loop, then load the best -# combination into the model by injecting its solution. +# combinations, iterate the master/NLP loop, then load the results into +# the model by injecting every feasible combination found (best first). function _optimize_hook(model::JuMP.AbstractModel, method::LOA; kwargs...) _clear_reformulations(model) reformulate_model(model, method.inner_method) @@ -50,6 +53,10 @@ function _optimize_hook(model::JuMP.AbstractModel, method::LOA; kwargs...) best_objective = _worst_objective(sense_token) best_result = nothing master_bound = nothing + feasible_results = NamedTuple[] + master_failure = nothing + cuts_added = false + converged = false # Set-covering seed. This mimics Pyomo GDPopt's set-covering # initialization: borrow the master as a covering MILP (only its @@ -77,18 +84,24 @@ function _optimize_hook(model::JuMP.AbstractModel, method::LOA; kwargs...) combination = solved ? _extract_combination(problem, master) : nothing JuMP.set_objective_sense(master.model, master.objective_sense) JuMP.set_objective_function(master.model, oa_objective) - solved || break + if !solved + master_failure = JuMP.termination_status(master.model) + break + end _set_nlp_warm_start(previous_result) result = _solve_nlp(problem, combination, method; deadline = loop_deadline) avoid_combination(master.model, combination, master.variable_map) _add_oa_cuts(problem, master, result, method) - if result.feasible && - _is_better(sense_token, result.objective, best_objective) - best_objective = result.objective - best_result = result + cuts_added = true + if result.feasible + push!(feasible_results, result) + previous_result = result + if _is_better(sense_token, result.objective, best_objective) + best_objective = result.objective + best_result = result + end end - result.feasible && (previous_result = result) _log_loa_progress(t_start, best_objective, master_bound) # A disjunct counts as covered only once it is active in a # feasible NLP. An infeasible combination still contributes its @@ -105,12 +118,14 @@ function _optimize_hook(model::JuMP.AbstractModel, method::LOA; kwargs...) # Master/NLP loop: `alpha_oa` is the bound, the NLP refines the # incumbent. Exit on convergence, infeasible master, max_iter, or time. - converged = false for _ in 1:method.max_iter time() < loop_deadline || break _cap_remaining_time(master.model, loop_deadline) JuMP.optimize!(master.model) - JuMP.is_solved_and_feasible(master.model) || break + if !JuMP.is_solved_and_feasible(master.model) + master_failure = JuMP.termination_status(master.model) + break + end master_bound = JuMP.value(master.alpha_oa) if best_result !== nothing gap = _gap(sense_token, best_objective, master_bound) @@ -128,54 +143,111 @@ function _optimize_hook(model::JuMP.AbstractModel, method::LOA; kwargs...) deadline = loop_deadline) avoid_combination(master.model, combination, master.variable_map) _add_oa_cuts(problem, master, result, method) - if result.feasible && - _is_better(sense_token, result.objective, best_objective) - best_objective = result.objective - best_result = result + cuts_added = true + if result.feasible + push!(feasible_results, result) + previous_result = result + if _is_better(sense_token, result.objective, best_objective) + best_objective = result.objective + best_result = result + end end - result.feasible && (previous_result = result) _log_loa_progress(t_start, best_objective, master_bound) end - # Clear the loop's leftover time-limit cap before handing the model - # back so it does not leak into a later re-solve. _restore_time_limit(nlp, original_time_limit) - _report_loa_gap(best_objective, best_result, master_bound, sense_token, - converged) - # The injected solution is not a clean reformulation, so any later - # reformulation method must rebuild. + status = _loa_termination_status(best_result !== nothing, converged, + master_failure, cuts_added, time() >= loop_deadline) + message = _loa_status_message(status, best_objective, best_result, + master_bound, sense_token) + best_result === nothing ? (@warn message) : (@info message) + # Must be read before the injection replaces the NLP's optimizer. + solver = "LOA(" * string(nameof(typeof(method.inner_method))) * + ", nlp = " * JuMP.solver_name(nlp) * + ", master = " * JuMP.solver_name(master.model) * ")" + sort!(feasible_results; by = result -> result.objective, + rev = master.objective_sense == _MOI.MAX_SENSE) + # No-good cuts mean the master bound only covers unvisited + # combinations, so the reported bound folds in the incumbent + # (`raw_status` above keeps the unfolded master bound). + bound = if best_result === nothing + master_bound + elseif _infeasible_master(master_failure) + best_objective + elseif master_bound === nothing + nothing + else + _loosest_bound(sense_token, best_objective, master_bound) + end + outcome = (results = feasible_results, status = status, + raw_status = message, bound = bound, + solve_time = time() - t_start, solver_name = solver) + # Injection is not a reformulation; a later method must rebuild. _set_solution_method(model, method) _set_ready_to_optimize(model, false) - best_result === nothing || - _load_solution(model, problem, best_result, method, time() - t_start) + _load_solution(model, problem, outcome, method) return end -# Report the final gap. The bound is rigorous only for a convex inner -# problem; on a nonconvex one it can cross the incumbent (negative gap). -function _report_loa_gap( +# Master statuses that report infeasibility. A local claim +# (LOCALLY_INFEASIBLE, e.g. from an NLP-based MILP solver like Juniper) +# counts for combination exhaustion, but only a global certificate +# justifies the global INFEASIBLE report. +_infeasible_master(::Nothing) = false +_infeasible_master(status::_MOI.TerminationStatusCode) = status in + (_MOI.INFEASIBLE, _MOI.INFEASIBLE_OR_UNBOUNDED, + _MOI.LOCALLY_INFEASIBLE) +_proven_infeasible_master(::Nothing) = false +_proven_infeasible_master(status::_MOI.TerminationStatusCode) = status in + (_MOI.INFEASIBLE, _MOI.INFEASIBLE_OR_UNBOUNDED) + +# LOA-level termination status, reported the way a native solver would: +# convergence and combination exhaustion (master infeasible with an +# incumbent in hand) are locally solved (a local NLP solver cannot +# certify global optimality), a master proven infeasible before any cut +# proves the linear relaxation infeasible, exhaustion without an +# incumbent is only a local claim, and otherwise the binding limit is +# reported (`OTHER_LIMIT` for a master solve that failed without an +# infeasibility status, e.g. on its own time cap). +function _loa_termination_status( + found_incumbent::Bool, + converged::Bool, + master_failure::Union{Nothing, _MOI.TerminationStatusCode}, + cuts_added::Bool, + timed_out::Bool + ) + (converged || (_infeasible_master(master_failure) && found_incumbent)) && + return _MOI.LOCALLY_SOLVED + _proven_infeasible_master(master_failure) && !cuts_added && + return _MOI.INFEASIBLE + _infeasible_master(master_failure) && return _MOI.LOCALLY_INFEASIBLE + timed_out && return _MOI.TIME_LIMIT + master_failure === nothing || return _MOI.OTHER_LIMIT + return _MOI.ITERATION_LIMIT +end + +# Human-readable run summary: logged at the end of the hook and stored +# as the model's `RawStatusString`. The bound is rigorous only for a +# convex inner problem; on a nonconvex one it can cross the incumbent +# (negative gap). +function _loa_status_message( + status::_MOI.TerminationStatusCode, best_objective::Real, best_result, master_bound, - sense_token::Val, - converged::Bool + sense_token::Val ) - if best_result === nothing - @warn "LOA finished: no feasible incumbent found." - return - end - if master_bound === nothing - @info "LOA finished [no bound]: incumbent $best_objective " * + best_result === nothing && + return "LOA finished [$status]: no feasible incumbent found." + master_bound === nothing && + return "LOA finished [$status]: incumbent $best_objective " * "(master produced no bound; best seed kept)." - return - end gap = _gap(sense_token, best_objective, master_bound) relative = abs(best_objective) > 1e-10 ? gap / abs(best_objective) : gap - label = converged ? "converged" : "limit hit" - @info "LOA finished [$label]: incumbent $best_objective, master " * + label = status == _MOI.LOCALLY_SOLVED ? "converged" : "limit hit" + return "LOA finished [$label]: incumbent $best_objective, master " * "bound $master_bound, gap $gap (relative $relative)." - return end ################################################################################ @@ -315,6 +387,21 @@ function _relax_binaries(problem::_LOAProblem) return end +# Undo `_relax_binaries` and the loop's last combination fix: drop the +# fix and the relaxation bounds, then restore integrality so the model +# is handed back with its binaries exactly as the reformulation created +# them. (A fixed binary carries no bounds — `fix(; force = true)` +# deleted them — while an unfixed one still carries the relaxation's.) +function _restore_binaries(problem::_LOAProblem) + for binary in problem.binaries + JuMP.is_fixed(binary) && JuMP.unfix(binary) + JuMP.has_lower_bound(binary) && JuMP.delete_lower_bound(binary) + JuMP.has_upper_bound(binary) && JuMP.delete_upper_bound(binary) + JuMP.set_binary(binary) + end + return +end + ################################################################################ # MASTER CONSTRUCTION ################################################################################ @@ -428,8 +515,7 @@ function _solve_nlp( if JuMP.is_solved_and_feasible(nlp) return (combination = combination, linearization_point = _extract_solution(nlp), - objective = JuMP.objective_value(nlp), feasible = true, - status = JuMP.termination_status(nlp)) + objective = JuMP.objective_value(nlp), feasible = true) end # Primary NLP infeasible — try NLPF (unless disabled, in which case @@ -512,82 +598,225 @@ function _set_nlp_warm_start(previous) return end +################################################################################ +# LOA RESULT CACHE (MOI LAYER) +################################################################################ +# `_LOAResultCache` delegates the MOI interface to its inner mock. Only +# `SolverName` (the mock hardcodes "Mock") and the bound attributes (a +# raw `KeyError` from the mock's generic storage when the master +# produced no bound) are intercepted. +_MOI.is_empty(cache::_LOAResultCache) = _MOI.is_empty(cache.mock) +_MOI.empty!(cache::_LOAResultCache) = _MOI.empty!(cache.mock) +_MOI.optimize!(cache::_LOAResultCache) = _MOI.optimize!(cache.mock) +_MOI.copy_to(cache::_LOAResultCache, src::_MOI.ModelLike) = + _MOI.copy_to(cache.mock, src) +_MOI.add_variable(cache::_LOAResultCache) = _MOI.add_variable(cache.mock) +function _MOI.add_constraint( + cache::_LOAResultCache, + func::_MOI.AbstractFunction, + set::_MOI.AbstractSet + ) + return _MOI.add_constraint(cache.mock, func, set) +end +_MOI.delete(cache::_LOAResultCache, index::_MOI.Index) = + _MOI.delete(cache.mock, index) +_MOI.is_valid(cache::_LOAResultCache, index::_MOI.Index) = + _MOI.is_valid(cache.mock, index) +function _MOI.modify( + cache::_LOAResultCache, + ci::_MOI.ConstraintIndex, + change::_MOI.AbstractFunctionModification + ) + return _MOI.modify(cache.mock, ci, change) +end +function _MOI.modify( + cache::_LOAResultCache, + attr::_MOI.ObjectiveFunction, + change::_MOI.AbstractFunctionModification + ) + return _MOI.modify(cache.mock, attr, change) +end +function _MOI.supports_constraint( + cache::_LOAResultCache, + F::Type{<:_MOI.AbstractFunction}, + S::Type{<:_MOI.AbstractSet} + ) + return _MOI.supports_constraint(cache.mock, F, S) +end +function _MOI.supports( + cache::_LOAResultCache, + attr::Union{_MOI.AbstractModelAttribute, + _MOI.AbstractOptimizerAttribute} + ) + return _MOI.supports(cache.mock, attr) +end +function _MOI.supports( + cache::_LOAResultCache, + attr::Union{_MOI.AbstractVariableAttribute, + _MOI.AbstractConstraintAttribute}, + IdxType::Type{<:_MOI.Index} + ) + return _MOI.supports(cache.mock, attr, IdxType) +end +function _MOI.get( + cache::_LOAResultCache, + attr::Union{_MOI.AbstractModelAttribute, + _MOI.AbstractOptimizerAttribute} + ) + return _MOI.get(cache.mock, attr) +end +function _MOI.get( + cache::_LOAResultCache, + attr::_MOI.AbstractVariableAttribute, + index::_MOI.VariableIndex + ) + return _MOI.get(cache.mock, attr, index) +end +function _MOI.get( + cache::_LOAResultCache, + attr::_MOI.AbstractConstraintAttribute, + index::_MOI.ConstraintIndex + ) + return _MOI.get(cache.mock, attr, index) +end +function _MOI.set( + cache::_LOAResultCache, + attr::Union{_MOI.AbstractModelAttribute, + _MOI.AbstractOptimizerAttribute}, + value + ) + return _MOI.set(cache.mock, attr, value) +end +function _MOI.set( + cache::_LOAResultCache, + attr::_MOI.AbstractVariableAttribute, + index::_MOI.VariableIndex, + value + ) + return _MOI.set(cache.mock, attr, index, value) +end +function _MOI.set( + cache::_LOAResultCache, + attr::_MOI.AbstractConstraintAttribute, + index::_MOI.ConstraintIndex, + value + ) + return _MOI.set(cache.mock, attr, index, value) +end +_MOI.get(cache::_LOAResultCache, ::_MOI.SolverName) = cache.name +# A real solver throws `GetAttributeNotAllowed` when no bound exists. +function _MOI.get( + cache::_LOAResultCache, + attr::Union{_MOI.ObjectiveBound, _MOI.RelativeGap} + ) + haskey(cache.mock.model_attributes, attr) || + throw(_MOI.GetAttributeNotAllowed(attr, + "The LOA master did not produce a bound.")) + return _MOI.get(cache.mock, attr) +end +# No duals from an MI(N)LP solve; matches `dual_status = NO_SOLUTION`. +# Both methods are needed: the caching layer swallows the first throw +# and recomputes `DualObjectiveValue` from per-constraint duals. +_MOI.get(cache::_LOAResultCache, attr::_MOI.DualObjectiveValue) = + throw(_MOI.GetAttributeNotAllowed(attr, "LOA does not provide duals.")) +function _MOI.get( + cache::_LOAResultCache, + attr::_MOI.ConstraintDual, + index::_MOI.ConstraintIndex + ) + return throw(_MOI.GetAttributeNotAllowed(attr, + "LOA does not provide duals.")) +end + ################################################################################ # LOAD SOLUTION BY INJECTION ################################################################################ -# Complete primal for every NLP variable: the loop's linearization point -# (non-fixed vars), a user fix value for the rest, overlaid with the -# incumbent combination for the binaries (fixed during the loop, so absent -# from the linearization point). -function _incumbent_primal(problem::_LOAProblem, best_result) +# Complete primal for one pool result. The binaries were fixed during +# the loop (absent from the linearization point), so the combination +# supplies them. +function _result_primal(problem::_LOAProblem, result) nlp = problem.nlp V = JuMP.variable_ref_type(typeof(nlp)) T = JuMP.value_type(typeof(nlp)) primal = Dict{V, T}() for v in JuMP.all_variables(nlp) - if haskey(best_result.linearization_point, v) - primal[v] = best_result.linearization_point[v] + if haskey(result.linearization_point, v) + primal[v] = result.linearization_point[v] elseif JuMP.is_fixed(v) primal[v] = JuMP.fix_value(v) else primal[v] = zero(T) end end - for (binary, active) in best_result.combination + for (binary, active) in result.combination primal[binary] = active ? one(T) : zero(T) end return primal end -# Attach a MockOptimizer preloaded with the incumbent so native JuMP -# queries (value, objective_value, termination_status) return it with no -# solve and no added constraints. `is_model_dirty = false` unlocks the -# result queries (JuMP otherwise guards them and throws OptimizeNotCalled). +# Attach a `_LOAResultCache` preloaded with the outcome so standard +# JuMP result queries answer with no solve (an empty pool injects only +# the statuses). `is_model_dirty = false` re-opens the result queries +# JuMP guards behind OptimizeNotCalled. function _inject_solution( nlp::JuMP.AbstractModel, - primal::AbstractDict, - objective, - status::_MOI.TerminationStatusCode, - solve_time::Real + problem::_LOAProblem, + outcome::NamedTuple ) T = JuMP.value_type(typeof(nlp)) - JuMP.set_optimizer(nlp, () -> _MOI.Utilities.MockOptimizer( - _MOI.Utilities.UniversalFallback(_MOI.Utilities.Model{T}())); - add_bridges = false) + primals = [_result_primal(problem, result) + for result in outcome.results] + # The mock is the one MOI optimizer that accepts written results. + # Serve stored objectives rather than re-evaluating at the primal. + JuMP.set_optimizer(nlp, () -> _LOAResultCache( + _MOI.Utilities.MockOptimizer( + _MOI.Utilities.UniversalFallback(_MOI.Utilities.Model{T}()), + T; eval_objective_value = false), + outcome.solver_name); add_bridges = false) + # Attach copies the model in and creates the `optimizer_index` map. _MOI.Utilities.attach_optimizer(JuMP.backend(nlp)) - mock = JuMP.unsafe_backend(nlp) - _MOI.set(mock, _MOI.TerminationStatus(), status) - _MOI.set(mock, _MOI.PrimalStatus(), _MOI.FEASIBLE_POINT) - _MOI.set(mock, _MOI.ResultCount(), 1) - _MOI.set(mock, _MOI.ObjectiveValue(), objective) - _MOI.set(mock, _MOI.SolveTimeSec(), Float64(solve_time)) - _MOI.set(mock, _MOI.RawStatusString(), "LOA incumbent (injected)") - for (variable, value) in primal - _MOI.set(mock, _MOI.VariablePrimal(), - JuMP.optimizer_index(variable), value) + # Result attributes are read-only through the caching layer, so they + # are written on the inner optimizer directly. + cache = JuMP.unsafe_backend(nlp) + _MOI.set(cache, _MOI.TerminationStatus(), outcome.status) + _MOI.set(cache, _MOI.RawStatusString(), outcome.raw_status) + _MOI.set(cache, _MOI.SolveTimeSec(), Float64(outcome.solve_time)) + _MOI.set(cache, _MOI.ResultCount(), length(primals)) + if outcome.bound !== nothing + _MOI.set(cache, _MOI.ObjectiveBound(), outcome.bound) + isempty(primals) || _MOI.set(cache, _MOI.RelativeGap(), + abs(outcome.bound - outcome.results[1].objective) / + abs(outcome.results[1].objective)) end + indices = Dict(variable => JuMP.optimizer_index(variable) + for variable in JuMP.all_variables(nlp)) + for k in eachindex(primals) + _MOI.set(cache, _MOI.PrimalStatus(k), _MOI.FEASIBLE_POINT) + _MOI.set(cache, _MOI.ObjectiveValue(k), + outcome.results[k].objective) + for (variable, value) in primals[k] + _MOI.set(cache, _MOI.VariablePrimal(k), + indices[variable], value) + end + end + # No public API for this; must stay the last mutation. nlp.is_model_dirty = false return end -# Finalize a finite GDP: drop the loop's incumbent binary fixes so the -# model carries no leftover fixes, then inject the incumbent. The binaries -# stay relaxed; a later reformulation restores their integrality. -# Dispatchable so extensions (InfiniteOpt) override for their own type. +# Finalize a finite GDP. Dispatchable so the InfiniteOpt extension +# overrides it. function _load_solution( model::JuMP.AbstractModel, problem::_LOAProblem, - best_result, - method::LOA, - solve_time::Real + outcome::NamedTuple, + method::LOA ) - V = JuMP.variable_ref_type(typeof(model)) - for (_, bvar) in _indicator_to_binary(model) - bvar isa V && JuMP.is_fixed(bvar) && JuMP.unfix(bvar) - end - primal = _incumbent_primal(problem, best_result) - _inject_solution(problem.nlp, primal, best_result.objective, - best_result.status, solve_time) + # Drop the solver first: an NLP-only solver fails JuMP's ZeroOne + # supports check during the binary restore, even detached. + _MOI.Utilities.drop_optimizer(JuMP.backend(problem.nlp)) + _restore_binaries(problem) + _inject_solution(problem.nlp, problem, outcome) gdp_data(model).loa_solver = method.nlp_optimizer return end diff --git a/src/model.jl b/src/model.jl index 225b4805..aad34afd 100644 --- a/src/model.jl +++ b/src/model.jl @@ -62,7 +62,7 @@ function _optimize_hook( if solver !== nothing backend = JuMP.backend(model) backend isa _MOI.Utilities.CachingOptimizer && - backend.optimizer isa _MOI.Utilities.MockOptimizer && + backend.optimizer isa _LOAResultCache && JuMP.set_optimizer(model, solver) gdp_data(model).loa_solver = nothing end diff --git a/src/reformulate.jl b/src/reformulate.jl index 1e7f3726..264fe294 100644 --- a/src/reformulate.jl +++ b/src/reformulate.jl @@ -34,20 +34,6 @@ function _clear_reformulations(model::JuMP.AbstractModel) empty!(gdp_data(model).reformulation_variables) empty!(gdp_data(model).variable_bounds) empty!(gdp_data(model).disaggregation_map) - _restore_logical_binaries(model) - return -end - -# An LOA solve relaxes the logical binaries (and fixes them per -# iteration). Restore them to free binaries so a later reformulation of the -# model (or a copy of it) sees the integrality it expects. -function _restore_logical_binaries(model::JuMP.AbstractModel) - V = JuMP.variable_ref_type(typeof(model)) - for (_, bvar) in _indicator_to_binary(model) - bvar isa V || continue - JuMP.is_fixed(bvar) && JuMP.unfix(bvar) - JuMP.is_binary(bvar) || JuMP.set_binary(bvar) - end return end diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 32f1f34f..2bdf45f6 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -200,7 +200,7 @@ function test_loa_solve_simple() @objective(model, Max, x) optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) - @test termination_status(model) == MOI.OPTIMAL + @test termination_status(model) == MOI.LOCALLY_SOLVED @test objective_value(model) ≈ 7.0 atol=1e-4 end @@ -219,7 +219,7 @@ function test_loa_solve_simple_with_mbm() optimize!(model, gdp_method = LOA(HiGHS.Optimizer; inner_method = MBM(HiGHS.Optimizer))) - @test termination_status(model) == MOI.OPTIMAL + @test termination_status(model) == MOI.LOCALLY_SOLVED @test objective_value(model) ≈ 7.0 atol=1e-4 end @@ -243,7 +243,7 @@ function test_loa_solve_two_disjunctions() @objective(model, Max, x + z) optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) - @test termination_status(model) == MOI.OPTIMAL + @test termination_status(model) == MOI.LOCALLY_SOLVED @test objective_value(model) ≈ 12.0 atol=1e-4 end @@ -365,7 +365,7 @@ function test_loa_restores_prior_time_limit() optimize!(model, gdp_method = LOA(HiGHS.Optimizer; iteration_time_limit = 60.0, time_limit = Inf)) @test time_limit_sec(model) == 90.0 - @test termination_status(model) == MOI.OPTIMAL + @test termination_status(model) == MOI.LOCALLY_SOLVED @test objective_value(model) ≈ 7.0 atol = 1e-4 end @@ -390,6 +390,8 @@ function test_loa_limit_hit_report() @test_logs (:info, r"limit hit") match_mode = :any begin optimize!(model, gdp_method = method) end + @test termination_status(model) == MOI.ITERATION_LIMIT + @test primal_status(model) == MOI.FEASIBLE_POINT @test objective_value(model) ≈ -10.0 atol = 1e-4 end @@ -595,7 +597,7 @@ function test_loa_iteration_loop() @disjunction(model, W) @objective(model, Min, x - z) optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) - @test termination_status(model) == MOI.OPTIMAL + @test termination_status(model) == MOI.LOCALLY_SOLVED @test objective_value(model) ≈ -10.0 atol = 1e-4 @test value(Y[1]) ≈ 1.0 atol = 1e-6 @test value(W[2]) ≈ 1.0 atol = 1e-6 @@ -622,13 +624,13 @@ function test_loa_time_limits() model = build_model() optimize!(model, gdp_method = LOA(HiGHS.Optimizer; time_limit = 60.0)) - @test termination_status(model) == MOI.OPTIMAL + @test termination_status(model) == MOI.LOCALLY_SOLVED @test objective_value(model) ≈ 7.0 atol = 1e-4 model = build_model() optimize!(model, gdp_method = LOA(HiGHS.Optimizer; iteration_time_limit = 60.0, time_limit = Inf)) - @test termination_status(model) == MOI.OPTIMAL + @test termination_status(model) == MOI.LOCALLY_SOLVED @test objective_value(model) ≈ 7.0 atol = 1e-4 end @@ -639,7 +641,7 @@ function test_loa_no_feasible_incumbent() # returns its infeasible fallback. No seed or loop NLP yields a # feasible incumbent, so `best_result` stays `nothing`, the master # is infeasible (no bound), and LOA exits on the "no feasible - # incumbent" warning from `_report_loa_gap`. + # incumbent" warning from `_loa_status_message`. model = GDPModel(HiGHS.Optimizer) set_silent(model) @variable(model, 0 <= x <= 10) @@ -654,6 +656,16 @@ function test_loa_no_feasible_incumbent() optimize!(model, gdp_method = method) end @test !DP._ready_to_optimize(model) + # The failed run still answers the standard result API the way a + # native solver does: the linear equality makes the very first + # master infeasible (a genuine certificate), the pool is empty, and + # value/bound queries throw the standard errors. + @test termination_status(model) == MOI.INFEASIBLE + @test result_count(model) == 0 + @test !has_values(model) + @test occursin("no feasible incumbent", raw_status(model)) + @test_throws MOI.ResultIndexBoundsError value(x) + @test_throws MOI.GetAttributeNotAllowed objective_bound(model) end function test_loa_hull_linear() @@ -671,7 +683,7 @@ function test_loa_hull_linear() @objective(model, Max, x) optimize!(model, gdp_method = LOA(HiGHS.Optimizer; inner_method = Hull())) - @test termination_status(model) == MOI.OPTIMAL + @test termination_status(model) == MOI.LOCALLY_SOLVED @test objective_value(model) ≈ 7.0 atol = 1e-4 end @@ -692,7 +704,7 @@ function test_loa_hull_two_disjunctions() @objective(model, Max, x + z) optimize!(model, gdp_method = LOA(HiGHS.Optimizer; inner_method = Hull())) - @test termination_status(model) == MOI.OPTIMAL + @test termination_status(model) == MOI.LOCALLY_SOLVED @test objective_value(model) ≈ 12.0 atol = 1e-4 end @@ -786,11 +798,68 @@ function test_loa_hull_nested_disaggregation_map() @test haskey(disaggregations, (x, y[2])) end +function test_loa_result_attributes() + # Post-LOA the model answers the full standard JuMP result API from + # the injected result cache: LOA-level status, composed solver name, + # master bound and gap, wall-clock time, and one solution-pool + # result per feasible combination (best first). Both disjuncts are + # nonlinear so set covering must visit both combinations, making + # the pool size deterministic. + ipopt = optimizer_with_attributes(Ipopt.Optimizer, + "print_level" => 0, "sb" => "yes") + model = GDPModel(ipopt) + set_silent(model) + @variable(model, 0 <= x <= 10) + @constraint(model, cap, x <= 9) + @variable(model, Y[1:2], Logical) + @constraint(model, x^2 <= 9, Disjunct(Y[1])) + @constraint(model, x^2 <= 49, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + optimize!(model, + gdp_method = LOA(ipopt; mip_optimizer = HiGHS.Optimizer)) + + @test termination_status(model) == MOI.LOCALLY_SOLVED + @test primal_status(model) == MOI.FEASIBLE_POINT + @test dual_status(model) == MOI.NO_SOLUTION + @test_throws MOI.GetAttributeNotAllowed dual_objective_value(model) + @test_throws MOI.GetAttributeNotAllowed dual(cap) + @test startswith(solver_name(model), "LOA(BigM") + @test occursin("Ipopt", solver_name(model)) + @test occursin("HiGHS", solver_name(model)) + @test occursin("LOA finished", raw_status(model)) + @test objective_bound(model) ≈ 7.0 atol = 1e-3 + @test relative_gap(model) <= 1e-3 + @test solve_time(model) >= 0.0 + # Solution pool: both combinations feasible, best first. + @test result_count(model) == 2 + @test objective_value(model) ≈ 7.0 atol = 1e-3 + @test value(x) ≈ 7.0 atol = 1e-3 + @test value(Y[2]) ≈ 1.0 atol = 1e-6 + @test primal_status(model; result = 2) == MOI.FEASIBLE_POINT + @test objective_value(model; result = 2) ≈ 3.0 atol = 1e-3 + @test value(x; result = 2) ≈ 3.0 atol = 1e-3 + @test primal_status(model; result = 3) == MOI.NO_SOLUTION + # ConstraintPrimal is served by evaluating at the injected primal. + @test value(cap) ≈ 7.0 atol = 1e-3 + # solution_summary exercises nearly every query in one call. + summary = sprint(show, solution_summary(model)) + @test occursin("LOA(", summary) + @test occursin("LOCALLY_SOLVED", summary) + # The binaries come back binary, unfixed, and bound-free. + bins = [v for (_, v) in DP._indicator_to_binary(model) + if v isa JuMP.VariableRef] + @test all(JuMP.is_binary, bins) + @test all(!JuMP.is_fixed, bins) + @test all(!JuMP.has_lower_bound, bins) +end + function test_reformulate_after_loa_restores_binaries() - # LOA relaxes the logical binaries and fixes them to its incumbent, so - # a later reformulation of the same model must restore binary - # integrality. Regression: a nested disjunction's tight-M looked up a - # relaxed binary's bounds and threw a KeyError. + # LOA relaxes the logical binaries and fixes them per iteration; at + # exit it must hand them back binary and unfixed so a later + # reformulation of the same model works unchanged. Regression: a + # nested disjunction's tight-M looked up a relaxed binary's bounds + # and threw a KeyError. model = GDPModel(HiGHS.Optimizer) set_silent(model) @variable(model, 1 <= x <= 9) @@ -806,10 +875,10 @@ function test_reformulate_after_loa_restores_binaries() optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) bins = [v for (_, v) in DP._indicator_to_binary(model) if v isa JuMP.VariableRef] - @test all(!JuMP.is_binary, bins) # LOA left them relaxed + @test all(JuMP.is_binary, bins) # restored at LOA exit + @test all(!JuMP.is_fixed, bins) @test optimize!(model, gdp_method = BigM()) isa Nothing - @test all(JuMP.is_binary, bins) # restored by reformulation @test termination_status(model) == MOI.OPTIMAL @test objective_value(model) ≈ 9 end @@ -847,5 +916,6 @@ end test_loa_hull_nonlinear_disjunct() test_loa_hull_complement_nonlinear() test_loa_hull_nested_disaggregation_map() + test_loa_result_attributes() test_reformulate_after_loa_restores_binaries() end diff --git a/test/solve.jl b/test/solve.jl index 7c5df5a7..ee89e6e0 100644 --- a/test/solve.jl +++ b/test/solve.jl @@ -64,7 +64,7 @@ function test_linear_gdp_example(m, use_complements = false) @test !value(W[2]) @test optimize!(m, gdp_method = LOA(HiGHS.Optimizer)) isa Nothing - @test termination_status(m) == MOI.OPTIMAL + @test termination_status(m) == MOI.LOCALLY_SOLVED @test objective_value(m) ≈ 11 atol=1e-3 @test value.(x) ≈ [9,2] atol=1e-3 @test !value(Y[1]) From 59fc2f253ebe46b65185c1d8d26654629ce449c5 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Mon, 20 Jul 2026 13:09:59 -0400 Subject: [PATCH 72/72] Restore the optimizer LOA displaces; refuse to re-solve its result cache --- src/datatypes.jl | 6 +-- src/loa.jl | 64 +++++++++++++++++++++++--- src/model.jl | 15 ++----- test/constraints/loa.jl | 99 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 20 deletions(-) diff --git a/src/datatypes.jl b/src/datatypes.jl index ae9f29fc..8bbbce16 100644 --- a/src/datatypes.jl +++ b/src/datatypes.jl @@ -739,9 +739,9 @@ mutable struct GDPData{M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, C # Solution data solution_method::Union{Nothing, AbstractSolutionMethod} ready_to_optimize::Bool - # Real solver stashed by an LOA solve so a later reformulation - # re-solve can replace the injected solution optimizer. - loa_solver::Any + # The optimizer an LOA solve displaced (it solves the NLP in place), + # stashed so a later reformulation re-solve can put it back. + displaced_optimizer::Any # Default constructor function GDPData{M, V, C}() where {M <: JuMP.AbstractModel, V <: JuMP.AbstractVariableRef, C} diff --git a/src/loa.jl b/src/loa.jl index b5ad1a4f..4465e359 100644 --- a/src/loa.jl +++ b/src/loa.jl @@ -42,7 +42,8 @@ function _optimize_hook(model::JuMP.AbstractModel, method::LOA; kwargs...) master = _build_loa_master(problem, method) _relax_binaries(problem) nlp = problem.nlp - JuMP.set_optimizer(nlp, method.nlp_optimizer) + displaced_optimizer = _install_nlp_optimizer(nlp, method.nlp_optimizer, + gdp_data(model).displaced_optimizer) JuMP.set_silent(nlp) t_start = time() overall_deadline = t_start + method.time_limit @@ -181,7 +182,8 @@ function _optimize_hook(model::JuMP.AbstractModel, method::LOA; kwargs...) end outcome = (results = feasible_results, status = status, raw_status = message, bound = bound, - solve_time = time() - t_start, solver_name = solver) + solve_time = time() - t_start, solver_name = solver, + displaced_optimizer = displaced_optimizer) # Injection is not a reformulation; a later method must rebuild. _set_solution_method(model, method) _set_ready_to_optimize(model, false) @@ -402,6 +404,50 @@ function _restore_binaries(problem::_LOAProblem) return end +################################################################################ +# OPTIMIZER HANDOVER +################################################################################ +# Install the NLP solver, returning the optimizer it displaces. LOA +# solves the NLP on the model itself, so that one is the user's own. A +# re-solve displaces the last run's cache instead, so `previous` (the +# optimizer already stashed) carries through untouched. +function _install_nlp_optimizer( + nlp::JuMP.AbstractModel, + nlp_optimizer, + previous + ) + backend = JuMP.backend(nlp) + displaced = if backend isa _MOI.Utilities.CachingOptimizer && + backend.optimizer !== nothing + JuMP.unsafe_backend(nlp) + else + nothing + end + JuMP.set_optimizer(nlp, nlp_optimizer) + return displaced isa _LOAResultCache ? previous : displaced +end + +# Model types without a MOI backend of their own never carry a cache. +function _has_loa_cache(model::JuMP.GenericModel) + backend = JuMP.backend(model) + return backend isa _MOI.Utilities.CachingOptimizer && + backend.optimizer isa _LOAResultCache +end +_has_loa_cache(::JuMP.AbstractModel) = false + +# Restore the displaced optimizer, emptied so the re-solve copies the +# model in. Without one, drop the cache rather than serve its results. +function _restore_displaced_optimizer(model::JuMP.AbstractModel) + displaced = gdp_data(model).displaced_optimizer + if displaced === nothing + _MOI.Utilities.drop_optimizer(JuMP.backend(model)) + else + _MOI.empty!(displaced) + JuMP.set_optimizer(model, () -> displaced) + end + return +end + ################################################################################ # MASTER CONSTRUCTION ################################################################################ @@ -602,12 +648,18 @@ end # LOA RESULT CACHE (MOI LAYER) ################################################################################ # `_LOAResultCache` delegates the MOI interface to its inner mock. Only -# `SolverName` (the mock hardcodes "Mock") and the bound attributes (a +# `SolverName` (the mock hardcodes "Mock"), the bound attributes (a # raw `KeyError` from the mock's generic storage when the master -# produced no bound) are intercepted. +# produced no bound), and `optimize!` are intercepted. _MOI.is_empty(cache::_LOAResultCache) = _MOI.is_empty(cache.mock) _MOI.empty!(cache::_LOAResultCache) = _MOI.empty!(cache.mock) -_MOI.optimize!(cache::_LOAResultCache) = _MOI.optimize!(cache.mock) +# "Solving" the cache would clear JuMP's dirty flag and serve the stored +# results again, so an edited model would report a stale point as fresh. +function _MOI.optimize!(cache::_LOAResultCache) + return error("This model holds the results of an LOA solve and " * + "cannot be re-solved directly (`ignore_optimize_hook = true`). " * + "Call `optimize!(model, gdp_method = ...)` to rebuild and solve.") +end _MOI.copy_to(cache::_LOAResultCache, src::_MOI.ModelLike) = _MOI.copy_to(cache.mock, src) _MOI.add_variable(cache::_LOAResultCache) = _MOI.add_variable(cache.mock) @@ -817,7 +869,7 @@ function _load_solution( _MOI.Utilities.drop_optimizer(JuMP.backend(problem.nlp)) _restore_binaries(problem) _inject_solution(problem.nlp, problem, outcome) - gdp_data(model).loa_solver = method.nlp_optimizer + gdp_data(model).displaced_optimizer = outcome.displaced_optimizer return end diff --git a/src/model.jl b/src/model.jl index aad34afd..c1d23950 100644 --- a/src/model.jl +++ b/src/model.jl @@ -55,17 +55,10 @@ function _optimize_hook( method::AbstractReformulationMethod; kwargs... ) - # A prior LOA solve left an injected solution optimizer in the slot; - # swap the real solver back before a genuine re-solve, but only if the - # user has not already set their own optimizer since. - solver = gdp_data(model).loa_solver - if solver !== nothing - backend = JuMP.backend(model) - backend isa _MOI.Utilities.CachingOptimizer && - backend.optimizer isa _LOAResultCache && - JuMP.set_optimizer(model, solver) - gdp_data(model).loa_solver = nothing - end + # A prior LOA solve left its result cache in the slot; put the + # optimizer it displaced back, unless the user has since set another. + _has_loa_cache(model) && _restore_displaced_optimizer(model) + gdp_data(model).displaced_optimizer = nothing if !_ready_to_optimize(model) || _solution_method(model) != method reformulate_model(model, method) end diff --git a/test/constraints/loa.jl b/test/constraints/loa.jl index 2bdf45f6..4ab77659 100644 --- a/test/constraints/loa.jl +++ b/test/constraints/loa.jl @@ -369,6 +369,101 @@ function test_loa_restores_prior_time_limit() @test objective_value(model) ≈ 7.0 atol = 1e-4 end +function test_loa_restores_displaced_optimizer() + # LOA solves the NLP on the model itself, so it displaces the + # model's own optimizer. A later reformulation solve must get that + # optimizer back, not LOA's NLP solver. + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + set_optimizer_attribute(model, "mip_rel_gap", 0.25) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + displaced = JuMP.unsafe_backend(model) + + optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) + @test DP.gdp_data(model).displaced_optimizer === displaced + + # The reformulation solve restores it, attributes intact. + optimize!(model, gdp_method = BigM()) + @test JuMP.unsafe_backend(model) === displaced + @test get_optimizer_attribute(model, "mip_rel_gap") == 0.25 + @test DP.gdp_data(model).displaced_optimizer === nothing + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 7.0 atol = 1e-4 +end + +function test_loa_repeated_solve_keeps_displaced_optimizer() + # A second LOA solve displaces the first one's result cache, not the + # model's own optimizer. The stash must still hold the latter. + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + displaced = JuMP.unsafe_backend(model) + + optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) + optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) + @test DP.gdp_data(model).displaced_optimizer === displaced + + optimize!(model, gdp_method = BigM()) + @test JuMP.unsafe_backend(model) === displaced + @test termination_status(model) == MOI.OPTIMAL + @test objective_value(model) ≈ 7.0 atol = 1e-4 +end + +function test_loa_no_optimizer_to_restore() + # With no optimizer to put back, the result cache must be dropped + # rather than left to serve its stored results as a fresh solve. + model = GDPModel() + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + + optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) + @test DP.gdp_data(model).displaced_optimizer === nothing + @test objective_value(model) ≈ 7.0 atol = 1e-4 + + @test_throws JuMP.NoOptimizer optimize!(model, gdp_method = BigM()) + set_optimizer(model, HiGHS.Optimizer) + set_silent(model) + optimize!(model, gdp_method = BigM()) + @test objective_value(model) ≈ 7.0 atol = 1e-4 +end + +function test_loa_cache_refuses_direct_solve() + # `ignore_optimize_hook = true` would "solve" the result cache and + # clear JuMP's dirty flag, reporting a stale point as freshly + # solved. It must error instead. + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, Y[1:2], Logical) + @constraint(model, x <= 3, Disjunct(Y[1])) + @constraint(model, x <= 7, Disjunct(Y[2])) + @disjunction(model, Y) + @objective(model, Max, x) + optimize!(model, gdp_method = LOA(HiGHS.Optimizer)) + @test_throws ErrorException optimize!(model; + ignore_optimize_hook = true) + + # Still true once the model has been edited underneath the cache. + @constraint(model, x <= 1) + @test !has_values(model) + @test_throws ErrorException optimize!(model; + ignore_optimize_hook = true) +end + function test_loa_limit_hit_report() # Stop the main loop on `max_iter = 1` after the master has produced # a bound: the report must label the run "limit hit" (not converged), @@ -901,6 +996,10 @@ end test_loa_nonlinear_equality_disjunct() test_loa_nonlinear_interval_disjunct() test_loa_restores_prior_time_limit() + test_loa_restores_displaced_optimizer() + test_loa_repeated_solve_keeps_displaced_optimizer() + test_loa_no_optimizer_to_restore() + test_loa_cache_refuses_direct_solve() test_loa_limit_hit_report() test_loa_complement_indicator_nonlinear_disjunct() test_loa_nlpf_infeasible_disjunct()