From af2a9a7ff5f01ac2dd02306bd1336c73329f2490 Mon Sep 17 00:00:00 2001 From: Will Bryant Date: Tue, 15 Sep 2026 04:57:10 +1200 Subject: [PATCH 1/3] Add support for IntVar == constant and IntVar <= constant RoutingSolver constraints --- ext/or-tools/routing.cpp | 37 +++++++++------- lib/or-tools.rb | 1 + lib/or_tools/routing_solver.rb | 14 ++++++ test/routing_constraints_test.rb | 76 ++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 lib/or_tools/routing_solver.rb create mode 100644 test/routing_constraints_test.rb diff --git a/ext/or-tools/routing.cpp b/ext/or-tools/routing.cpp index c7b23e7..5d8ffcd 100644 --- a/ext/or-tools/routing.cpp +++ b/ext/or-tools/routing.cpp @@ -97,6 +97,17 @@ namespace Rice::detail { }; } // namespace Rice::detail +template +operations_research::Constraint *make_constraint(operations_research::Solver &solver, operations_research::IntExpr *left, T right, const std::string &op) { + if (op == "==") { + return solver.MakeEquality(left, right); + } else if (op == "<=") { + return solver.MakeLessOrEqual(left, right); + } else { + throw std::runtime_error{"Unknown operator"}; + } +} + void init_routing(Rice::Module& m) { auto rb_cRoutingSearchParameters = Rice::define_class_under(m, "RoutingSearchParameters"); auto rb_cIntVar = Rice::define_class_under(m, "RoutingIntVar"); @@ -293,24 +304,18 @@ void init_routing(Rice::Module& m) { Rice::define_class_under(m, "RoutingSolver") .define_method( - "add", - [](operations_research::Solver& self, Object o) { - operations_research::Constraint* constraint; - if (o.respond_to("left")) { - operations_research::IntExpr* left(Rice::detail::From_Ruby().convert(o.call("left"))); - operations_research::IntExpr* right(Rice::detail::From_Ruby().convert(o.call("right"))); - std::string op = o.call("op").to_s().str(); - if (op == "==") { - constraint = self.MakeEquality(left, right); - } else if (op == "<=") { - constraint = self.MakeLessOrEqual(left, right); - } else { - throw std::runtime_error{"Unknown operator"}; - } + "add_constraint", + [](operations_research::Solver& self, operations_research::Constraint& constraint) { + self.AddConstraint(&constraint); + }) + .define_method( + "_make_constraint", + [](operations_research::Solver& self, Object left, Object right, Symbol op) { + if (right.class_of().name() == "ORTools::Constant") { + return make_constraint(self, Rice::detail::From_Ruby().convert(left), Rice::detail::From_Ruby().convert(right.call("value")), op.str()); } else { - constraint = Rice::detail::From_Ruby().convert(o); + return make_constraint(self, Rice::detail::From_Ruby().convert(left), Rice::detail::From_Ruby().convert(right), op.str()); } - self.AddConstraint(constraint); }) .define_method( "fixed_duration_interval_var", diff --git a/lib/or-tools.rb b/lib/or-tools.rb index 4b9dac1..93c1380 100644 --- a/lib/or-tools.rb +++ b/lib/or-tools.rb @@ -29,6 +29,7 @@ # routing require_relative "or_tools/routing_index_manager" require_relative "or_tools/routing_model" +require_relative "or_tools/routing_solver" # higher level interfaces require_relative "or_tools/basic_scheduler" diff --git a/lib/or_tools/routing_solver.rb b/lib/or_tools/routing_solver.rb new file mode 100644 index 0000000..618c7f0 --- /dev/null +++ b/lib/or_tools/routing_solver.rb @@ -0,0 +1,14 @@ +module ORTools + class RoutingSolver + def add(comparison) + case comparison + when Comparison + add_constraint(_make_constraint(comparison.left, comparison.right, comparison.op)) + when Constraint + add_constraint(comparison) + else + raise TypeError, "Not supported: RoutingSolver#add(#{comparison})" + end + end + end +end diff --git a/test/routing_constraints_test.rb b/test/routing_constraints_test.rb new file mode 100644 index 0000000..5c2c042 --- /dev/null +++ b/test/routing_constraints_test.rb @@ -0,0 +1,76 @@ +require_relative "test_helper" + +class RoutingConstraintsTest < Minitest::Test + def test_no_extra_constraints + build_routing + solve + + assert_equal :success, @routing.status + assert_equal [0, 2, 1, 0], route + end + + def test_var_less_than_or_equal_var + build_routing + @routing.solver.add(@distance_dimension.cumul_var(@manager.node_to_index(1)) <= @distance_dimension.cumul_var(@manager.node_to_index(2))) + solve + + assert_equal :success, @routing.status + assert_equal [0, 1, 2, 0], route + end + + def test_var_less_than_or_equal_const + build_routing + @routing.solver.add(@distance_dimension.cumul_var(@manager.node_to_index(1)) <= 2455) + solve + + assert_equal :success, @routing.status + assert_equal [0, 1, 2, 0], route + end + + def test_var_equal_const + build_routing + @routing.solver.add(@distance_dimension.cumul_var(@manager.node_to_index(1)) == 2451) + solve + + assert_equal :success, @routing.status + assert_equal [0, 1, 2, 0], route + end + + def test_var_equal_const_failure + build_routing + @routing.solver.add(@distance_dimension.cumul_var(@manager.node_to_index(1)) == 2455) + solve + + assert_equal :fail, @routing.status + end + + private + + def build_routing + @manager = ORTools::RoutingIndexManager.new(3, 1, 0) + @routing = ORTools::RoutingModel.new(@manager) + transit_callback_index = @routing.register_transit_matrix([ + [0, 2451, 731], + [2451, 0, 1745], + [731, 1745, 0], + ]) + @routing.set_arc_cost_evaluator_of_all_vehicles(transit_callback_index) + @routing.add_dimension(transit_callback_index, 0, 10000, true, "Distance") + @distance_dimension = @routing.mutable_dimension("Distance") + end + + def solve + @solution = @routing.solve(first_solution_strategy: :path_cheapest_arc) + end + + def route + route = [] + index = @routing.start(0) + while !@routing.end?(index) + route << @manager.index_to_node(index) + index = @solution.value(@routing.next_var(index)) + end + route << @manager.index_to_node(index) + route + end +end From cd4210dd9d0b96624406b8337156a898c8245d32 Mon Sep 17 00:00:00 2001 From: Will Bryant Date: Tue, 15 Sep 2026 05:08:42 +1200 Subject: [PATCH 2/3] Implement the remaining Comparison operators --- ext/or-tools/routing.cpp | 8 +++++++ test/routing_constraints_test.rb | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/ext/or-tools/routing.cpp b/ext/or-tools/routing.cpp index 5d8ffcd..da1fa44 100644 --- a/ext/or-tools/routing.cpp +++ b/ext/or-tools/routing.cpp @@ -101,8 +101,16 @@ template operations_research::Constraint *make_constraint(operations_research::Solver &solver, operations_research::IntExpr *left, T right, const std::string &op) { if (op == "==") { return solver.MakeEquality(left, right); + } else if (op == "!=") { + return solver.MakeNonEquality(left, right); + } else if (op == "<") { + return solver.MakeLess(left, right); } else if (op == "<=") { return solver.MakeLessOrEqual(left, right); + } else if (op == ">") { + return solver.MakeGreater(left, right); + } else if (op == ">=") { + return solver.MakeGreaterOrEqual(left, right); } else { throw std::runtime_error{"Unknown operator"}; } diff --git a/test/routing_constraints_test.rb b/test/routing_constraints_test.rb index 5c2c042..bd3791c 100644 --- a/test/routing_constraints_test.rb +++ b/test/routing_constraints_test.rb @@ -44,6 +44,42 @@ def test_var_equal_const_failure assert_equal :fail, @routing.status end + def test_var_not_equal_const + build_routing + @routing.solver.add(@distance_dimension.cumul_var(@manager.node_to_index(1)) != 2451) + solve + + assert_equal :success, @routing.status + assert_equal [0, 2, 1, 0], route + end + + def test_var_less_than_const + build_routing + @routing.solver.add(@distance_dimension.cumul_var(@manager.node_to_index(1)) < 2455) + solve + + assert_equal :success, @routing.status + assert_equal [0, 1, 2, 0], route + end + + def test_var_greater_than_const + build_routing + @routing.solver.add(@distance_dimension.cumul_var(@manager.node_to_index(1)) > 2455) + solve + + assert_equal :success, @routing.status + assert_equal [0, 2, 1, 0], route + end + + def test_var_greater_than_or_equal_const + build_routing + @routing.solver.add(@distance_dimension.cumul_var(@manager.node_to_index(1)) >= 2455) + solve + + assert_equal :success, @routing.status + assert_equal [0, 2, 1, 0], route + end + private def build_routing From 833b105c993f75d10e496e452cbe4d5ad27a0266 Mon Sep 17 00:00:00 2001 From: Will Bryant Date: Tue, 15 Sep 2026 09:25:59 +1200 Subject: [PATCH 3/3] Support expressions in routing constraints This supports all the expressions that can be represented using Expression, which in turn supports Comparison. Test coverage includes: * IntVar + IntVar * IntVar - IntVar * IntVar + const * IntVar - const * sum(IntVar[]) * IntVar + IntVar + const * IntVar*IntVar * IntVar*const * const*IntVar * -IntVar --- ext/or-tools/routing.cpp | 51 +++++++++++++++++- test/routing_constraints_test.rb | 90 ++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/ext/or-tools/routing.cpp b/ext/or-tools/routing.cpp index da1fa44..1afc457 100644 --- a/ext/or-tools/routing.cpp +++ b/ext/or-tools/routing.cpp @@ -116,6 +116,53 @@ operations_research::Constraint *make_constraint(operations_research::Solver &so } } +operations_research::IntExpr *make_int_expr(operations_research::Solver &solver, Object expression) { + if (expression.class_of().name() == "ORTools::Constant") { + return solver.MakeIntConst(Rice::detail::From_Ruby().convert(expression.call("value"))); + } else if (expression.class_of().name() == "ORTools::Product") { + // MakeProd handles the left and right Bound() cases specially itself, so we don't need to optimize + // IntVar*Constant or Constant*IntVar ourselves + return solver.MakeProd(make_int_expr(solver, expression.call("left")), make_int_expr(solver, expression.call("right"))); + } else if (expression.class_of().name() == "ORTools::Expression") { + const Array parts(expression.call("parts")); + + switch (parts.size()) { + case 1: + // Unwrap unary Expression + return make_int_expr(solver, parts[0]); + + case 2: + // ExpressionMethods#-(other) is implemented using + -(other), which we extract here mainly to help the common IntVar-IntVar case + if (parts[1].class_of().name() == "ORTools::Product" && + parts[1].call("left").class_of().name() == "ORTools::Constant" && + Rice::detail::From_Ruby().convert(parts[1].call("left").call("value")) == -1) { + return solver.MakeDifference(make_int_expr(solver, parts[0]), make_int_expr(solver, parts[1].call("right"))); + } else { + return solver.MakeSum(make_int_expr(solver, parts[0]), make_int_expr(solver, parts[1])); + } + + default: + // There's a sum(IntVar[]), but not a sum(IntExpr[]) + if (std::all_of(parts.begin(), parts.end(), [](Object object) { + return Rice::detail::From_Ruby().is_convertible(object); + })) { + std::vector vars; + std::transform(parts.begin(), parts.end(), std::back_inserter(vars), [](Object object) { + return Rice::detail::From_Ruby().convert(object); + }); + return solver.MakeSum(vars); + } else { + operations_research::IntExpr *curr = make_int_expr(solver, parts[0]); + for (size_t index = 1; index < parts.size(); index++) curr = solver.MakeSum(curr, make_int_expr(solver, parts[index])); + return curr; + } + } + } else { + // Anything else should be a Variable, ie. a RoutingIntVar + return Rice::detail::From_Ruby().convert(expression); + } +} + void init_routing(Rice::Module& m) { auto rb_cRoutingSearchParameters = Rice::define_class_under(m, "RoutingSearchParameters"); auto rb_cIntVar = Rice::define_class_under(m, "RoutingIntVar"); @@ -320,9 +367,9 @@ void init_routing(Rice::Module& m) { "_make_constraint", [](operations_research::Solver& self, Object left, Object right, Symbol op) { if (right.class_of().name() == "ORTools::Constant") { - return make_constraint(self, Rice::detail::From_Ruby().convert(left), Rice::detail::From_Ruby().convert(right.call("value")), op.str()); + return make_constraint(self, make_int_expr(self, left), Rice::detail::From_Ruby().convert(right.call("value")), op.str()); } else { - return make_constraint(self, Rice::detail::From_Ruby().convert(left), Rice::detail::From_Ruby().convert(right), op.str()); + return make_constraint(self, make_int_expr(self, left), make_int_expr(self, right), op.str()); } }) .define_method( diff --git a/test/routing_constraints_test.rb b/test/routing_constraints_test.rb index bd3791c..5d16ce2 100644 --- a/test/routing_constraints_test.rb +++ b/test/routing_constraints_test.rb @@ -80,6 +80,96 @@ def test_var_greater_than_or_equal_const assert_equal [0, 2, 1, 0], route end + def test_var_plus_var + build_routing + @routing.solver.add(@distance_dimension.cumul_var(@manager.node_to_index(1)) + @distance_dimension.cumul_var(@manager.node_to_index(2)) == (2451 + (2451 + 1745))) + solve + + assert_equal :success, @routing.status + assert_equal [0, 1, 2, 0], route + end + + def test_sum_vars + build_routing + @routing.solver.add(@distance_dimension.cumul_var(@manager.node_to_index(0)) + @distance_dimension.cumul_var(@manager.node_to_index(1)) + @distance_dimension.cumul_var(@manager.node_to_index(2)) == (2451 + (2451 + 1745))) + solve + + assert_equal :success, @routing.status + assert_equal [0, 1, 2, 0], route + end + + def test_var_plus_const + build_routing + @routing.solver.add(@distance_dimension.cumul_var(@manager.node_to_index(1)) + 10000 == (2451 + 10000)) + solve + + assert_equal :success, @routing.status + assert_equal [0, 1, 2, 0], route + end + + def test_var_plus_var + build_routing + @routing.solver.add(@distance_dimension.cumul_var(@manager.node_to_index(1)) + @distance_dimension.cumul_var(@manager.node_to_index(2)) + 10000 == (2451 + (2451 + 1745) + 10000)) + solve + + assert_equal :success, @routing.status + assert_equal [0, 1, 2, 0], route + end + + def test_var_minus_var + build_routing + @routing.solver.add(@distance_dimension.cumul_var(@manager.node_to_index(2)) - @distance_dimension.cumul_var(@manager.node_to_index(1)) == ((2451 + 1745) - 2451)) + solve + + assert_equal :success, @routing.status + assert_equal [0, 1, 2, 0], route + end + + def test_var_minus_const + build_routing + @routing.solver.add(@distance_dimension.cumul_var(@manager.node_to_index(1)) - 1000 == (2451 - 1000)) + solve + + assert_equal :success, @routing.status + assert_equal [0, 1, 2, 0], route + end + + def test_var_product_const + build_routing + @routing.solver.add(@distance_dimension.cumul_var(@manager.node_to_index(1))*2 <= 4910) + solve + + assert_equal :success, @routing.status + assert_equal [0, 1, 2, 0], route + end + + def test_const_product_var + build_routing + @routing.solver.add(2*@distance_dimension.cumul_var(@manager.node_to_index(1)) <= 4910) + solve + + assert_equal :success, @routing.status + assert_equal [0, 1, 2, 0], route + end + + def test_var_product_var + build_routing + @routing.solver.add(@distance_dimension.cumul_var(@manager.node_to_index(1))*@distance_dimension.cumul_var(@manager.node_to_index(2)) == (2451*(2451 + 1745))) + solve + + assert_equal :success, @routing.status + assert_equal [0, 1, 2, 0], route + end + + def test_negate_var + build_routing + @routing.solver.add(-@distance_dimension.cumul_var(@manager.node_to_index(1)) > -2455) + solve + + assert_equal :success, @routing.status + assert_equal [0, 1, 2, 0], route + end + private def build_routing