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/3] 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/3] 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/3] 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