From 4fef477b526761fefed95d1b02b8b8fe6321eba1 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Wed, 21 Oct 2026 15:13:17 -0400 Subject: [PATCH 1/5] MOAI.jl predictors as GDP with Disjunctive(...,method) --- Project.toml | 5 +- ext/MathOptAIDisjunctiveProgramming.jl | 82 +++++++++++ src/extension_api.jl | 34 +++++ .../MathOptAIDisjunctiveProgramming.jl | 131 ++++++++++++++++++ test/runtests.jl | 1 + 5 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 ext/MathOptAIDisjunctiveProgramming.jl create mode 100644 test/extensions/MathOptAIDisjunctiveProgramming.jl diff --git a/Project.toml b/Project.toml index 2fb97a55..c8557581 100644 --- a/Project.toml +++ b/Project.toml @@ -11,10 +11,12 @@ Reexport = "189a3867-3050-52da-a836-e630ba90ab69" [weakdeps] AbstractGPs = "99985d1d-32ba-4be9-9821-2ec096f28918" InfiniteOpt = "20393b10-9daf-11e9-18c9-8db751c92c57" +MathOptAI = "e52c2cb8-508e-4e12-9dd2-9c4755b60e73" [extensions] AbstractGPsDisjunctiveProgramming = "AbstractGPs" InfiniteDisjunctiveProgramming = "InfiniteOpt" +MathOptAIDisjunctiveProgramming = "MathOptAI" [compat] AbstractGPs = "0.5" @@ -25,6 +27,7 @@ Ipopt = "1.9.0" JuMP = "1.18" Juniper = "0.9.3" LinearAlgebra = "1" +MathOptAI = "0.1.19, 0.2, 1" Pajarito = "0.8" Reexport = "1" julia = "1.10" @@ -39,4 +42,4 @@ Pajarito = "2f354839-79df-5901-9f0a-cdb2aac6fe30" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Aqua", "HiGHS", "Test", "Juniper", "Ipopt", "InfiniteOpt", "AbstractGPs", "Hypatia", "Pajarito"] +test = ["Aqua", "HiGHS", "Test", "Juniper", "Ipopt", "InfiniteOpt", "AbstractGPs", "Hypatia", "Pajarito", "MathOptAI"] diff --git a/ext/MathOptAIDisjunctiveProgramming.jl b/ext/MathOptAIDisjunctiveProgramming.jl new file mode 100644 index 00000000..fa37356e --- /dev/null +++ b/ext/MathOptAIDisjunctiveProgramming.jl @@ -0,0 +1,82 @@ +module MathOptAIDisjunctiveProgramming + +import MathOptAI +import DisjunctiveProgramming as DP +import JuMP +const _MOI = JuMP.MOI + +################################################################################ +# DISJUNCTIVE PREDICTOR +################################################################################ +# A MathOptAI predictor to embed as a GDP disjunction; the base package only +# carries the `Disjunctive` stub. +struct _Disjunctive{P <: MathOptAI.AbstractPredictor} <: + MathOptAI.AbstractPredictor + predictor::P +end +DP.Disjunctive(predictor) = _Disjunctive(predictor) + +################################################################################ +# DECISION TREES +################################################################################ +# Leaf => root-to-leaf split rows as (feature, set) pairs; the left bound is +# threshold - atol, MathOptAI's disjoint-branch convention +_leaf_paths(leaf, path) = [leaf => path] +function _leaf_paths( + node::MathOptAI.BinaryDecisionTree, + path = Pair{Int, _MOI.AbstractScalarSet}[] + ) + threshold = Float64(node.feat_value) + left = node.feat_id => _MOI.LessThan(threshold - node.atol) + right = node.feat_id => _MOI.GreaterThan(threshold) + return vcat(_leaf_paths(node.lhs, [path; left]), + _leaf_paths(node.rhs, [path; right])) +end + +# Leaf rule as an `Affine`: a constant leaf has zero slopes +_affine_leaf(leaf::Real, num_inputs) = + MathOptAI.Affine(zeros(1, num_inputs), [float(leaf)]) +_affine_leaf(leaf::MathOptAI.Affine, num_inputs) = leaf + +# Envelope of the leaf rules over the input box; `missing` when an input +# with a nonzero slope is unbounded, which Hull then rejects itself +function _output_bounds(leaves, x) + bounds = MathOptAI.get_variable_bounds.(x) + lower, upper = Inf, -Inf + for leaf in leaves + leaf_lower = leaf_upper = only(leaf.b) + for (slope, (input_lower, input_upper)) in zip(leaf.A, bounds) + iszero(slope) && continue + leaf_lower += slope * (slope > 0 ? input_lower : input_upper) + leaf_upper += slope * (slope > 0 ? input_upper : input_lower) + end + lower, upper = min(lower, leaf_lower), max(upper, leaf_upper) + end + return lower, upper +end + +function MathOptAI.add_predictor( + model::JuMP.AbstractModel, + predictor::_Disjunctive{<:MathOptAI.BinaryDecisionTree}, + x::Vector + ) + paths = _leaf_paths(predictor.predictor) + leaves = [_affine_leaf(leaf, length(x)) for (leaf, _) in paths] + y = only(MathOptAI.add_variables(model, x, 1, "moai_Disjunctive")) + cons = Any[] + MathOptAI.set_variable_bounds(cons, y, _output_bounds(leaves, x)...) + indicators = JuMP.@variable(model, [1:length(paths)], DP.Logical, + base_name = "moai_Disjunctive_Z") + for (indicator, (_, path), leaf) in zip(indicators, paths, leaves) + for (feature, set) in path # affine row, the form DP.jl reformulates + push!(cons, JuMP.@constraint(model, 1 * x[feature] in set, + DP.Disjunct(indicator))) + end + push!(cons, JuMP.@constraint(model, + y == only(leaf.A * x) + only(leaf.b), DP.Disjunct(indicator))) + end + push!(cons, DP.disjunction(model, indicators)) + return [y], MathOptAI.Formulation(predictor, Any[y; indicators], cons) +end + +end # module diff --git a/src/extension_api.jl b/src/extension_api.jl index 6132d4b9..a4efefc4 100644 --- a/src/extension_api.jl +++ b/src/extension_api.jl @@ -117,3 +117,37 @@ function sample_M_values(sampler, objectives, sub, method, support_grids) "AbstractGPs loaded) to estimate M values with a " * "Gaussian process.") end + +################################################################################ +# MATHOPTAI PREDICTORS +################################################################################ +""" + Disjunctive(predictor::MathOptAI.AbstractPredictor) + +Wrap a MathOptAI predictor so that `MathOptAI.add_predictor` embeds it in +a [`GDPModel`](@ref) as a disjunction instead of MathOptAI's own +mixed-integer formulation. The disjunction is reformulated like any other +when the model is solved with `optimize!(model, gdp_method = ...)` or by +[`reformulate_model`](@ref). Requires `MathOptAI` to be loaded. Supports +`BinaryDecisionTree` with constant or `Affine` leaves: one disjunct per +leaf, carrying the root-to-leaf split rows and the leaf model. + +The returned `Formulation` holds the output variable, the leaf indicators, +the output bounds, the disjunct constraints and the disjunction. + +**Example** +```julia +julia> using DisjunctiveProgramming, MathOptAI, HiGHS + +julia> tree = MathOptAI.BinaryDecisionTree(1, 0.0, -1, 1); + +julia> model = GDPModel(HiGHS.Optimizer); + +julia> @variable(model, -1 <= x[1:1] <= 1); + +julia> y, f = MathOptAI.add_predictor(model, Disjunctive(tree), x); + +julia> optimize!(model, gdp_method = Hull()) +``` +""" +function Disjunctive end diff --git a/test/extensions/MathOptAIDisjunctiveProgramming.jl b/test/extensions/MathOptAIDisjunctiveProgramming.jl new file mode 100644 index 00000000..acdeb421 --- /dev/null +++ b/test/extensions/MathOptAIDisjunctiveProgramming.jl @@ -0,0 +1,131 @@ +using HiGHS, MathOptAI +import DisjunctiveProgramming as DP + +# x[1] <= 0 ? -1 : (x[1] <= 1 ? 0 : 1), MathOptAI's own docstring example. +# The atol gap sits well above HiGHS's MIP feasibility tolerance (1e-6). +_step_tree(atol = 1e-3) = MathOptAI.BinaryDecisionTree{Float64, Int}(1, 0.0, + -1, MathOptAI.BinaryDecisionTree{Float64, Int}(1, 1.0, 0, 1, atol), atol) + +# linear model tree: x[1] <= 1 ? 2 x[1] - x[2] : x[1] + 3 x[2] + 1 +const _AffineTree = MathOptAI.BinaryDecisionTree{Float64, + MathOptAI.Affine{Float64}} +_affine_tree(atol = 1e-3) = _AffineTree(1, 1.0, + MathOptAI.Affine([2.0 -1.0], [0.0]), + MathOptAI.Affine([1.0 3.0], [1.0]), atol) +_affine_truth(x) = x[1] < 1 ? 2x[1] - x[2] : x[1] + 3x[2] + 1 + +# surrogate value at pinned inputs, from the GDP embedding or MathOptAI's own +# MIP; the pin is a global equality so the box the M values are read from +# stays [lb, ub] and every leaf keeps a feasible region +function _surrogate_value(model, predictor, xval, lb, ub; kwargs...) + set_silent(model) + @variable(model, lb[i] <= x[i in eachindex(xval)] <= ub[i]) + y, _ = MathOptAI.add_predictor(model, predictor, x) + @constraint(model, x .== xval) + optimize!(model; kwargs...) + return is_solved_and_feasible(model) ? value(y[1]) : nothing +end +_gdp_value(predictor, method, xval, lb, ub) = _surrogate_value( + GDPModel(HiGHS.Optimizer), Disjunctive(predictor), xval, lb, ub, + gdp_method = method) +_moai_value(predictor, xval, lb, ub) = + _surrogate_value(Model(HiGHS.Optimizer), predictor, xval, lb, ub) + +function test_disjunctive_wrapper() + predictor = Disjunctive(_step_tree()) + @test predictor isa MathOptAI.AbstractPredictor + @test predictor.predictor isa MathOptAI.BinaryDecisionTree +end + +function test_tree_formulation_contents() + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, -5 <= x[1:1] <= 5) + y, formulation = MathOptAI.add_predictor(model, Disjunctive(_step_tree()), + x) + @test length(y) == 1 + @test lower_bound(y[1]) == -1 && upper_bound(y[1]) == 1 + @test formulation isa MathOptAI.Formulation + @test formulation.predictor isa typeof(Disjunctive(_step_tree())) + # the output and the three leaf indicators + @test length(formulation.variables) == 1 + 3 + @test formulation.variables[1] === y[1] + @test all(v -> v isa LogicalVariableRef, formulation.variables[2:end]) + # two output bounds, 5 path rows, 3 leaf rows and the disjunction + @test length(formulation.constraints) == 2 + 5 + 3 + 1 + @test all(c -> is_valid(model, c), formulation.constraints) + dref = formulation.constraints[end] + @test dref isa DisjunctionRef + @test length(DP._disjunctions(model)) == 1 + @test length(DP._disjunct_constraints(model)) == 5 + 3 + @test haskey(DP._exactly1_constraints(model), dref) + # the GDP method is chosen at solve time like for any disjunction + @objective(model, Max, y[1] - 0.1 * x[1]) + optimize!(model, gdp_method = BigM()) + @test is_solved_and_feasible(model) + @test value(y[1]) ≈ 1 && value(x[1]) ≈ 1 + @objective(model, Min, y[1] + 0.1 * x[1]) + optimize!(model, gdp_method = Hull()) + @test value(y[1]) ≈ -1 && value(x[1]) ≈ -5 +end + +# every method reproduces MathOptAI's leaf regions, including the atol gap +function test_tree_matches_mathoptai() + methods = [BigM(), Hull(), MBM(HiGHS.Optimizer), Indicator()] + for xval in [-2.0, -0.01, 0.0, 0.5, 0.99, 1.0, 3.0] + expected = _moai_value(_step_tree(), [xval], [-5], [5]) + @test expected !== nothing + for method in methods + @test _gdp_value(_step_tree(), method, [xval], [-5], [5]) ≈ + expected atol = 1e-8 + end + end + @test _moai_value(_step_tree(), [1 - 5e-4], [-5], [5]) === nothing + for method in methods + @test _gdp_value(_step_tree(), method, [1 - 5e-4], [-5], [5]) === + nothing + end +end + +# whole-model methods apply too, since nothing is reformulated early +function test_cutting_planes() + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, -5 <= x[1:1] <= 5) + y, _ = MathOptAI.add_predictor(model, Disjunctive(_step_tree()), x) + @objective(model, Max, y[1] - 0.1 * x[1]) + optimize!(model, gdp_method = CuttingPlanes(HiGHS.Optimizer)) + @test is_solved_and_feasible(model) + @test value(y[1]) ≈ 1 atol = 1e-4 + @test value(x[1]) ≈ 1 atol = 1e-4 +end + +function test_affine_leaves() + for method in [BigM(), Hull()], xval in + [[-2.0, 0.5], [0.0, 0.0], [0.999, -1.0], [1.0, 2.0], [3.0, 1.5]] + @test _gdp_value(_affine_tree(), method, xval, [-3, -3], [3, 3]) ≈ + _affine_truth(xval) atol = 1e-6 + end + # envelope bounds and a free optimum + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, -3 <= x[1:2] <= 3) + y, formulation = MathOptAI.add_predictor(model, + Disjunctive(_affine_tree()), x) + @test lower_bound(y[1]) == -11 && upper_bound(y[1]) == 13 + @objective(model, Max, y[1]) + optimize!(model, gdp_method = Hull()) + @test is_solved_and_feasible(model) + @test value(y[1]) ≈ 13 && value(x[1]) ≈ 3 && value(x[2]) ≈ 3 + @objective(model, Min, y[1]) + optimize!(model, gdp_method = Hull()) + @test value(y[1]) ≈ -9 && value(x[1]) ≈ -3 && value(x[2]) ≈ 3 +end + +@testset "MathOptAIDisjunctiveProgramming" begin + test_disjunctive_wrapper() + test_tree_formulation_contents() + test_tree_matches_mathoptai() + test_cutting_planes() + test_affine_leaves() +end diff --git a/test/runtests.jl b/test/runtests.jl index bcfbf132..aec661b9 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -27,4 +27,5 @@ include("print.jl") include("solve.jl") include("extensions/InfiniteDisjunctiveProgramming.jl") include("extensions/AbstractGPsDisjunctiveProgramming.jl") +include("extensions/MathOptAIDisjunctiveProgramming.jl") From 84f7ea85c4149467186ba80ed158a356d8f7ef5f Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Thu, 29 Oct 2026 12:03:01 -0400 Subject: [PATCH 2/5] Support InfiniteLogical in trees --- ext/MathOptAIDisjunctiveProgramming.jl | 36 ++++++++++----- .../MathOptAIDisjunctiveProgramming.jl | 45 ++++++++++++++++++- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/ext/MathOptAIDisjunctiveProgramming.jl b/ext/MathOptAIDisjunctiveProgramming.jl index fa37356e..e1b9efde 100644 --- a/ext/MathOptAIDisjunctiveProgramming.jl +++ b/ext/MathOptAIDisjunctiveProgramming.jl @@ -8,8 +8,7 @@ const _MOI = JuMP.MOI ################################################################################ # DISJUNCTIVE PREDICTOR ################################################################################ -# A MathOptAI predictor to embed as a GDP disjunction; the base package only -# carries the `Disjunctive` stub. +# A MathOptAI predictor to embed as a GDP disjunction struct _Disjunctive{P <: MathOptAI.AbstractPredictor} <: MathOptAI.AbstractPredictor predictor::P @@ -19,7 +18,7 @@ DP.Disjunctive(predictor) = _Disjunctive(predictor) ################################################################################ # DECISION TREES ################################################################################ -# Leaf => root-to-leaf split rows as (feature, set) pairs; the left bound is +# Leaf => root to leaf split rows as (feature, set) pairs; the left bound is # threshold - atol, MathOptAI's disjoint-branch convention _leaf_paths(leaf, path) = [leaf => path] function _leaf_paths( @@ -33,22 +32,33 @@ function _leaf_paths( _leaf_paths(node.rhs, [path; right])) end -# Leaf rule as an `Affine`: a constant leaf has zero slopes +# Leaf rule as an `Affine`: a constant leaf has zero coefficients _affine_leaf(leaf::Real, num_inputs) = MathOptAI.Affine(zeros(1, num_inputs), [float(leaf)]) _affine_leaf(leaf::MathOptAI.Affine, num_inputs) = leaf +# Bounds of an input read through JuMP, since InfiniteOpt's MathOptAI hook +# reports none for its variables; an infinite parameter gives its domain +function _input_bounds(x::JuMP.AbstractVariableRef) + JuMP.is_fixed(x) && return JuMP.fix_value(x), JuMP.fix_value(x) + JuMP.is_binary(x) && return 0.0, 1.0 + lower = JuMP.has_lower_bound(x) ? JuMP.lower_bound(x) : missing + upper = JuMP.has_upper_bound(x) ? JuMP.upper_bound(x) : missing + return lower, upper +end +_input_bounds(x) = MathOptAI.get_variable_bounds(x) + # Envelope of the leaf rules over the input box; `missing` when an input -# with a nonzero slope is unbounded, which Hull then rejects itself +# with a nonzero coefficient is unbounded, which Hull then rejects itself function _output_bounds(leaves, x) - bounds = MathOptAI.get_variable_bounds.(x) + bounds = _input_bounds.(x) lower, upper = Inf, -Inf for leaf in leaves leaf_lower = leaf_upper = only(leaf.b) - for (slope, (input_lower, input_upper)) in zip(leaf.A, bounds) - iszero(slope) && continue - leaf_lower += slope * (slope > 0 ? input_lower : input_upper) - leaf_upper += slope * (slope > 0 ? input_upper : input_lower) + for (coeff, (input_lower, input_upper)) in zip(leaf.A, bounds) + iszero(coeff) && continue + leaf_lower += coeff * (coeff > 0 ? input_lower : input_upper) + leaf_upper += coeff * (coeff > 0 ? input_upper : input_lower) end lower, upper = min(lower, leaf_lower), max(upper, leaf_upper) end @@ -65,7 +75,11 @@ function MathOptAI.add_predictor( y = only(MathOptAI.add_variables(model, x, 1, "moai_Disjunctive")) cons = Any[] MathOptAI.set_variable_bounds(cons, y, _output_bounds(leaves, x)...) - indicators = JuMP.@variable(model, [1:length(paths)], DP.Logical, + # indicators vary over the parameters of y (if any), so each point + # picks its own leaf + tag = DP.VariableProperties(y).variable_type + logical = isnothing(tag) ? DP.Logical : DP.Logical(tag) + indicators = JuMP.@variable(model, [1:length(paths)], logical, base_name = "moai_Disjunctive_Z") for (indicator, (_, path), leaf) in zip(indicators, paths, leaves) for (feature, set) in path # affine row, the form DP.jl reformulates diff --git a/test/extensions/MathOptAIDisjunctiveProgramming.jl b/test/extensions/MathOptAIDisjunctiveProgramming.jl index acdeb421..7f46a24a 100644 --- a/test/extensions/MathOptAIDisjunctiveProgramming.jl +++ b/test/extensions/MathOptAIDisjunctiveProgramming.jl @@ -1,4 +1,4 @@ -using HiGHS, MathOptAI +using HiGHS, MathOptAI, InfiniteOpt import DisjunctiveProgramming as DP # x[1] <= 0 ? -1 : (x[1] <= 1 ? 0 : 1), MathOptAI's own docstring example. @@ -122,10 +122,53 @@ function test_affine_leaves() @test value(y[1]) ≈ -9 && value(x[1]) ≈ -3 && value(x[2]) ≈ 3 end +# tree evaluated pointwise: y(t) and the leaf indicators follow x(t), so the +# active leaf changes across the supports +function test_infinite_tree() + for method in [BigM(), Hull(), MBM(HiGHS.Optimizer)] + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + @infinite_parameter(model, t in [0, 1], num_supports = 11) + @variable(model, -5 <= x[1:1] <= 5, Infinite(t)) + y, formulation = MathOptAI.add_predictor(model, + Disjunctive(_step_tree()), x) + @test parameter_refs(y[1]) == (t,) + @test lower_bound(y[1]) == -1 && upper_bound(y[1]) == 1 + @test all(v -> v isa LogicalVariableRef, formulation.variables[2:end]) + @constraint(model, x[1] == 4t - 2) + optimize!(model, gdp_method = method) + @test is_solved_and_feasible(model) + xvals = value(x[1]) + @test value(y[1]) ≈ _step_value.(xvals) atol = 1e-6 + @test length(value(formulation.variables[2])) == 11 + end +end +_step_value(x) = x <= -1e-3 ? -1.0 : (x <= 1 - 1e-3 ? 0.0 : 1.0) + +# an infinite parameter as an input takes its domain as bounds +function test_infinite_tree_parameter_input() + for method in [BigM(), Hull()] + model = InfiniteGDPModel(HiGHS.Optimizer) + set_silent(model) + @infinite_parameter(model, t in [0, 1], num_supports = 11) + @variable(model, -3 <= x <= 3, Infinite(t)) + y, _ = MathOptAI.add_predictor(model, Disjunctive(_affine_tree()), + [x, t]) + @test lower_bound(y[1]) == -7 && upper_bound(y[1]) == 7 + @constraint(model, x == 3t - 1) + optimize!(model, gdp_method = method) + @test is_solved_and_feasible(model) + expected = _affine_truth.(zip(value(x), supports(t))) + @test value(y[1]) ≈ expected atol = 1e-6 + end +end + @testset "MathOptAIDisjunctiveProgramming" begin test_disjunctive_wrapper() test_tree_formulation_contents() test_tree_matches_mathoptai() test_cutting_planes() test_affine_leaves() + test_infinite_tree() + test_infinite_tree_parameter_input() end From f5291156fdd0609f98d83975165ab533afc9124b Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Thu, 29 Oct 2026 12:04:48 -0400 Subject: [PATCH 3/5] MOAI version --- Project.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index c8557581..a81a9eed 100644 --- a/Project.toml +++ b/Project.toml @@ -27,7 +27,7 @@ Ipopt = "1.9.0" JuMP = "1.18" Juniper = "0.9.3" LinearAlgebra = "1" -MathOptAI = "0.1.19, 0.2, 1" +MathOptAI = "1" Pajarito = "0.8" Reexport = "1" julia = "1.10" From 06be2b3cc803347487e8c25b20aff9d440b1c428 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Thu, 29 Oct 2026 21:31:17 -0400 Subject: [PATCH 4/5] Maintaining precision, format --- ext/MathOptAIDisjunctiveProgramming.jl | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/ext/MathOptAIDisjunctiveProgramming.jl b/ext/MathOptAIDisjunctiveProgramming.jl index e1b9efde..2fdc4837 100644 --- a/ext/MathOptAIDisjunctiveProgramming.jl +++ b/ext/MathOptAIDisjunctiveProgramming.jl @@ -9,8 +9,7 @@ const _MOI = JuMP.MOI # DISJUNCTIVE PREDICTOR ################################################################################ # A MathOptAI predictor to embed as a GDP disjunction -struct _Disjunctive{P <: MathOptAI.AbstractPredictor} <: - MathOptAI.AbstractPredictor +struct _Disjunctive{P <: MathOptAI.AbstractPredictor} <: MathOptAI.AbstractPredictor predictor::P end DP.Disjunctive(predictor) = _Disjunctive(predictor) @@ -25,23 +24,25 @@ function _leaf_paths( node::MathOptAI.BinaryDecisionTree, path = Pair{Int, _MOI.AbstractScalarSet}[] ) - threshold = Float64(node.feat_value) - left = node.feat_id => _MOI.LessThan(threshold - node.atol) - right = node.feat_id => _MOI.GreaterThan(threshold) + left = node.feat_id => _MOI.LessThan(node.feat_value - node.atol) + right = node.feat_id => _MOI.GreaterThan(node.feat_value) return vcat(_leaf_paths(node.lhs, [path; left]), _leaf_paths(node.rhs, [path; right])) end # Leaf rule as an `Affine`: a constant leaf has zero coefficients -_affine_leaf(leaf::Real, num_inputs) = - MathOptAI.Affine(zeros(1, num_inputs), [float(leaf)]) +function _affine_leaf(leaf::Real, num_inputs) + value = float(leaf) + return MathOptAI.Affine(zeros(typeof(value), 1, num_inputs), [value]) +end _affine_leaf(leaf::MathOptAI.Affine, num_inputs) = leaf # Bounds of an input read through JuMP, since InfiniteOpt's MathOptAI hook # reports none for its variables; an infinite parameter gives its domain function _input_bounds(x::JuMP.AbstractVariableRef) JuMP.is_fixed(x) && return JuMP.fix_value(x), JuMP.fix_value(x) - JuMP.is_binary(x) && return 0.0, 1.0 + value_type = JuMP.value_type(typeof(x)) + JuMP.is_binary(x) && return zero(value_type), one(value_type) lower = JuMP.has_lower_bound(x) ? JuMP.lower_bound(x) : missing upper = JuMP.has_upper_bound(x) ? JuMP.upper_bound(x) : missing return lower, upper From 9c3d2511fb8705025ebea1de8f6e434ef0049344 Mon Sep 17 00:00:00 2001 From: dnguyen227 Date: Fri, 30 Oct 2026 10:37:47 -0400 Subject: [PATCH 5/5] _input_bounds test --- .../MathOptAIDisjunctiveProgramming.jl | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/extensions/MathOptAIDisjunctiveProgramming.jl b/test/extensions/MathOptAIDisjunctiveProgramming.jl index 7f46a24a..f069e7aa 100644 --- a/test/extensions/MathOptAIDisjunctiveProgramming.jl +++ b/test/extensions/MathOptAIDisjunctiveProgramming.jl @@ -122,6 +122,22 @@ function test_affine_leaves() @test value(y[1]) ≈ -9 && value(x[1]) ≈ -3 && value(x[2]) ≈ 3 end +# an expression input takes MathOptAI's bounds: 2 x[1] - 1 in [-7, 5] +function test_expression_input() + model = GDPModel(HiGHS.Optimizer) + set_silent(model) + @variable(model, -3 <= x[1:2] <= 3) + inputs = [2 * x[1] - 1, x[2]] + y, _ = MathOptAI.add_predictor(model, Disjunctive(_step_tree()), inputs) + @test lower_bound(y[1]) == -1 && upper_bound(y[1]) == 1 + z, _ = MathOptAI.add_predictor(model, Disjunctive(_affine_tree()), inputs) + @test lower_bound(z[1]) == -17 && upper_bound(z[1]) == 15 + @constraint(model, x .== [1.5, 2.0]) + optimize!(model, gdp_method = BigM()) + @test is_solved_and_feasible(model) + @test value(y[1]) ≈ 1 && value(z[1]) ≈ _affine_truth([2.0, 2.0]) +end + # tree evaluated pointwise: y(t) and the leaf indicators follow x(t), so the # active leaf changes across the supports function test_infinite_tree() @@ -169,6 +185,7 @@ end test_tree_matches_mathoptai() test_cutting_planes() test_affine_leaves() + test_expression_input() test_infinite_tree() test_infinite_tree_parameter_input() end