diff --git a/python/cuopt/cuopt/routing/_recording.py b/python/cuopt/cuopt/routing/_recording.py new file mode 100644 index 000000000..5e202774a --- /dev/null +++ b/python/cuopt/cuopt/routing/_recording.py @@ -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 diff --git a/python/cuopt/cuopt/routing/vehicle_routing.py b/python/cuopt/cuopt/routing/vehicle_routing.py index d441a1828..fc142325c 100644 --- a/python/cuopt/cuopt/routing/vehicle_routing.py +++ b/python/cuopt/cuopt/routing/vehicle_routing.py @@ -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 ( @@ -19,7 +20,7 @@ ) -class DataModel(vehicle_routing_wrapper.DataModel): +class DataModel(_RecordingDataModel): """ DataModel(n_locations, n_fleet, n_orders: int = -1) @@ -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: @@ -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()) @@ -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, @@ -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) diff --git a/python/cuopt/cuopt/tests/routing/test_recording.py b/python/cuopt/cuopt/tests/routing/test_recording.py new file mode 100644 index 000000000..81576191e --- /dev/null +++ b/python/cuopt/cuopt/tests/routing/test_recording.py @@ -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" + ) diff --git a/python/cuopt/cuopt/tests/routing/test_warnings_exceptions.py b/python/cuopt/cuopt/tests/routing/test_warnings_exceptions.py index aafcb45a6..d8c8f8f74 100644 --- a/python/cuopt/cuopt/tests/routing/test_warnings_exceptions.py +++ b/python/cuopt/cuopt/tests/routing/test_warnings_exceptions.py @@ -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) ----- @@ -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"