Skip to content

feat: Add Google OR-Tools CP-SAT solver#126

Open
gabriel-gehrke wants to merge 15 commits into
rust-or:mainfrom
gabriel-gehrke:dev
Open

feat: Add Google OR-Tools CP-SAT solver#126
gabriel-gehrke wants to merge 15 commits into
rust-or:mainfrom
gabriel-gehrke:dev

Conversation

@gabriel-gehrke

@gabriel-gehrke gabriel-gehrke commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

This PR adds a bridge to the Google OR-Tools CP-SAT solver via the cp_sat Rust crate (v0.4.0), accessible through the cp_sat feature flag.

CP-SAT is one of the fastest open-source solvers for combinatorial (integer) optimization problems. It supports integer and boolean variables, linear objectives, linear constraints, and advanced features like solution hints (warm-starting), time limits, MIP gaps, parallel search with different search strategies, and verbose logging.

Implementation

The bridge lives in src/solvers/cp_sat.rs and covers:

  • cp_sat() function: Converts UnsolvedProblem to CpSatProblem
  • SolverModel impl: add_constraint() with equality/inequality support, solve() with full status mapping
  • WithInitialSolution impl: Warm-start hints via CpModelBuilder::add_hint()
  • WithTimeLimit impl: Set max_time_in_seconds on internal parameters
  • WithMipGap impl: Set relative_gap_limit on internal parameters
  • Additional API: configuring verbosity and some search parameters (set_verbose(), set_log_to_response(), set_num_search_workers(), set_random_seed(), ...)
  • CpSatSolution wraps CpSolverResponse with access to response_stats(), solution_log(), additional_solutions(), into_response()
  • 24 unit tests covering core functionality, edge cases, and error handling (all passing)

Limitations & Rough Edges

Unlike most other good_lp solvers, the cp_sat crate links dynamically to a very heavy OR-Tools shared library. This is handled by the cp_sat crate but it does so a little clumsily, relying on correct setting of environment variables (Note I am already working on a better ortools to rust interface, which could replace this crate in the future). Currently, users must:

  • Install OR-Tools on their system (prebuilt binaries provided for different Linux distribution, macos and WIndows via Github releases)
  • Set ORTOOLS_PREFIX, RUSTFLAGS="-L /opt/ortools/lib -lprotobuf", LD_LIBRARY_PATH=/opt/ortools/lib

As the OR-Tools prebuilt binary is platform-specific, I currently pin it to ubuntu-24.04 in CI. The CI job runs only cp_sat-specific lib tests (cargo test --lib -- cp_sat), not integration tests which use continuous variables and would panic.

For now I did not put it into all_default_solvers due to the heavy external dependency (OR-Tools shared libraries), cp_sat is opt-in only, similar to cplex-rs.

Progress on Fixes

  • No more rounding. Reject non-integer values instead (turns model invalid, clean error is returned during solve().
  • Don't special-case CI workflow
  • Replace lambda with imperative logic
  • Don't require manual env vars
  • Point to upstream OR-Tools install docs
  • Remove redundant dep:cp_sat
  • Remove duplicate solver list in src/lib.rs

@TeXitoi TeXitoi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This "integer only model" makes cp_sat difficult to use as a LP solver. Ideally, the weights would be scaled, but that’s not really doable without a global analysis of the LP.

Comment thread src/solvers/cp_sat.rs Outdated

// Translate bounds to CP-SAT domain
let domain_lower = if def.min.is_finite() {
round_i64(def.min)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

rounding is not correct. lower must be ceiled, and upper must be floored.

Comment thread src/solvers/cp_sat.rs Outdated
fn expr_from_constraint_expression(&self, expression: &crate::Expression) -> LinearExpr {
let mut linear = LinearExpr::default();
for (var, coeff) in expression.linear.coefficients.iter() {
let coeff_i64 = round_i64(*coeff);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I’m afraid these rounding will be very error prone for the users.

@gabriel-gehrke

Copy link
Copy Markdown
Contributor Author

This "integer only model" makes cp_sat difficult to use as a LP solver. Ideally, the weights would be scaled, but that’s not really doable without a global analysis of the LP.

The CP-SAT primer by Dominik Krupke highlights this about fractional (continuous values):

Let us look at the following example with two linear equality constraints:

x−y=0
4−x=2y

x,y≥0

You can verify that x=4/3 and y=4/3 is a feasible solution. However, coding this in CP-SAT results in an infeasible solution:

model = cp_model.CpModel()
x = model.new_int_var(-100, 100, "x")
y = model.new_int_var(-100, 100, "y")

model.add(x - y == 0)
model.add(4 - x == 2 * y)

solver = cp_model.CpSolver()
status = solver.solve(model)
assert status == cp_model.INFEASIBLE

Even using scaling techniques, such as multiplying integer variables by 1,000,000 to increase the resolution, would not render the model feasible. While common linear programming solvers would handle this model without issue, CP-SAT struggles unless modifications are made to eliminate fractions, such as multiplying all terms by 3. However, this requires manual intervention, which undermines the idea of using a solver. These limitations are important to consider, although such scenarios are rare in practical applications.<

This shows that automatic scaling can not reliably cover all buliding blocks that good_lp offers when using CP-SAT. Personally, I would be in favor of leaving this to the user.

@gabriel-gehrke

Copy link
Copy Markdown
Contributor Author

Maybe we could add a warning whenever a fractional value is passed to the rounding method, informing the user that their value will be rounded.

Even considering the limitations, I think it would be highly beneficial to support CP-SAT in good_lp. It is very performant, and there are many combinatorial problem classes that do not necessarily involve continuous variables (covering, routing, knapsack packing, vehicle routing, etc.). Having CP-SAT alongside the LP and MIP solvers would also allow combining LP for relaxations and switching to CP-SAT for the integer solving phase.

@TeXitoi

TeXitoi commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Note that my remark is not limited to linear variables, but also fractional weights in the objective and constraint. If the user says x + 0.5y > 1.5, it will not do the intended thing. In this case, multiplying by 2 all the weight will work, but more complex cases exists. As long as you don’t use fractional variables, you can scale at the constraint/objective level. You even have a factor to "correct" the scaling of the objective.

@lovasoa lovasoa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

thanks ! that's a good addition.

See my suggestions below.

I haven't checked the quality of the underlying crate itself, I trust you with it.

Comment thread .github/workflows/rust.yml Outdated
env:
ORTOOLS_PREFIX: /opt/ortools
RUSTFLAGS: -L /opt/ortools/lib -lprotobuf
LD_LIBRARY_PATH: /opt/ortools/lib

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can we avoid special-casing the new solver like that ? Use the standard system library path, standard build.rs in the underlying crate, etc. Ideally you would add an option to statically link the c++ deps in the underlying crate and use it here. The goal of good-lp is to be as user-friendly and zero-config as possible.

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.

Use the standard system library path, standard build.rs in the underlying crate, etc. Ideally you would add an option to statically link the c++ deps in the underlying crate and use it here. The goal of good-lp is to be as user-friendly and zero-config as possible

Two problems here:

  1. The ortools C++ library is quite heavy and it has a lot of dependencies: Abseil, Protobuf, zlib, pthreads, other solvers again, ... I have not looked into whether that is possible at all yet, but my instinct says: NO. It also takes a non-trivial time to build from source, in the order of a couple of minutes even on a 24 thread cpu. So I think most users will just install this library as a binary release.
  2. The underlying crate assumes the ortools library to lie at "/opt/ortools", so that is actually the default and not the standard system library path. Another location can be provided by setting ORTOOLS_PREFIX. I agree that we should simplify this. And this is actually one of the reasons why I am working on my own ortools bridge, which will not only provide an interface to CP-SAT but also to some of their other optimizers, and handle linking better via CMake.

Comment thread src/solvers/cp_sat.rs Outdated
Comment on lines +43 to +54
assert!(
def.is_integer,
"CP-SAT does not support continuous variables. \
Variable {} (index {}) was not declared as integer or binary. \
Please use `.integer()` or `.binary()` on the variable definition.",
if def.name.is_empty() {
format!("v{}", var.index())
} else {
def.name.clone()
},
var.index()
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can we avoid crashing the entire program here ? Just let the model build and return an error on solve

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.

This confuses me because I found analogous behavior in the clarabel solver interface:

/// The [clarabel](https://oxfordcontrol.github.io/ClarabelDocs/stable/) solver,
/// to be used with [UnsolvedProblem::using].
pub fn clarabel(to_solve: UnsolvedProblem) -> ClarabelProblem {
    
    // [...]

    for (var, def) in variables.iter_variables_with_def() {
        if def.is_integer {
            panic!("Clarabel doesn't support integer variables")
        }
        // [...]
    }
    p
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

yeah, clarabel should not do that either ! pr welcome !

Comment thread src/solvers/cp_sat.rs Outdated
/// Panics on NaN.
fn round_i64(x: f64) -> i64 {
if x.is_nan() {
panic!("CP-SAT received NaN");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

please do not crash the user's program. Handle errors cleanly

Comment thread src/solvers/cp_sat.rs Outdated
Comment thread src/solvers/cp_sat.rs Outdated
Comment on lines +141 to +148
// Validate: lower bound must not exceed upper bound
assert!(
domain_lower <= domain_upper,
"Invalid variable bounds: lower={} > upper={} for variable '{}'",
def.min,
def.max,
name
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

do not crash

Comment thread src/solvers/cp_sat.rs Outdated
self.params.relative_gap_limit.is_some() || self.params.absolute_gap_limit.is_some();

let status = response.status();
let status_from_limits = || {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why does this need to be a function?

Comment thread README.md Outdated
Comment on lines +234 to +240
Then set the following environment variables when building and running:

```bash
export ORTOOLS_PREFIX=/opt/ortools
export RUSTFLAGS="-L /opt/ortools/lib -lprotobuf"
export LD_LIBRARY_PATH=/opt/ortools/lib
```

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

that's non standard, and painful for users

Comment thread README.md Outdated
Comment on lines +228 to +231
curl -LO 'https://github.com/google/or-tools/releases/download/v9.15/or-tools_amd64_ubuntu-24.04_cpp_v9.15.6755.tar.gz'
sudo mkdir -p /opt/ortools
sudo tar -xzf or-tools_amd64_ubuntu-24.04_cpp_v9.15.6755.tar.gz -C /opt/ortools --strip-components=1
cd /opt/ortools && sudo make test

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe we should point to upstream instructions

Comment thread Cargo.toml Outdated
singlethread-cbc = ["coin_cbc?/singlethread-cbc"]
scip = ["russcip"]
scip_bundled = ["russcip?/bundled"]
cp_sat = ["dep:cp_sat"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

is that needed ?

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.

I looked into this and as far as I understand, it's not strictly necessary. Cargo automatically creates a feature with the same name as any optional dependency, so the cp_sat feature would exist even without the explicit cp_sat = ["dep:cp_sat"] line.

I think I added this because I tried adding cp_sat = ["cp_sat"] before and that failed, so I looked for solutions. But can probably be dropped. Only benefit is the dep: prefix makes the relationship explicit rather than relying on the implicit auto-generated feature.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

yes, you can drop it

Comment thread src/lib.rs Outdated
Comment on lines +6 to +18
//! # Solvers
//!
//! The following solvers are available:
//!
//! - [`coin_cbc`](solvers::coin_cbc) - [COIN-OR CBC](https://www.coin-or.org/Cbc/) (enabled by default)
//! - [`cp_sat`](solvers::cp_sat) - [Google OR-Tools CP-SAT](https://developers.google.com/optimization/cp/cp_solver) (requires `cp_sat` feature)
//! - [`microlp`](solvers::microlp) - A lightweight LP solver
//! - [`lpsolve`](solvers::lpsolve) - [LpSolve](http://lpsolve.sourceforge.net/)
//! - [`highs`](solvers::highs) - [HiGHS](https://highs.dev/)
//! - [`scip`](solvers::scip) - [SCIP](https://scipopt.org/)
//! - [`lp-solvers`](solvers::lp_solvers) - A wrapper around several LP solvers
//! - [`clarabel`](solvers::clarabel) - [Clarabel](https://github.com/oxfordcontrol/Clarabel.rs)
//!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we can inline the readme if you want. But let's not maintain two lists

@lovasoa

lovasoa commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

@gabriel-gehrke : also from the test logs, I see it prints to stdout. Can we avoid that ?

@lovasoa

lovasoa commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

For non integer weights : can we just reject them with a clean error ? And document that solver accepts only integers. Let users do the rounding on their side

@gabriel-gehrke

Copy link
Copy Markdown
Contributor Author

@gabriel-gehrke : also from the test logs, I see it prints to stdout. Can we avoid that ?

I implemented a configurable verbosity flag similar to how Highs can print to stdout (so CP-SAT just does that, for example for debugging). It can also that provide the log after terminating. In one of the tests it basically tests that this flag can be set. I can remove that test, as it is more like a "this does not cause an error" thing, and not checking whether it actually prints to stdout.

@gabriel-gehrke

Copy link
Copy Markdown
Contributor Author

Thank you for the detailed feedback, I will make sure to fix these issues.

@lovasoa

lovasoa commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

In one of the tests it basically tests that this flag can be set.

Oh ok no problem then, you can leave that part as is. Sorry!

@matth2k

matth2k commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

No contribution on my end here, but this would be a really cool backend to have for what I'm working on. cp-sat is a speedy solver for the problems I used it on.

@gabriel-gehrke

gabriel-gehrke commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

So I've been thinking about the whole approach of 'not crashing the user's program even if inputs are invalid'. Due to the trait signatures in this crate (notably SolverModel::add_constraint returning a ConstraintReference, and the Solver impl using FnMut(UnsolvedProblem) -> CpSatProblem), pulling this off without becoming a pain in the ass is surprisingly non-trivial.

My proposal:

  • Store state logic inside CpSatProblem via a private ProblemState enum that represents either a Valid state (with CpModelBuilder + variable vector) or an Invalid state (with just a ResolutionError)
  • While in Valid state, add variables and constraints to the model normally
  • As soon as an invalid definition is encountered (continuous variable, lower > upper, NaN coefficient), transition to Invalid and immediately fail in solve(), which is the earliest point in the model-creation + solving process that returns a Result

About variable/constraint handles:

  • add_constraint() in Invalid state could return a constant ConstraintReference { index: 0 }. Since solve() will return Err, the user can never meaningfully use that reference, but code that depens on these being unique could behave unexpectedly.

I would appreciate feedback on this approach

@lovasoa

lovasoa commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

@gabriel-gehrke : yes, I think that's the way it should work. A Valid(InnerModel)/Invalid enum, all methods implemented on InnerModel, the top level implementation just forwards calls to the inner struct or does nothing.

Comment thread src/solvers/cp_sat.rs
Comment on lines +116 to +134
if !x.is_finite() {
return Err(ResolutionError::Str(format!(
"The CP-SAT solver does not accept infinite values for coefficients, constants, \
or variable bounds. Received `{x}`, which is not finite."
)));
}
if x.fract() != 0.0 {
return Err(ResolutionError::Str(format!(
"The CP-SAT solver only accepts integer values for coefficients, constants, \
or variable bounds. Received `{x}`, which is not an integer. \
Please round or scale your values to integers."
)));
}
if !(x >= -(2f64.powi(63)) && x < 2f64.powi(63)) {
return Err(ResolutionError::Str(format!(
"The CP-SAT solver value `{x}` is out of the i64 range."
)));
}
Ok(x as i64)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we could use try_from, no ?

Comment thread README.md
[CP-SAT](https://developers.google.com/optimization/cp/cp_solver) is Google's fast constraint programming solver,
part of [OR-Tools](https://developers.google.com/optimization). It is one of the fastest open-source solvers
for combinatorial (integer) optimization problems, supporting both integer and boolean variables.
CP-SAT does **not** support continuous (floating-point) variables; using a non-integer variable will cause a panic.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

just return an error

@gabriel-gehrke

gabriel-gehrke commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

I opened a PR on the cp_sat crate. That PR fixes the dependency on 2 of the 3 environment variables my setup currently depends on. I do not have high hopes for it to get merged, unfortunately, as the repository has multiple open pull requests without any reaction from the maintainer(s).

@lovasoa

lovasoa commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

@TeXitoi , maybe you could have a look ?

@gabriel-gehrke
gabriel-gehrke requested a review from lovasoa July 10, 2026 08:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants