Skip to content

[ENH] set_cover Python: expose the lower bound accessors directly - #5277

Open
jg-codes wants to merge 1 commit into
google:mainfrom
jg-codes:expose-set-cover-lower-bound
Open

[ENH] set_cover Python: expose the lower bound accessors directly#5277
jg-codes wants to merge 1 commit into
google:mainfrom
jg-codes:expose-set-cover-lower-bound

Conversation

@jg-codes

@jg-codes jg-codes commented Aug 1, 2026

Copy link
Copy Markdown

[ENH] set_cover Python: expose the lower bound accessors directly

Disclosure: drafted with AI assistance (Claude). See the verification section for exactly what was built and run.

What

DualAscentOptimizer computes a dual lower bound and stores it on the invariant:

// ortools/set_cover/set_cover_heuristics.cc:1314
inv()->ReportLowerBound(max_lower_bound, /*is_cost_consistent=*/false);

SetCoverInvariant exposes it in C++ at set_cover_invariant.h:146 (LowerBound()) and :131 (CostOrLowerBound()). Neither is bound in ortools/set_cover/python/set_cover.cc.

The value is reachable from Python today, just indirectly: ExportSolutionAsProto() writes it into SetCoverSolutionResponse.cost_lower_bound at set_cover_invariant.cc:536, and that binding exists.

bound = inv.export_solution_as_proto().cost_lower_bound

That serializes the whole solution (selected subsets, cost, all of it) to read one scalar. This patch binds the accessor directly, next to the existing cost binding:

gap = (inv.cost() - inv.lower_bound()) / inv.cost()

For applied work that gap is the difference between handing a planner a depot network and handing them a network with a statement about how much room is left. The number already exists; this is about it sitting next to cost(), where a caller looking for it would expect to find it.

Changes

  • ortools/set_cover/python/set_cover.cc: three .def() lines on SetCoverInvariant, next to the existing cost binding: lower_bound(), cost_or_lower_bound(), is_cost_consistent().
  • ortools/set_cover/python/set_cover_test.py: two tests.

is_cost_consistent() is included deliberately. ReportLowerBound() is called with is_cost_consistent=false, and CostOrLowerBound() switches on that flag, so without it a caller cannot tell which of the two numbers they just received.

No new dependency, no build change.

Verification

Built and tested on Linux (Bazel 8.7.0, x86_64) against main at d9c0910:

bazel test //ortools/set_cover/python:set_cover_test   PASSED

clang-format clean against the repo .clang-format; the Python is black clean at the file's existing line width.

I also exercised the end-to-end path this patch is meant to unblock, using a temporary local benchmark script against the same build: DualAscentOptimizer.optimize() followed by inv.lower_bound() returns a non-trivial bound on real OR-Library SCP instances (see #5122 for the numbers). That path could not be checked before, since DualAscentOptimizer is not in the released PyPI wheel this PR was originally drafted against.

Checklist

  • Read CONTRIBUTING.md and the PR template, targeting main
  • CLA signed
  • Minimal diff: three bindings and their tests
  • No unrelated formatting or refactoring
  • AI-assisted: disclosed above

DualAscentOptimizer computes a dual lower bound and stores it via
inv()->ReportLowerBound() (set_cover_heuristics.cc:1276, :1314).
SetCoverInvariant exposes it in C++ as LowerBound() and
CostOrLowerBound(), neither of which is bound in the pybind11 wrapper.

The value is reachable from Python today, just indirectly:
ExportSolutionAsProto() serializes it into
SetCoverSolutionResponse.cost_lower_bound (set_cover_invariant.cc:536),
and that binding exists. But reading a scalar today means serializing
the whole solution proto, which is awkward when the caller wants an
optimality gap next to inv.cost().

Binds LowerBound(), CostOrLowerBound() and is_cost_consistent(). The
last one is needed because ReportLowerBound() is called with
is_cost_consistent=false, so a caller has to be able to tell whether
the invariant currently holds a feasible solution or a relaxation,
which is exactly what CostOrLowerBound() switches on.

Adds two tests.

Built and tested on Linux (Bazel 8.7.0) against main at d9c0910:
bazel test //ortools/set_cover/python:set_cover_test passes.
@jg-codes

jg-codes commented Aug 1, 2026

Copy link
Copy Markdown
Author

For context: this depends on nothing else open right now, but is part of the same set_cover wrapper audit as #5121, #5122, and #5278.

@Mizux Mizux added the Solver: Set Cover Solver in set_cover/ label Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Solver: Set Cover Solver in set_cover/

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants