Skip to content

Support MOI.ObjectiveBound() - #117

Merged
odow merged 9 commits into
jump-dev:masterfrom
sandyspiers:objective-bound
Oct 27, 2026
Merged

odow merged 9 commits into
jump-dev:masterfrom
sandyspiers:objective-bound

Conversation

@sandyspiers

Copy link
Copy Markdown
Contributor

Adds support for MOI.ObjectiveBound.

I've made this parametric, so it should come out the same type as primal_objective.

To achieve this, we have to add the flag -s, which ask MiniZinc to report statistics. This goes to the output file, so users don't see it anyways.

If a solver can report a bound (cpsat, highs), then it gets reported there.

We then regex capture it, convert to float, then convert to T, rounding it in the direction of the objective if we need to.

Thanks!

@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.43%. Comparing base (7ce81b3) to head (41eb948).
⚠️ Report is 7 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #117      +/-   ##
==========================================
+ Coverage   98.31%   98.43%   +0.12%     
==========================================
  Files           5        5              
  Lines         652      703      +51     
==========================================
+ Hits          641      692      +51     
  Misses         11       11              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread src/optimize.jl Outdated
) where {T<:Integer}
# all variables and coefficients are integer, so the objective is integer,
# and the bound can be rounded towards it
return T(sense == MOI.MAX_SENSE ? floor(bound) : ceil(bound))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think this is safe. HiGHS might return 0.9999999999 as the bound when the true value is 1.0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also can use floor(T, x)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably we can check if isapprox(round(T, x), x) is true and only round it if it's the case. Note that isapprox(0, x) is false for all x so we need to use a nonzero atol to make it work. If MiniZinc returns 0.5, we just want to leave it as is

@sandyspiers sandyspiers Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm yes I hadn't considered that.

If MiniZinc returns 0.5, we just want to leave it as is

I think this wouldn't work for GenericModel{Int}, since in JuMP, the return value of objective_bound(model) would be an Int (in this case)

https://github.com/jump-dev/JuMP.jl/blob/800e0c1438336b77630be248c1a2e699efc51ad3/src/objective.jl#L77

Would changing objective_bound(model::GenericModel{T})::Union{T,Vector{T}} where {T} to remove its return type be acceptable?

Also, what would a good choice of atol be?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the same as the default rtol, Base.rtoldefault(T)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since T<:Integer and Base.roldefault(Integer)=0, I assume you meant Base.rtoldefault(typeof(bound)), which I have implemented

Comment thread src/optimize.jl Outdated
# a bound within tolerance of an integer is snapped to it.
# otherwise it is rounded towards the objective.
# since isapprox(0, x) is always false, an atol must be provided
if isapprox(round(bound), bound; atol = Base.rtoldefault(typeof(bound)))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if isapprox(round(bound), bound; atol = Base.rtoldefault(typeof(bound)))
if isapprox(round(T, bound), bound; atol = Base.rtoldefault(typeof(bound)))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless I'm missing something ?

Comment thread src/optimize.jl Outdated
if isapprox(round(bound), bound; atol = Base.rtoldefault(typeof(bound)))
return round(T, bound)
end
return sense == MOI.MAX_SENSE ? floor(T, bound) : ceil(T, bound)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's only true if the objective is affine right ? Are we sure there are no nonlinear function that can produce non-integer values ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should just through an InexactError here

@odow

odow commented Oct 25, 2026

Copy link
Copy Markdown
Member

I'm not convinced with the rounding here. I think we might just be better off reporting what the solver tells us.

@sandyspiers

Copy link
Copy Markdown
Contributor Author

Yes, I am less convinced now as well. I hadn't even considered nonlinear expressions.

If we report straight from MiniZinc, then it may return a float, which could become an issue for GenericModel{Int} since from JuMP, objective_bound(model::GenericModel{T})::Union{T,Vector{T}} where {T} would have to return an Int

https://github.com/jump-dev/JuMP.jl/blob/800e0c1438336b77630be248c1a2e699efc51ad3/src/objective.jl#L77

I'm not quite sure the solution here, happy to take suggestions. Maybe just throw InexactError for now, in line with @blegat's suggestion?

@blegat

blegat commented Oct 26, 2026

Copy link
Copy Markdown
Member

I'm not quite sure the solution here, happy to take suggestions. Maybe just throw InexactError for now, in line with @blegat's suggestion?

Yes, we should start with the most conservative approach and then see if there are use case for rounding. So we can start with a simple convert(T, bound) which already throws the InexactError for us and then we'll see when MiniZinc gives us an non-integral answer if it's a bug in MiniZinc or if a rounding makes sense

@sandyspiers

Copy link
Copy Markdown
Contributor Author

I've dropped the rounding, stuck with convert(T, model.objective_bound), and put this in MOI.get(model::Optimizer{T}, attr::MOI.ObjectiveBound) instead, so that we only throw InexactError if the user specifically asked for the objective bound

Comment thread src/optimize.jl Outdated
function MOI.get(model::Optimizer{T}, attr::MOI.ObjectiveBound) where {T}
if model.solver_status == "OPTIMAL"
# completed the search, so the bound is the objective value
return MOI.get(model, MOI.ObjectiveValue())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should return a value here only if the solver actually found something called "objective bound". It's okay to throw an error when the solver said "optimal" but didn't provide a bound.

@odow
odow merged commit bc92b29 into jump-dev:master Oct 27, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants