From fc0f4e584ad7a646e38375bd1228b913d8ab1d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Wed, 6 May 2026 21:55:47 +0200 Subject: [PATCH 1/6] Implement bridge_cost for CachingOptimizer --- src/Bridges/lazy_bridge_optimizer.jl | 43 +++++++++++++++++-- src/Utilities/cachingoptimizer.jl | 10 +++++ .../General/test_lazy_bridge_optimizer.jl | 40 +++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/Bridges/lazy_bridge_optimizer.jl b/src/Bridges/lazy_bridge_optimizer.jl index 37255c56b9..0b25e67987 100644 --- a/src/Bridges/lazy_bridge_optimizer.jl +++ b/src/Bridges/lazy_bridge_optimizer.jl @@ -255,7 +255,9 @@ function node( @nospecialize(b::LazyBridgeOptimizer), @nospecialize(S::Type{<:MOI.AbstractSet}), ) - # If we support the set, the node is 0. + # If we support the set, the node is 0 unless the inner model reports a + # non-zero `VariableBridgingCost` (which can happen when the inner model is + # itself a bridge optimizer that needs to bridge `S`). if ( S <: MOI.AbstractScalarSet && MOI.supports_add_constrained_variable(b.model, S) @@ -263,7 +265,22 @@ function node( S <: MOI.AbstractVectorSet && MOI.supports_add_constrained_variables(b.model, S) ) - return VariableNode(0) + inner_cost = MOI.get(b.model, MOI.VariableBridgingCost{S}())::Float64 + if iszero(inner_cost) + return VariableNode(0) + end + # The inner model supports `S` but with a non-zero bridging cost. + # Create a leaf node whose distance is `inner_cost` so that bridges + # that emit constrained variables in `S` account for it. + cached = get(b.variable_node, (S,), nothing) + if cached !== nothing + return cached + end + new_node = add_node(b.graph, VariableNode) + b.variable_node[(S,)] = new_node + push!(b.variable_types, (S,)) + b.graph.variable_dist[new_node.index] = inner_cost + return new_node end # If (S,) is stored in .variable_node, we've already added the node # previously. @@ -315,9 +332,27 @@ function node( @nospecialize(F::Type{<:MOI.AbstractFunction}), @nospecialize(S::Type{<:MOI.AbstractSet}), ) - # If we support the constraint type, the node is 0. + # If we support the constraint type, the node is 0 unless the inner model + # reports a non-zero `ConstraintBridgingCost` (which can happen when the + # inner model is itself a bridge optimizer that needs to bridge `F`-in-`S`). if MOI.supports_constraint(b.model, F, S) - return ConstraintNode(0) + inner_cost = + MOI.get(b.model, MOI.ConstraintBridgingCost{F,S}())::Float64 + if iszero(inner_cost) + return ConstraintNode(0) + end + # The inner model supports `F`-in-`S` but with a non-zero bridging cost. + # Create a leaf node whose distance is `inner_cost` so that bridges + # that emit `F`-in-`S` constraints account for it. + cached = get(b.constraint_node, (F, S), nothing) + if cached !== nothing + return cached + end + new_node = add_node(b.graph, ConstraintNode) + b.constraint_node[(F, S)] = new_node + push!(b.constraint_types, (F, S)) + b.graph.constraint_dist[new_node.index] = inner_cost + return new_node end # If (F, S) is stored in .constraint_node, we've already added the node # previously. diff --git a/src/Utilities/cachingoptimizer.jl b/src/Utilities/cachingoptimizer.jl index f3392bbf07..0df5e5863f 100644 --- a/src/Utilities/cachingoptimizer.jl +++ b/src/Utilities/cachingoptimizer.jl @@ -897,6 +897,16 @@ function MOI.get(model::CachingOptimizer, attr::MOI.AbstractModelAttribute) return _get_model_attribute(model, attr) end +function MOI.get( + model::CachingOptimizer, + attr::Union{MOI.VariableBridgingCost,MOI.ConstraintBridgingCost}, +)::Float64 + if state(model) == NO_OPTIMIZER + return MOI.get(model.model_cache, attr) + end + return MOI.get(model.optimizer, attr) +end + function MOI.get( model::CachingOptimizer, attr::MOI.TerminationStatus, diff --git a/test/Bridges/General/test_lazy_bridge_optimizer.jl b/test/Bridges/General/test_lazy_bridge_optimizer.jl index 2d28d77a45..a2b0d90a21 100644 --- a/test/Bridges/General/test_lazy_bridge_optimizer.jl +++ b/test/Bridges/General/test_lazy_bridge_optimizer.jl @@ -2477,6 +2477,46 @@ function test_issue_2870_relative_entropy() return end +MOI.Utilities.@model( + NonnegOnlyModel, + (), + (), + (MOI.Nonnegatives,), + (), + (), + (), + (MOI.VectorOfVariables,), + (MOI.VectorAffineFunction,) +) + +function test_nested_lazy_bridge_optimizer_cost() + # When the inner model is itself a `LazyBridgeOptimizer` that needs to + # bridge a set, the outer `LazyBridgeOptimizer` must take the inner + # bridging cost into account when computing edge costs in its own graph, + # not assume zero cost just because the inner reports `supports`. + T = Float64 + # Solver supporting only `Nonnegatives`-constrained variables. + inner = MOI.Bridges.LazyBridgeOptimizer(NonnegOnlyModel{T}()) + MOI.Bridges.add_bridge( + inner, + MOI.Bridges.Variable.NonposToNonnegBridge{T}, + ) + @test MOI.get(inner, MOI.VariableBridgingCost{MOI.Nonnegatives}()) == 0.0 + @test MOI.get(inner, MOI.VariableBridgingCost{MOI.Nonpositives}()) == 1.0 + cache = MOI.Utilities.CachingOptimizer( + MOI.Utilities.UniversalFallback(MOI.Utilities.Model{T}()), + inner, + ) + @test MOI.get(cache, MOI.VariableBridgingCost{MOI.Nonpositives}()) == 1.0 + outer = MOI.Bridges.LazyBridgeOptimizer(cache) + @test MOI.get(outer, MOI.VariableBridgingCost{MOI.Nonpositives}()) == 1.0 + @test MOI.Bridges.bridging_cost( + outer.graph, + MOI.Bridges.node(outer, MOI.Nonpositives), + ) == 1.0 + return +end + end # module TestBridgesLazyBridgeOptimizer.runtests() From dd39ab789026c035e9c5f898f0d76e815c175220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Sun, 10 May 2026 16:41:09 +0200 Subject: [PATCH 2/6] Fix format --- test/Bridges/General/test_lazy_bridge_optimizer.jl | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/Bridges/General/test_lazy_bridge_optimizer.jl b/test/Bridges/General/test_lazy_bridge_optimizer.jl index a2b0d90a21..3be1f1895d 100644 --- a/test/Bridges/General/test_lazy_bridge_optimizer.jl +++ b/test/Bridges/General/test_lazy_bridge_optimizer.jl @@ -2497,10 +2497,7 @@ function test_nested_lazy_bridge_optimizer_cost() T = Float64 # Solver supporting only `Nonnegatives`-constrained variables. inner = MOI.Bridges.LazyBridgeOptimizer(NonnegOnlyModel{T}()) - MOI.Bridges.add_bridge( - inner, - MOI.Bridges.Variable.NonposToNonnegBridge{T}, - ) + MOI.Bridges.add_bridge(inner, MOI.Bridges.Variable.NonposToNonnegBridge{T}) @test MOI.get(inner, MOI.VariableBridgingCost{MOI.Nonnegatives}()) == 0.0 @test MOI.get(inner, MOI.VariableBridgingCost{MOI.Nonpositives}()) == 1.0 cache = MOI.Utilities.CachingOptimizer( From d79f73a75e2baae9cc545b9fcd7985169124fdd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Mon, 11 May 2026 08:31:33 +0200 Subject: [PATCH 3/6] Add tests --- .../General/test_lazy_bridge_optimizer.jl | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/test/Bridges/General/test_lazy_bridge_optimizer.jl b/test/Bridges/General/test_lazy_bridge_optimizer.jl index 3be1f1895d..bf4b1211dc 100644 --- a/test/Bridges/General/test_lazy_bridge_optimizer.jl +++ b/test/Bridges/General/test_lazy_bridge_optimizer.jl @@ -2495,22 +2495,87 @@ function test_nested_lazy_bridge_optimizer_cost() # bridging cost into account when computing edge costs in its own graph, # not assume zero cost just because the inner reports `supports`. T = Float64 - # Solver supporting only `Nonnegatives`-constrained variables. + # Solver supporting only `Nonnegatives`-constrained variables and + # `VAF`-in-`Nonnegatives` constraints. `Nonpositives` is bridged via + # `NonposToNonneg` in both forms, so the inner reports `supports` for + # `Nonpositives` but with a `1.0` bridging cost. inner = MOI.Bridges.LazyBridgeOptimizer(NonnegOnlyModel{T}()) MOI.Bridges.add_bridge(inner, MOI.Bridges.Variable.NonposToNonnegBridge{T}) + MOI.Bridges.add_bridge( + inner, + MOI.Bridges.Constraint.NonposToNonnegBridge{T}, + ) @test MOI.get(inner, MOI.VariableBridgingCost{MOI.Nonnegatives}()) == 0.0 @test MOI.get(inner, MOI.VariableBridgingCost{MOI.Nonpositives}()) == 1.0 + @test MOI.get( + inner, + MOI.ConstraintBridgingCost{ + MOI.VectorAffineFunction{T}, + MOI.Nonpositives, + }(), + ) == 1.0 cache = MOI.Utilities.CachingOptimizer( MOI.Utilities.UniversalFallback(MOI.Utilities.Model{T}()), inner, ) @test MOI.get(cache, MOI.VariableBridgingCost{MOI.Nonpositives}()) == 1.0 + @test MOI.get( + cache, + MOI.ConstraintBridgingCost{ + MOI.VectorAffineFunction{T}, + MOI.Nonpositives, + }(), + ) == 1.0 outer = MOI.Bridges.LazyBridgeOptimizer(cache) @test MOI.get(outer, MOI.VariableBridgingCost{MOI.Nonpositives}()) == 1.0 @test MOI.Bridges.bridging_cost( outer.graph, MOI.Bridges.node(outer, MOI.Nonpositives), ) == 1.0 + # Add a constraint bridge in `outer` whose target is + # `VAF-in-Nonpositives`. The bridge's edge cost in `outer.graph` must + # reflect the inner cost (1.0) of `VAF-in-Nonpositives`, so bridging + # `SAF-in-LessThan{T}` costs 1.0 (bridge) + 1.0 (inner) = 2.0. Without + # the fix it would be wrongly reported as 1.0. + MOI.Bridges.add_bridge(outer, MOI.Bridges.Constraint.VectorizeBridge{T}) + @test MOI.get( + outer, + MOI.ConstraintBridgingCost{ + MOI.ScalarAffineFunction{T}, + MOI.LessThan{T}, + }(), + ) == 2.0 + @test MOI.Bridges.bridging_cost( + outer.graph, + MOI.Bridges.node(outer, MOI.ScalarAffineFunction{T}, MOI.LessThan{T}), + ) == 2.0 + @test MOI.Bridges.is_bridged( + outer, + MOI.ScalarAffineFunction{T}, + MOI.LessThan{T}, + ) + # Sanity check: with `MOI.Utilities.Model` as inner (which natively + # supports `SAF-in-LessThan{T}` so the inner bridging cost is `0`), the + # choice differs: `outer_native` does not need to bridge + # `SAF-in-LessThan{T}` and the cost is `0.0`, while `outer` above must + # use `Constraint.VectorizeBridge` and pays `2.0`. + outer_native = MOI.Bridges.LazyBridgeOptimizer(MOI.Utilities.Model{T}()) + MOI.Bridges.add_bridge( + outer_native, + MOI.Bridges.Constraint.VectorizeBridge{T}, + ) + @test MOI.get( + outer_native, + MOI.ConstraintBridgingCost{ + MOI.ScalarAffineFunction{T}, + MOI.LessThan{T}, + }(), + ) == 0.0 + @test !MOI.Bridges.is_bridged( + outer_native, + MOI.ScalarAffineFunction{T}, + MOI.LessThan{T}, + ) return end From 722fa65b41f0451719a28af19101288256992cd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Thu, 14 May 2026 19:52:32 +0200 Subject: [PATCH 4/6] Add test --- .../General/test_lazy_bridge_optimizer.jl | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/test/Bridges/General/test_lazy_bridge_optimizer.jl b/test/Bridges/General/test_lazy_bridge_optimizer.jl index bf4b1211dc..bae95797b9 100644 --- a/test/Bridges/General/test_lazy_bridge_optimizer.jl +++ b/test/Bridges/General/test_lazy_bridge_optimizer.jl @@ -2579,6 +2579,130 @@ function test_nested_lazy_bridge_optimizer_cost() return end +# A minimal model whose only purpose is to report custom non-zero values for +# `VariableBridgingCost` and `ConstraintBridgingCost`. Its constructor takes +# dictionaries that map set types (resp. `(F, S)` tuples) to a `Float64` cost, +# so tests can vary the inner costs and observe how the bridge selection in an +# outer `LazyBridgeOptimizer` changes. +mutable struct CostModel{T} <: MOI.ModelLike + var_costs::Dict{Type,Float64} + con_costs::Dict{Tuple{Type,Type},Float64} +end + +function CostModel{T}(; + var_costs::Dict{Type,Float64} = Dict{Type,Float64}(), + con_costs::Dict{Tuple{Type,Type},Float64} = Dict{Tuple{Type,Type},Float64}(), +) where {T} + return CostModel{T}(var_costs, con_costs) +end + +function MOI.supports_add_constrained_variable( + model::CostModel, + ::Type{S}, +) where {S<:MOI.AbstractScalarSet} + return haskey(model.var_costs, S) +end + +function MOI.supports_add_constrained_variables( + model::CostModel, + ::Type{S}, +) where {S<:MOI.AbstractVectorSet} + return haskey(model.var_costs, S) +end + +function MOI.supports_add_constrained_variables( + model::CostModel, + ::Type{MOI.Reals}, +) + return haskey(model.var_costs, MOI.Reals) +end + +function MOI.supports_constraint( + model::CostModel, + ::Type{F}, + ::Type{S}, +) where {F<:MOI.AbstractFunction,S<:MOI.AbstractSet} + return haskey(model.con_costs, (F, S)) +end + +function MOI.get( + model::CostModel, + ::MOI.VariableBridgingCost{S}, +) where {S<:MOI.AbstractSet} + return get(model.var_costs, S, Inf) +end + +function MOI.get( + model::CostModel, + ::MOI.ConstraintBridgingCost{F,S}, +) where {F<:MOI.AbstractFunction,S<:MOI.AbstractSet} + return get(model.con_costs, (F, S), Inf) +end + +function test_custom_cost_model_bridge_selection() + # Outer wraps a `CostModel` that supports `VAF-in-RSOC` and `VAF-in-PSD` + # but NOT `VAF-in-SOC`. With both `SOCtoRSOCBridge` (cost 1) and + # `SOCtoPSDBridge` (cost 10) in `outer`, the choice for `VAF-in-SOC` + # depends on the inner costs of `RSOC` and `PSD`: when both inner costs + # are 0, `SOCtoRSOC` wins (1 < 10); when the inner cost of `RSOC` is + # high enough, `SOCtoPSD` becomes cheaper. + T = Float64 + F = MOI.VectorAffineFunction{T} + # Case 1: both inner costs are 0. `SOCtoRSOC` should be selected. + model1 = CostModel{T}(; + con_costs = Dict{Tuple{Type,Type},Float64}( + (F, MOI.RotatedSecondOrderCone) => 0.0, + (F, MOI.PositiveSemidefiniteConeTriangle) => 0.0, + ), + ) + outer1 = MOI.Bridges.LazyBridgeOptimizer(model1) + MOI.Bridges.add_bridge(outer1, MOI.Bridges.Constraint.SOCtoRSOCBridge{T}) + MOI.Bridges.add_bridge(outer1, MOI.Bridges.Constraint.SOCtoPSDBridge{T}) + @test MOI.get( + outer1, + MOI.ConstraintBridgingCost{F,MOI.SecondOrderCone}(), + ) == 1.0 + @test MOI.Bridges.bridge_type(outer1, F, MOI.SecondOrderCone) <: + MOI.Bridges.Constraint.SOCtoRSOCBridge{T} + # Case 2: inner cost of `RSOC` is 15. The path via `SOCtoRSOC` becomes + # 1 + 15 = 16, which is more expensive than `SOCtoPSD` at 10 + 0 = 10, + # so the bridge selection flips to `SOCtoPSD`. + model2 = CostModel{T}(; + con_costs = Dict{Tuple{Type,Type},Float64}( + (F, MOI.RotatedSecondOrderCone) => 15.0, + (F, MOI.PositiveSemidefiniteConeTriangle) => 0.0, + ), + ) + outer2 = MOI.Bridges.LazyBridgeOptimizer(model2) + MOI.Bridges.add_bridge(outer2, MOI.Bridges.Constraint.SOCtoRSOCBridge{T}) + MOI.Bridges.add_bridge(outer2, MOI.Bridges.Constraint.SOCtoPSDBridge{T}) + @test MOI.get( + outer2, + MOI.ConstraintBridgingCost{F,MOI.SecondOrderCone}(), + ) == 10.0 + @test MOI.Bridges.bridge_type(outer2, F, MOI.SecondOrderCone) <: + MOI.Bridges.Constraint.SOCtoPSDBridge{T} + # Case 3: same bridges, but now `PSD` is also expensive (cost 12). The + # path via `SOCtoRSOC` is 1 + 5 = 6 and the path via `SOCtoPSD` is + # 10 + 12 = 22, so `SOCtoRSOC` wins again. + model3 = CostModel{T}(; + con_costs = Dict{Tuple{Type,Type},Float64}( + (F, MOI.RotatedSecondOrderCone) => 5.0, + (F, MOI.PositiveSemidefiniteConeTriangle) => 12.0, + ), + ) + outer3 = MOI.Bridges.LazyBridgeOptimizer(model3) + MOI.Bridges.add_bridge(outer3, MOI.Bridges.Constraint.SOCtoRSOCBridge{T}) + MOI.Bridges.add_bridge(outer3, MOI.Bridges.Constraint.SOCtoPSDBridge{T}) + @test MOI.get( + outer3, + MOI.ConstraintBridgingCost{F,MOI.SecondOrderCone}(), + ) == 6.0 + @test MOI.Bridges.bridge_type(outer3, F, MOI.SecondOrderCone) <: + MOI.Bridges.Constraint.SOCtoRSOCBridge{T} + return +end + end # module TestBridgesLazyBridgeOptimizer.runtests() From e76c7939798ddab26107fa96e8bb4a515e484d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Thu, 14 May 2026 23:14:27 +0200 Subject: [PATCH 5/6] Simplify --- src/Bridges/lazy_bridge_optimizer.jl | 42 ++++++++++++---------------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/src/Bridges/lazy_bridge_optimizer.jl b/src/Bridges/lazy_bridge_optimizer.jl index 0b25e67987..879cc947e0 100644 --- a/src/Bridges/lazy_bridge_optimizer.jl +++ b/src/Bridges/lazy_bridge_optimizer.jl @@ -269,29 +269,26 @@ function node( if iszero(inner_cost) return VariableNode(0) end - # The inner model supports `S` but with a non-zero bridging cost. - # Create a leaf node whose distance is `inner_cost` so that bridges - # that emit constrained variables in `S` account for it. - cached = get(b.variable_node, (S,), nothing) - if cached !== nothing - return cached - end - new_node = add_node(b.graph, VariableNode) - b.variable_node[(S,)] = new_node - push!(b.variable_types, (S,)) - b.graph.variable_dist[new_node.index] = inner_cost - return new_node + else + inner_cost = nothing end # If (S,) is stored in .variable_node, we've already added the node # previously. variable_node = get(b.variable_node, (S,), nothing) if variable_node !== nothing + # The inner model supports `S` but with a non-zero bridging cost. + # Create a leaf node whose distance is `inner_cost` so that bridges + # that emit constrained variables in `S` account for it. return variable_node end # This is a new (S,). We need to add it to the graph. variable_node = add_node(b.graph, VariableNode) b.variable_node[(S,)] = variable_node push!(b.variable_types, (S,)) + if !isnothing(inner_cost) + b.graph.variable_dist[variable_node.index] = inner_cost + return variable_node + end F = MOI.Utilities.variable_function_type(S) if is_bridged(b, MOI.Reals) # The solver doesn't support adding free variables. @@ -341,18 +338,8 @@ function node( if iszero(inner_cost) return ConstraintNode(0) end - # The inner model supports `F`-in-`S` but with a non-zero bridging cost. - # Create a leaf node whose distance is `inner_cost` so that bridges - # that emit `F`-in-`S` constraints account for it. - cached = get(b.constraint_node, (F, S), nothing) - if cached !== nothing - return cached - end - new_node = add_node(b.graph, ConstraintNode) - b.constraint_node[(F, S)] = new_node - push!(b.constraint_types, (F, S)) - b.graph.constraint_dist[new_node.index] = inner_cost - return new_node + else + inner_cost = nothing end # If (F, S) is stored in .constraint_node, we've already added the node # previously. @@ -364,6 +351,13 @@ function node( constraint_node = add_node(b.graph, ConstraintNode) b.constraint_node[(F, S)] = constraint_node push!(b.constraint_types, (F, S)) + if !isnothing(inner_cost) + # The inner model supports `F`-in-`S` but with a non-zero bridging cost. + # Create a leaf node whose distance is `inner_cost` so that bridges + # that emit `F`-in-`S` constraints account for it. + b.graph.constraint_dist[constraint_node.index] = inner_cost + return constraint_node + end for (i, BT) in enumerate(b.constraint_bridge_types) if MOI.supports_constraint(BT, F, S) edge = _edge(b, i, Constraint.concrete_bridge_type(BT, F, S))::Edge From e80dc56c3ba09c674269f3f140d272c1573e74a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Thu, 14 May 2026 23:17:20 +0200 Subject: [PATCH 6/6] Fix --- src/Bridges/lazy_bridge_optimizer.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Bridges/lazy_bridge_optimizer.jl b/src/Bridges/lazy_bridge_optimizer.jl index 879cc947e0..7d0d97f050 100644 --- a/src/Bridges/lazy_bridge_optimizer.jl +++ b/src/Bridges/lazy_bridge_optimizer.jl @@ -276,9 +276,6 @@ function node( # previously. variable_node = get(b.variable_node, (S,), nothing) if variable_node !== nothing - # The inner model supports `S` but with a non-zero bridging cost. - # Create a leaf node whose distance is `inner_cost` so that bridges - # that emit constrained variables in `S` account for it. return variable_node end # This is a new (S,). We need to add it to the graph. @@ -286,6 +283,9 @@ function node( b.variable_node[(S,)] = variable_node push!(b.variable_types, (S,)) if !isnothing(inner_cost) + # The inner model supports `S` but with a non-zero bridging cost. + # Create a leaf node whose distance is `inner_cost` so that bridges + # that emit constrained variables in `S` account for it. b.graph.variable_dist[variable_node.index] = inner_cost return variable_node end