Skip to content
Draft
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
128 changes: 128 additions & 0 deletions python/cuopt/cuopt/routing/_recording.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Store-then-build recording layer for the routing DataModel.

The public DataModel records each mutating setter call instead of pushing to the
GPU immediately, and materializes the Cython/device data model only when a solve
runs (or a getter is queried) by replaying the recorded calls onto the wrapper.

The recorded setters/getters mirror the Cython wrapper's method surface and are
installed automatically *from the wrapper* (see ``_install_methods``): every
``set_*``/``add_*`` becomes a recorder and every ``get_*`` a delegator. So there
is a single source of truth -- adding a method to the wrapper/DataModel needs no
change here. Only the size scalars are written out explicitly below (they answer
without a build); that explicit definition is left untouched by the auto-install.

A public setter that needs to query prior state before build (e.g. a duplicate
guard) reads it from the recorded calls via ``_recorded`` -- there is no shadow
state to maintain per setter.
"""

# ``get_*`` methods on the wrapper that are helpers, not problem-data getters.
_SKIP_GETTERS = frozenset({"get_type_from_str", "get_type_from_int"})

_methods_installed = False


class _RecordingDataModel:
"""Records DataModel setter calls and builds the device model lazily."""

def __init__(self, num_locations, fleet_size, n_orders=-1):
_install_methods()
self._init_args = (num_locations, fleet_size, n_orders)
self._calls = []
self._built = None

# -- size scalars: answered without building (queried during validation) --
def get_num_locations(self):
return self._init_args[0]

def get_fleet_size(self):
return self._init_args[1]

def get_num_orders(self):
n_orders = self._init_args[2]
return self._init_args[0] if n_orders == -1 else n_orders

# -- record / build --
def _record(self, name, args, kwargs):
self._calls.append((name, args, kwargs))
self._built = None

def _recorded(self, name):
"""Positional args of each prior recorded call to ``name``.

Lets a public setter answer set-time "already set?" questions from the
recorded calls, so no per-setter shadow state is needed.
"""
return [args for call, args, _ in self._calls if call == name]

def _build(self):
"""Materialize the device (Cython) data model by replaying calls."""
if self._built is None:
model = _built_cls()(*self._init_args)
for name, args, kwargs in self._calls:
getattr(model, name)(*args, **kwargs)
self._built = model
return self._built


def _make_setter(name):
def _setter(self, *args, **kwargs):
self._record(name, args, kwargs)

_setter.__name__ = name
return _setter


def _make_getter(name):
def _getter(self, *args, **kwargs):
return getattr(self._build(), name)(*args, **kwargs)

_getter.__name__ = name
return _getter


def _install_methods():
"""Mirror the wrapper's setter/getter surface onto _RecordingDataModel.

Derived from the wrapper so there is nothing to keep in sync. Methods
already defined on _RecordingDataModel (the explicit overrides above) are
skipped. Done once, lazily, on first construction so importing this module
does not require importing the wrapper.
"""
global _methods_installed
if _methods_installed:
return
from . import vehicle_routing_wrapper as _wrapper

for name in dir(_wrapper.DataModel):
if name.startswith("_") or name in _RecordingDataModel.__dict__:
continue
if name.startswith(("set_", "add_")):
setattr(_RecordingDataModel, name, _make_setter(name))
elif name.startswith("get_") and name not in _SKIP_GETTERS:
setattr(_RecordingDataModel, name, _make_getter(name))
_methods_installed = True


_BUILT_CLS = None


def _built_cls():
"""Return a Python subclass of the Cython wrapper DataModel.

The wrapper is a ``cdef class`` with no ``__dict__``; its ``__init__``
stores Python attributes (``self.costs`` etc.), so it must be subclassed by
a Python class to be instantiable. Imported lazily.
"""
global _BUILT_CLS
if _BUILT_CLS is None:
from . import vehicle_routing_wrapper as _wrapper

class _BuiltDataModel(_wrapper.DataModel):
pass

_BUILT_CLS = _BuiltDataModel
return _BUILT_CLS
16 changes: 11 additions & 5 deletions python/cuopt/cuopt/routing/vehicle_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from cuopt import routing
from cuopt.routing import vehicle_routing_wrapper
from cuopt.routing._recording import _RecordingDataModel
from cuopt.utilities import catch_cuopt_exception

from .validation import (
Expand All @@ -19,7 +20,7 @@
)


class DataModel(vehicle_routing_wrapper.DataModel):
class DataModel(_RecordingDataModel):
"""

