From 8344934d8e59c3f1f6657f4d3334a5633fdf9fbb Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Fri, 23 Oct 2026 13:08:57 -0400 Subject: [PATCH 1/3] Code coverage --- src/combination_sources.jl | 5 +- test/loa.jl | 145 +++++++++++++++++++++++++++++++++++++ test/mock_optimizer.jl | 11 ++- 3 files changed, 157 insertions(+), 4 deletions(-) diff --git a/src/combination_sources.jl b/src/combination_sources.jl index 812695f..f3ad116 100644 --- a/src/combination_sources.jl +++ b/src/combination_sources.jl @@ -281,13 +281,14 @@ function _bound_unit_interval(lp::MOI.ModelLike, vi::MOI.VariableIndex) return end -# weighted draw; uniform when the weights carry no information +# weighted draw; uniform when the weights carry no information; the +# last disjunct takes whatever remains past the others function _sample(rng, disjuncts, weights) total = sum(weights) total > 0 || return rand(rng, disjuncts) threshold = rand(rng) * total running = 0.0 - for (disjunct, w) in zip(disjuncts, weights) + for (disjunct, w) in zip(disjuncts[1:(end - 1)], weights) running += w running >= threshold && return disjunct end diff --git a/test/loa.jl b/test/loa.jl index c6f916e..a20c4ed 100644 --- a/test/loa.jl +++ b/test/loa.jl @@ -787,6 +787,39 @@ function test_nlp_failure_unresolved() @test occursin("1 unresolved", raw_status(model)) end +# Every NLP fails without a certificate: the master exhausts the +# combinations, but with no incumbent that is not an infeasibility +# proof. +function test_nlp_failure_no_incumbent() + factory = () -> DA.Optimizer( + () -> MockSolver(Ipopt.Optimizer; + fail_from = 1, fail_status = MOI.NUMERICAL_ERROR), + HiGHS.Optimizer) + model = Model(factory) + set_silent(model) + @variable(model, 0 <= x <= 10) + @variable(model, z[1:2], Bin) + @constraint(model, [1, z[1], z[2], x^2, x^2] in DA.DisjunctionSet([ + [MOI.GreaterThan(4.0)], [MOI.GreaterThan(25.0)]])) + @objective(model, Min, x) + optimize!(model) + @test termination_status(model) == MOI.OTHER_LIMIT + @test primal_status(model) == MOI.NO_SOLUTION + @test occursin("2 combination(s) failed", raw_status(model)) +end + +# A run that is not silent logs one progress record per NLP solve. +function test_progress_log() + model = Model(_loa_optimizer()) + @variable(model, 0 <= x <= 10) + @variable(model, z[1:2], Bin) + @constraint(model, [1, z[1], z[2], x, x] in DA.DisjunctionSet([ + [MOI.GreaterThan(2.0)], [MOI.GreaterThan(5.0)]])) + @objective(model, Min, x) + @test_logs (:info, r"LOA progress") match_mode = :any optimize!(model) + @test objective_value(model) ≈ 2.0 atol = 1e-5 +end + # Bad attribute values fail at set time, not mid-solve. function test_attribute_validation() algorithm = DA.LOA() @@ -1066,6 +1099,111 @@ function test_subproblem_method_dispatch() @test MOI.get(unsafe_backend(model), DA.SubproblemMethod()) === method end +# A subproblem method without its own `_check_nlp_support` brings its +# own solver, so the nlp_solver check is skipped; HiGHS as the +# nlp_solver would reject the quadratic disjunct row. +struct _UncheckedSubproblems end + +function test_subproblem_method_skips_nlp_check() + model = Model(() -> DA.Optimizer(HiGHS.Optimizer)) + @variable(model, 0 <= x <= 4) + @variable(model, z[1:2], Bin) + @constraint(model, [1, z[1], z[2], x^2, x] in DA.DisjunctionSet([ + [MOI.LessThan(4.0)], [MOI.LessThan(1.0)]])) + @objective(model, Min, x) + MOI.Utilities.attach_optimizer(model) + optimizer = unsafe_backend(model) + problem = DA._build_problem(optimizer) + @test_throws ErrorException DA._check_inner_support(nothing, optimizer, + problem) + @test DA._check_inner_support(_UncheckedSubproblems(), optimizer, + problem) === nothing +end + +# Operator trees demote by degree: powers of a variable or a constant, +# constant products folded, and typed rows nested as arguments. +function test_demote_operator_trees() + x = MOI.VariableIndex(1) + tree(head, args...) = MOI.ScalarNonlinearFunction(head, Any[args...]) + affine(a, c) = MOI.ScalarAffineFunction([MOI.ScalarAffineTerm(a, x)], c) + constant(c) = MOI.ScalarAffineFunction(MOI.ScalarAffineTerm{Float64}[], c) + square = MOI.Utilities.operate(*, Float64, x, x) + @test DA._demote(tree(:^, x, 2)) ≈ square + @test DA._demote(tree(:^, x, 1)) ≈ affine(1.0, 0.0) + @test DA._demote(tree(:^, x, 0)) ≈ constant(1.0) + @test DA._demote(tree(:^, 3.0, 2)) ≈ constant(9.0) + @test DA._demote(tree(:*, 2.0, 3.0, x)) ≈ affine(6.0, 0.0) + @test DA._degree(square) == 2 + nested = DA._demote(tree(:+, square, affine(2.0, 1.0))) + @test nested ≈ MOI.Utilities.operate(+, Float64, square, affine(2.0, 1.0)) +end + +# A master with a solution pool supplies the extra combinations from +# result indices past 1 with no re-solve and no no-good cut; a pool +# entry equal to the proposal is dropped. +function test_pool_candidates() + mip = () -> MockSolver(HiGHS.Optimizer, pool_size = 2) + model = Model(() -> DA.Optimizer(Ipopt.Optimizer, mip)) + @variable(model, 0 <= x <= 10) + @variable(model, z[1:2], Bin) + @constraint(model, [1, z[1], z[2], x, x] in DA.DisjunctionSet([ + [MOI.GreaterThan(2.0)], [MOI.GreaterThan(5.0)]])) + @objective(model, Min, x) + MOI.Utilities.attach_optimizer(model) + optimizer = unsafe_backend(model) + problem = DA._build_problem(optimizer) + master = DA._build_master(optimizer, problem) + MOI.add_constraint(master.model, master.alpha_oa, MOI.GreaterThan(0.0)) + @test DA._solve_master(optimizer, master, Inf) + @test MOI.get(master.model, MOI.ResultCount()) == 2 + solution = DA._extract_combination(problem, master) + other = only(c for c in _all_combinations(problem) if c != solution) + no_goods = MOI.NumberOfConstraints{MOI.ScalarAffineFunction{Float64}, + MOI.GreaterThan{Float64}}() + rows = MOI.get(master.model, no_goods) + candidates, excluded = DA._candidate_combinations(nothing, optimizer, + problem, master, other, nothing, 1, Inf) + @test !excluded + @test candidates == [solution] + dropped, excluded = DA._candidate_combinations(nothing, optimizer, + problem, master, solution, nothing, 1, Inf) + @test !excluded + @test isempty(dropped) + @test MOI.get(optimizer, DA.MasterSolveCount()) == 1 + @test MOI.get(master.model, no_goods) == rows +end + +# More neighbors than asked for: an evenly spaced subset. One +# disjunction of four gives three neighbors; two are asked for. +function test_neighborhood_subset() + model = Model(_loa_optimizer()) + @variable(model, 0 <= x <= 10) + @variable(model, z[1:4], Bin) + @constraint(model, [1, z..., x, x, x, x] in DA.DisjunctionSet( + [[MOI.LessThan(Float64(k))] for k in 1:4])) + @objective(model, Min, x) + MOI.Utilities.attach_optimizer(model) + optimizer = unsafe_backend(model) + problem = DA._build_problem(optimizer) + master = DA._build_master(optimizer, problem) + disjuncts = only(problem.disjunctions).disjuncts + proposal = DA._combination(problem, disjuncts[1:1]) + neighbors, excluded = DA._candidate_combinations(DA.Neighborhood(), + optimizer, problem, master, proposal, nothing, 2, Inf) + @test !excluded + @test [only(DA._active_disjuncts(problem, n)) for n in neighbors] == + disjuncts[[2, 4]] +end + +# The weighted draw follows the weights to either end of the list. +function test_sample_weights() + rng = DA.Random.MersenneTwister(0) + @test DA._sample(rng, [:first, :last], [1.0, 0.0]) == :first + @test DA._sample(rng, [:first, :last], [0.0, 1.0]) == :last + @test DA._sample(rng, [:only], [1.0]) == :only + @test DA._sample(rng, [:first, :last], [0.0, 0.0]) in (:first, :last) +end + @testset "LOA loop" begin test_linear_disjunction() test_row_function_constants() @@ -1100,6 +1238,8 @@ end test_master_abnormal_status() test_unbounded_model() test_nlp_failure_unresolved() + test_nlp_failure_no_incumbent() + test_progress_log() test_attribute_validation() test_nonlinear_indicator_error() test_strict_constant_solvers() @@ -1121,4 +1261,9 @@ end test_activation_binary() test_sense_primitives() test_linearize_quadratic() + test_subproblem_method_skips_nlp_check() + test_demote_operator_trees() + test_pool_candidates() + test_neighborhood_subset() + test_sample_weights() end diff --git a/test/mock_optimizer.jl b/test/mock_optimizer.jl index 1716a47..93c49ca 100644 --- a/test/mock_optimizer.jl +++ b/test/mock_optimizer.jl @@ -10,13 +10,15 @@ # set-cover model). # Deterministic triggers for the deadline and abnormal-master paths. # `strict_constants` imitates direct wrappers (e.g. Gurobi) that reject -# scalar functions with nonzero constants. +# scalar functions with nonzero constants. `pool_size > 1` imitates a +# solution pool (Gurobi's PoolSolutions) holding copies of result 1. mutable struct MockSolver <: MOI.AbstractOptimizer inner::MOI.AbstractOptimizer sleep_time::Float64 fail_from::Int fail_status::MOI.TerminationStatusCode strict_constants::Bool + pool_size::Int solves::Int shared_solves::Ref{Int} failing::Bool @@ -28,10 +30,11 @@ function MockSolver( fail_from::Int = typemax(Int), fail_status::MOI.TerminationStatusCode = MOI.NODE_LIMIT, strict_constants::Bool = false, + pool_size::Int = 1, shared_solves::Ref{Int} = Ref(0) ) return MockSolver(MOI.instantiate(factory), sleep_time, fail_from, - fail_status, strict_constants, 0, shared_solves, false) + fail_status, strict_constants, pool_size, 0, shared_solves, false) end function MOI.optimize!(model::MockSolver) @@ -54,6 +57,8 @@ function MOI.get(model::MockSolver, attr::_WrappedAttr) attr isa MOI.TerminationStatus && return model.fail_status attr isa MOI.PrimalStatus && return MOI.NO_SOLUTION end + attr isa MOI.ResultCount && + return model.pool_size * MOI.get(model.inner, attr) return MOI.get(model.inner, attr) end @@ -109,6 +114,8 @@ function MOI.get( attr::_WrappedIndexAttr, index::_WrappedIndex ) + attr isa MOI.VariablePrimal && attr.result_index <= model.pool_size && + return MOI.get(model.inner, MOI.VariablePrimal(), index) return MOI.get(model.inner, attr, index) end From c9bf86a8661cefbf597c18b5fe39ea0221cb9a1c Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Fri, 23 Oct 2026 13:19:53 -0400 Subject: [PATCH 2/3] Update site --- docs/make.jl | 1 + docs/src/api.md | 12 ++++++++++ docs/src/index.md | 60 +++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 docs/src/api.md diff --git a/docs/make.jl b/docs/make.jl index ef9694f..2aa670c 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -14,6 +14,7 @@ makedocs(; ), pages=[ "Home" => "index.md", + "API Reference" => "api.md", ], ) diff --git a/docs/src/api.md b/docs/src/api.md new file mode 100644 index 0000000..9f97dd7 --- /dev/null +++ b/docs/src/api.md @@ -0,0 +1,12 @@ +```@meta +CurrentModule = DisjunctiveAlgorithms +``` + +# API Reference + +```@index +``` + +```@autodocs +Modules = [DisjunctiveAlgorithms] +``` diff --git a/docs/src/index.md b/docs/src/index.md index 4c8e161..daeda34 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -2,13 +2,63 @@ CurrentModule = DisjunctiveAlgorithms ``` -# DisjunctiveAlgorithms +# DisjunctiveAlgorithms.jl -Documentation for [DisjunctiveAlgorithms](https://github.com/infiniteopt/DisjunctiveAlgorithms.jl). +An optimizer suite for generalized disjunctive programming (GDP). -```@index +DisjunctiveAlgorithms.jl is an MOI-layer solver for models that contain +disjunctions encoded as vector constraints in +`DisjunctiveProgramming.DisjunctionSet`. Disjunction-aware algorithms +(currently logic-based outer approximation) solve the model by +dispatching subproblems to user-provided MIP and NLP solvers. + +## Installation + +DisjunctiveAlgorithms is registered in the General registry. It +requires DisjunctiveProgramming 0.7 or later, which ships the +`DisjunctionSet` set and the `Direct()` reformulation; the package +manager resolves that version automatically. + +```julia +import Pkg +Pkg.add("DisjunctiveAlgorithms") +``` + +Supported Julia versions: 1.10 and later. + +## Usage with DisjunctiveProgramming.jl + +```julia +using DisjunctiveProgramming, DisjunctiveAlgorithms, HiGHS, Ipopt +import DisjunctiveAlgorithms as DA + +model = GDPModel(() -> DA.Optimizer(Ipopt.Optimizer, HiGHS.Optimizer)) +@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 = Direct()) ``` -```@autodocs -Modules = [DisjunctiveAlgorithms] +`Direct()` lowers each disjunction to a single +`DisjunctionSet` constraint that this package consumes directly; no +Big-M or Hull reformulation is performed on the modeling side. + +## Options + +The algorithm is selected with the [`Algorithm`](@ref) attribute +(default [`LOA`](@ref)`()`) and configured through the attributes it +documents, set with `set_attribute`: + +```julia +set_attribute(model, DA.NumIterationLimit(), 20) +set_attribute(model, DA.ConvergenceTolerance(), 1e-4) ``` + +The [`LOA`](@ref) docstring lists every supported attribute. After a +solve, [`MasterSolveCount`](@ref), [`NLPSolveCount`](@ref), +[`MasterSolveTime`](@ref) and [`NLPSolveTime`](@ref) report the work +done alongside the standard MOI result attributes. The +[API Reference](@ref) lists every exported function and attribute. From f1e0762f6ce4a19fc5ff01d302376b9bfc0f86a4 Mon Sep 17 00:00:00 2001 From: d227nguyen Date: Fri, 23 Oct 2026 13:26:57 -0400 Subject: [PATCH 3/3] Temp dev link to pages --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 3524c70..11ac951 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # DisjunctiveAlgorithms.jl -[![Stable](https://img.shields.io/badge/docs-stable-blue.svg)](https://infiniteopt.github.io/DisjunctiveAlgorithms.jl/stable/) [![Dev](https://img.shields.io/badge/docs-dev-blue.svg)](https://infiniteopt.github.io/DisjunctiveAlgorithms.jl/dev/) [![Build Status](https://github.com/infiniteopt/DisjunctiveAlgorithms.jl/actions/workflows/CI.yml/badge.svg?branch=main)](https://github.com/infiniteopt/DisjunctiveAlgorithms.jl/actions/workflows/CI.yml?query=branch%3Amain) [![Coverage](https://codecov.io/gh/infiniteopt/DisjunctiveAlgorithms.jl/branch/main/graph/badge.svg)](https://codecov.io/gh/infiniteopt/DisjunctiveAlgorithms.jl)