Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions cpp/src/routing/crossovers/ox_recombiner.cuh
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ struct OX {
return graphs_size + sol_arrays_size + helper_arrays_size;
}

/// @brief OX recombination of two parents into A (offspring built in place).
/// In fixed_route mode (fleet_size == min_vehicles) the vehicle count
/// is preserved; otherwise the offspring route count may vary.
/// @param A first parent; on success, replaced by the recombined offspring.
/// @param B second parent (read-only donor genome).
/// @return true if a valid offspring was produced and applied to A; false if
/// recombination was rejected (e.g. mismatched parents, fixed-route
/// count violation, size/memory guards) — A is then left unchanged.
bool recombine(Solution& A, Solution& B)
{
raft::common::nvtx::range fun_scope("ox");
Expand Down Expand Up @@ -204,7 +212,13 @@ struct OX {
routes_number = std::min(routesA.size(), routesB.size());

const auto& dimensions_info = A.problem->dimensions_info;
if (dimensions_info.has_dimension(dim_t::VEHICLE_FIXED_COST)) {
// Optimal-routes search lets Bellman-Ford pick a variable number of routes to minimize
// vehicle fixed cost. This is incompatible with fixed_route mode (fleet_size ==
// min_vehicles): the vehicle count cannot change, so searching over route counts both
// breaks the recreate_solution invariant (routes removed == routes added) and can drift
// the solution below min_vehicles. When the route count is fixed there is nothing to
// optimize over, so keep the strict fixed-route path.
if (!fixed_route && dimensions_info.has_dimension(dim_t::VEHICLE_FIXED_COST)) {
routes_number = max_vehicle_increase + std::max(routesA.size(), routesB.size());
optimal_routes_search = true;
}
Expand Down Expand Up @@ -390,9 +404,13 @@ struct OX {
--i;
}

if (fixed_route) {
cuopt_assert(routes_to_remove.size() == tmp_routes.size(),
"number of routes removed and routes added should be same");
if (fixed_route && routes_to_remove.size() != tmp_routes.size()) {
// In fixed_route mode the vehicle count must be preserved: we add one route per changed
// offspring segment (tmp_routes) and remove the distinct original routes those segments
// touch (routes_to_remove). A mismatch would change the vehicle count and drop it below
// min_vehicles, so reject this offspring instead of applying it.
cuopt_assert(false, "number of routes removed and routes added should be same");
return false;
}
if (routes_to_remove.size() == 0 || tmp_routes.size() == 0) { return false; }

Expand Down
17 changes: 16 additions & 1 deletion cpp/src/routing/local_search/cycle_finder/cycle.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#pragma once

#include <utilities/copy_helpers.hpp>
#include <utilities/macros.cuh>
#include <utilities/vector_helpers.cuh>
#include "../../solution/solution_handle.cuh"

Expand Down Expand Up @@ -55,11 +56,25 @@ struct ret_cycles_t {
}

struct view_t {
DI void push_back(i_t val) { paths[offsets[*n_cycles_] + curr_cycle_size++] = val; }
/// Append a vertex to the in-progress cycle (`*n_cycles_`): writes at
/// `offsets[*n_cycles_] + curr_cycle_size` into the flat `paths` buffer and
/// bumps the per-cycle counter. Caller resets `curr_cycle_size` per cycle;
/// append_cycle() finalizes it.
DI void push_back(i_t val)
{
const auto write_pos = offsets[*n_cycles_] + curr_cycle_size++;
cuopt_assert(write_pos < (i_t)paths.size(),
"ret_cycles paths overflow: increase cycle buffer size");
paths[write_pos] = val;
}

/// Close the current cycle: advance the cycle count and store its end as the
/// next offsets[] prefix-sum boundary (offsets[c] = offsets[c-1] + cycle_size).
DI void append_cycle(i_t cycle_size)
{
*n_cycles_ += 1;
cuopt_assert(*n_cycles_ < (i_t)offsets.size(),
"ret_cycles offsets overflow: increase cycle buffer size");
offsets[*n_cycles_] = offsets[*n_cycles_ - 1] + cycle_size;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,11 @@ class move_path_t {
}
}

/// Reset per-iteration move state
void reset(solution_handle_t<i_t, f_t> const* sol_handle)
{
constexpr i_t zero_val = 0;
n_insertions.set_value_async(zero_val, sol_handle->get_stream());
// set_value_to_zero_async() is capture-safe (no host source).
n_insertions.set_value_to_zero_async(sol_handle->get_stream());
async_fill(loop_closed, 1, sol_handle->get_stream());
async_fill(changed_routes, 0, sol_handle->get_stream());
}
Expand Down
9 changes: 5 additions & 4 deletions cpp/src/routing/solver.cu
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* clang-format off */
/*
* SPDX-FileCopyrightText: Copyright (c) 2021-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
/* clang-format on */
Expand Down Expand Up @@ -65,9 +65,10 @@ assignment_t<i_t> solver_t<i_t, f_t>::solve()
// TODO accept a settings object once we have full feature in ges solver
// We only set target vehicles and use fixed route loop in the below case. The other paths will
// run regular fixed route loop.
auto target_vehicles = -1;
if (data_view_ptr_->get_fleet_size() == data_view_ptr_->get_min_vehicles()) {
target_vehicles = data_view_ptr_->get_min_vehicles();
auto target_vehicles = -1;
const auto min_vehicles = data_view_ptr_->get_min_vehicles();
if (min_vehicles > 0 && data_view_ptr_->get_fleet_size() >= min_vehicles) {
target_vehicles = min_vehicles;
}

const bool is_pdp = data_view_ptr_->get_pickup_delivery_pair().first != nullptr;
Expand Down
72 changes: 72 additions & 0 deletions python/cuopt/cuopt/tests/routing/test_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import os
import numpy as np
import pytest

import cudf

Expand Down Expand Up @@ -262,3 +263,74 @@ def test_prize_collection():
assert objectives[routing.Objective.COST] == 13.0
assert sol.get_status() == 0
assert sol.get_vehicle_count() >= 2


# Cost matrix from issue #904 (7 locations: depot 0 + orders 1-6)
_ISSUE_904_COST_MATRIX = [
[0, 17, 12, 11, 10, 18, 10],
[16, 0, 15, 11, 19, 15, 16],
[19, 19, 0, 11, 16, 11, 17],
[17, 19, 17, 0, 11, 18, 19],
[10, 19, 19, 19, 0, 17, 15],
[12, 18, 15, 18, 18, 0, 14],
[12, 12, 11, 19, 10, 17, 0],
]


def _build_min_vehicles_data_model(vehicle_fixed_costs, min_vehicles=3):
"""Builds min vehicles regression data model"""
n_locations = 7
n_vehicles = 3
n_orders = 6
dm = routing.DataModel(n_locations, n_vehicles, n_orders)
dm.add_cost_matrix(
cudf.DataFrame(_ISSUE_904_COST_MATRIX).astype(np.float32)
)
# Capacity 10 lets all 6 orders fit on one vehicle; min_vehicles must still
# force 3 routes.
dm.add_capacity_dimension(
"demand",
cudf.Series([1] * n_orders, dtype=np.int32),
cudf.Series([10] * n_vehicles, dtype=np.int32),
)
dm.set_order_locations(cudf.Series([1, 2, 3, 4, 5, 6], dtype=np.int32))
dm.set_vehicle_fixed_costs(
cudf.Series(vehicle_fixed_costs, dtype=np.float32)
)
dm.set_min_vehicles(min_vehicles)
dm.set_objective_function(
cudf.Series(
[routing.Objective.COST, routing.Objective.VEHICLE_FIXED_COST]
),
cudf.Series([1.0, 1.0], dtype=np.float32),
)
return dm


@pytest.mark.parametrize(
"vehicle_fixed_costs",
[
[10.0, 20.0, 30.0], # non-zero fixed costs (the bug case in #904)
[
0.0,
0.0,
0.0,
], # zero fixed costs (H100 12.2/13.1 compat crashed in debug)
],
)
def test_min_vehicles_respected(vehicle_fixed_costs):
"""
Regression for https://github.com/NVIDIA/cuopt/issues/904.
Verifies that min_vehicles is respected and no crash occurs, regardless of
vehicle fixed costs.
"""
dm = _build_min_vehicles_data_model(
vehicle_fixed_costs=vehicle_fixed_costs
)
ss = routing.SolverSettings()
ss.set_time_limit(3)

sol = routing.Solve(dm, ss)

assert sol.get_status() == 0, sol.get_error_message()
assert sol.get_vehicle_count() >= 3