DataModel(n_locations, n_fleet, n_orders: int = -1)
Expand Down Expand Up @@ -134,7 +135,7 @@ def add_cost_matrix(
>>> data_model.add_cost_matrix(cost_mat_car, 2)
"""

if vehicle_type in self.costs:
if vehicle_type in {a[1] for a in self._recorded("add_cost_matrix")}:
raise ValueError("Vehicle type matrix has already been added")

if not skip_validation:
Expand Down Expand Up @@ -216,7 +217,9 @@ def add_transit_time_matrix(self, mat, vehicle_type=0):
>>> time_mat = cudf.DataFrame(time_mat)
>>> data_model.add_transit_time_matrix(time_mat, 0)
"""
if vehicle_type in self.transit_times:
if vehicle_type in {
a[1] for a in self._recorded("add_transit_time_matrix")
}:
raise ValueError("Vehicle type matrix has already been added")

validate_matrix(mat, "transit time matrix", self.get_num_locations())
Expand Down Expand Up @@ -1546,7 +1549,9 @@ def Solve(data_model, solver_settings=None):
if solver_settings is None:
solver_settings = SolverSettings()

solution = vehicle_routing_wrapper.Solve(data_model, solver_settings)
solution = vehicle_routing_wrapper.Solve(
data_model._build(), solver_settings
)
if solver_settings.get_config_file_name() is not None:
routing.utils.save_data_model_to_yaml(
data_model,
Expand Down Expand Up @@ -1603,4 +1608,5 @@ def BatchSolve(data_model_list, solver_settings=None):
if solver_settings is None:
solver_settings = SolverSettings()

return vehicle_routing_wrapper.BatchSolve(data_model_list, solver_settings)
built_list = [dm._build() for dm in data_model_list]
return vehicle_routing_wrapper.BatchSolve(built_list, solver_settings)
27 changes: 27 additions & 0 deletions python/cuopt/cuopt/tests/routing/test_recording.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

from cuopt import routing
from cuopt.routing import vehicle_routing_wrapper
from cuopt.routing._recording import _SKIP_GETTERS


def test_recording_covers_wrapper_surface():
"""Every public wrapper DataModel method must be handled by the recording
layer (installed as a recorder/getter or an explicit override). This fails
loudly if a new wrapper method -- e.g. a mutator not named set_*/add_* --
is added without being recorded, instead of silently dropping its data.
"""
dm = routing.DataModel(1, 1) # triggers _install_methods
handled = set(dir(type(dm)))
missing = [
name
for name in dir(vehicle_routing_wrapper.DataModel)
if not name.startswith("_")
and name not in _SKIP_GETTERS
and name not in handled
]
assert not missing, (
f"recording layer does not handle wrapper methods {missing}; "
"add a recorder/getter or list them in _SKIP_GETTERS"
)
32 changes: 20 additions & 12 deletions python/cuopt/cuopt/tests/routing/test_warnings_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,23 @@ def test_type_casting_warnings():
constraints["service"] = [2.5, 2.5, 2.5]

dm = routing.DataModel(3, 2)
with warnings.catch_warnings(record=True) as w:
dm.add_cost_matrix(cost_matrix)
assert "Casting cost_matrix from int64 to float32" in str(w[0].message)

dm.set_order_time_windows(
constraints["earliest"], constraints["latest"]
)
dm.add_cost_matrix(cost_matrix)
dm.set_order_time_windows(constraints["earliest"], constraints["latest"])
dm.set_order_service_times(constraints["service"])

dm.set_order_service_times(constraints["service"])
assert "Casting service_times from float64 to int32" in str(
w[1].message
)
# DataModel records inputs and converts them to device only when the model
# is built (store-then-build), so the dtype-cast warnings surface at build
# time. get_cost_matrix triggers the build that replays the recorded setters.
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
dm.get_cost_matrix(0)
messages = [str(rec.message) for rec in w]
assert any(
"Casting cost_matrix from int64 to float32" in m for m in messages
)
assert any(
"Casting service_times from float64 to int32" in m for m in messages
)


# ----- Validation (matrix, time windows, range) -----
Expand Down Expand Up @@ -124,6 +129,9 @@ def test_range():


def test_invalid_datamodel():
# Validation is deferred to build/solve time (store-then-build): the empty
# model is recorded at construction and rejected when it is solved.
dm = routing.DataModel(0, 0, 0)
with pytest.raises(InputValidationError) as err:
routing.DataModel(0, 0, 0)
routing.Solve(dm)
assert str(err.value) == "The data model needs at least one location"