scikit-route 2.0 — Definitive specification¶
Status: FROZEN for implementation. Changes go through a pull request against this document, approved by the lead. Written 2026-09-03 after reading the 1.0.0a2 tree at /Users/albertorubialesborrego/scikit-route (branch modernization/v2), the four proposals and the three scorecards. Facts below marked (verified) were checked against the repository today.
1. Executive summary and decisions¶
scikit-route 2.0 is a scikit-learn-flavoured library of route optimisation solvers with one problem model: a closed tour from a depot over a dense cost matrix, optionally decoded into several trips under a per-trip working-time budget with a fixed charge per additional trip. Every solver is an estimator (__init__ stores knobs, fit(X, ...) returns self, results in *_ attributes). One shared Cython 3 core evaluates costs and move deltas over typed memoryviews; each solver may ship its own .pyx next to its .py and cimports the core's inline primitives, so eight people can write hot loops without touching a shared file. The base class builds one immutable RoutingProblem, validates the tour every _solve returns and recomputes cost_ from it, which makes the 2020 "route does not match cost" bug impossible by construction. Plain TSP is est.fit(C); the multi-trip objective is est.fit(C, time_matrix=T, max_time_work=8, extra_cost=12.83, people=2) and is available to every budget-aware solver through the same kernels. 2.0 ships 15 solver classes (16 with AntColony; 14 if HeldKarp is also deferred) plus MultiStart and the two compatibility wrappers EnsembleGenetic/EnsembleSimulatedAnnealing; pandas and googlemaps are optional. Tests are deterministic through random_state, exact on n ≤ 9, and benchmarked against the Waterloo optima with measured tolerances. Version is 2.0.0 with a migration guide.
Decisions (binding)¶
- D1 Objective = giant tour + split rule. A solution is a permutation of all nodes with the depot first (the giant tour). Its cost is travel cost of the decoded trips plus
people * extra_cost * (n_trips - 1). Two decoders exist in the core, chosen byfit(..., split=):"greedy"(default: lega→bjoins the open trip ifft + T[a,b] + T[b,depot] <= max_time_work, else close ata, reopendepot→b) and"optimal"(Prins 2004: the minimum-cost partition into consecutive feasible trips, a DAG shortest path, O(n·L)). Greedy is the default because it is O(n), it is what the measured prototype implements, and it is the honest successor of the 1.0 rule; optimal is one flag away and is never worse for a given tour. Every trip, including the return leg, fits the budget under both rules. - D2 The base class recomputes
cost_from the returned tour with the problem's own decoder and raisesRuntimeErrorif_solvereturns anything but a permutation with the depot at position 0. Solvers never report a cost. - D3
max_time_workwithouttime_matrixraisesValueError("max_time_work given but no time_matrix; pass time_matrix=X to use the cost matrix as durations"). Silent defaulting hides unit mistakes (euros read as hours). - D4
peoplemultiplies onlyextra_cost. 1.0 multiplied travel cost too (contradicting its docstring); a global factor never changes the argmin, so only reported costs change. Documented in the migration guide. - D5 A node whose round trip exceeds the budget raises
InfeasibleProblemErrorat fit, naming the labels. - D6 Exact solvers that cannot certify the multi-trip objective (HeldKarp, MILP) raise when a budget is given; non-exact budget-unaware solvers (NearestNeighbour, Insertion, NRBS, SOM) warn and their result is still decoded and priced under the objective. An "exact" result that is not optimal for the objective is a trap; a heuristic one is merely a heuristic.
- D7 Data to
fit, knobs to__init__. Signature:fit(X, *, time_matrix=None, depot=None, coords=None, labels=None, max_time_work=None, extra_cost=0.0, people=1, split="greedy").time_matrixis keyword-only (also inRoutingProblem): 1.0 took(route_example, time_matrix, cost_matrix), so a migrating user who keeps the positional order would swap the two square matrices without any error and price hours as euros; the keyword makes the swap impossible.Xmay also be a readyRoutingProblem. Noy. Warm starts areinit=in__init__(KMeans precedent). - D8 Outputs are label-space ndarrays:
tour_(open, depot first),route_(as driven: depot first and last, re-inserted between trips),trips_(list of closed per-trip arrays). Index space never leaks.init=acceptstour_orroute_of another solver. - D9
history_is best-so-far per outer iteration (monotone non-increasing),n_iter_ == len(history_),stop_reason_ in {"converged","max_iter","patience","time_limit"}; these three exist only on iterative solvers. Each solver documents the subset of stop reasons it can emit (a solver withoutpatience/time_limitparameters never emits those values; the fitted-attribute table of §3.4 lists the subsets).trip_times_exists only when a time matrix was given. - D10 Randomness is pre-drawn in Python from
numpy.random.default_rng(random_state)and passed tonogilkernels as arrays. Bit-exact reproducibility, no numpy C-API at build time, thread-parallelMultiStart. Nobitgen_tin 2.0. - D11 Kernel topology: one core extension
skroute/_core/_routingwhosecdef inlineprimitives are defined, with their bodies, in_routing.pxd(the only way Cython shares inline code across extension modules — a body-lesscdef inlinedeclaration does not compile in the cimporting module, verified with Cython 3.3); the non-inlinecdef/cpdeffunctions are declared in the.pxdand defined in_routing.pyx. Per-solver.pyxfiles live next to their.py;setup.pyglobsskroute/**/*.pyx. Nocimport numpyanywhere, so wheels are numpy-ABI independent. - D12 No pure-Python fallback ships. Wheels cover CPython 3.11–3.14 × Linux/macOS/Windows; the pure-Python oracles live in
tests/reference.pyand are written by a non-kernel owner. - D13 Depot is stored as an index; the tour is rotated so
tour[0] == depot. No matrix permutation/copy (a second 900 MB at n = 10 639). - D14 ATSP is allowed everywhere except
ClarkeWright(tagrequires_symmetric). Reversal deltas are O(1) only when symmetric; otherwise kernels take the full-evaluation path (same path as multi-trip). MILP handles ATSP with arc variables. - D15 TSPLIB rounding is
nint(x) = floor(x + 0.5), nevernp.rint(half-to-even proved 9351 on qa194 against the published 9352). - D16 Layout: flat package,
tests/at the repo root and not a package (notests/__init__.py, notests/benchmarks/__init__.py): with a package, pytest's default import mode would put the repo root onsys.pathandimport skroutewould resolve to the uncompiled source tree instead of the installed wheel in the cibuildwheel and sdist jobs (verified).pyproject.tomlsets[tool.pytest.ini_options] testpaths = ["tests"],pythonpath = ["tests"],addopts = "-m 'not slow' --strict-markers",doctest_optionflags = "NORMALIZE_WHITESPACE ELLIPSIS NUMBER"; every test imports the oracle asimport reference. cibuildwheel:test-command = "pytest {project}/tests -x -q"(no-mclause:addoptsalready deselectsslow, and single quotes are not quoting characters forcmd.exe, which is what cibuildwheel uses on Windows),test-environment = { SKROUTE_EXPECT_WHEEL = "1" };tests/test_base.py::test_runs_against_installed_copyasserts"site-packages" in skroute.__file__whenever that variable is set (the sdist job sets it too). Neverpython -m pytestin the wheel/sdist jobs (it adds the cwd tosys.path). - D17
MultiStartuses joblib threads by default (prefer="threads"), seeds fromSeedSequence.spawn, results independent ofn_jobs.EnsembleGenetic/EnsembleSimulatedAnnealingare explicit-parameter subclasses. - D18 Roster: exact
BruteForce, HeldKarp (P1), MILP; constructionNearestNeighbour, Insertion, ClarkeWright, NRBS; local searchTwoOpt, OrOpt, LocalSearch, IteratedLocalSearch; metaheuristicsSimulatedAnnealing, TabuSearch, Genetic, AntColony (P1), SOM; ensembleMultiStart, EnsembleGenetic, EnsembleSimulatedAnnealing. That is 15 solvers (16 with AntColony; 14 if HeldKarp is also deferred) +MultiStart+ two compatibility wrappers. There are noCheapestInsertion/FarthestInsertionaliases:Insertion(strategy=...)is the only spelling (aliases add nothing over the parameter, breakcheck_routeritem 1 and inflate the roster). Deferred to 2.1: Christofides (needs a real blossom matching; never a greedy fake), Or-3opt/Lin–Kernighan, VNS, multi-trip MILP, float32 matrices, on-the-fly distances and coordinate-only fitting (SOM without a matrix). - D19 Benchmark tolerances are the measured ones (Proposal 4's baseline), tightened via release notes; no exact-float golden pins across platforms — reproducibility is asserted as "same seed, same machine, bit-identical".
- D20 pandas is optional (
scikit-route[pandas]); DataFrames are recognised by duck typing; loaders return numpyBunches and takeas_frame=True; CSVs are parsed with the stdlibcsvmodule (verified: addresses contain commas). - D21 Python ≥ 3.11; CI 3.11–3.14 plus 3.15 pre-release allowed-failure; runtime
numpy>=1.26,scipy>=1.11,joblib>=1.3. - D22 Version 2.0.0.
fitno longer returns a tuple, problem data moves from__init__tofit, four hard dependencies disappear, objective semantics change. - D23 Declarative parameter validation (
_parameter_constraintswithInterval/Options), deepget_params/set_paramswith__nesting, print-changed-onlyrepr, aRouterTagsprotocol, and a publiccheck_router()battery. - D24 No printing anywhere.
verboseroutes tologging.getLogger("skroute"). Because Python's last-resort handler shows only WARNING and above,verbose=1would otherwise look like "does nothing":skroute/__init__.pyattaches alogging.NullHandler()to theskroutelogger and exposesskroute.set_log_level(level)(sets the level and attaches aStreamHandlerto stderr if the logger has only theNullHandler); everyverbosedocstring ends with the sentence "Records go to theskroutelogger at INFO; enable them withlogging.basicConfig(level=logging.INFO)orskroute.set_log_level("INFO").";getting_started.mdshows it once. - D25 Hyper-parameter glossary (§4.0) is mandatory vocabulary in every solver and docstring.
- D26 Priorities and dates. P0 = must ship in 2.0.0. P1 = ships only if its owner finishes it (code, tests, docs page) by the feature freeze on 2026-10-16; otherwise the owner hands the lead a written deferral PR by that date and the item moves to 2.1 with its name not exported (procedure in §4.4). P1 items:
HeldKarp(WP2; MILP is instant at n ≤ 20 and BruteForce covers n ≤ 11),AntColony(WP6), theGEOandATTedge-weight types ofread_tsplib(WP7;EUC_2D,CEIL_2D,EXPLICITandread_tsplib_tourare P0 — all 27 bundled files areEUC_2D),GoogleDistanceMatrix(WP7).split="optimal"insidelocal_search_genericstays P0 (D1 promises the optimal split to every budget-aware solver). Release candidate 2026-10-23 (benchmarks page regenerated, nightly green on three OSes), release 2026-10-30. - D27
all_solvers()and the two halves of the test battery.skroute.all_solvers() -> list[type[BaseRouter]]returns, sorted by__name__, every concrete solver class exported fromskroutethat can be instantiated with no arguments: the 15 (16) solvers of D18 plusEnsembleGeneticandEnsembleSimulatedAnnealing;MultiStartis never returned (it needs an estimator) and is covered bytests/test_ensemble.py.check_router(estimator)(an unfitted instance) runs only the structural checks 1–11, 13 and 14 of §6 on instances it builds itself; the tolerance checks live intests/test_common.pyand are driven bytests/tolerances.py, the single source of every tolerance number. - D28
RouterTags.kindand an honest default.RouterTagsgainskind in {"exact", "construction", "local_search", "metaheuristic", "ensemble"}(used by the capability table and by the tolerance tests) andbudget_awaredefaults toFalse: a solver whose author forgets_get_tags()must be advertised as budget-unaware (it warns), never as budget-aware. - D29 Execution model (lead, 2026-09-03). The work packages are executed by parallel agents in separate git worktrees (
~/scikit-route-wt/<wp>, branchwp/<wp>) merged by the lead, in two waves: wave A = spine (base, problem, utils incl.estimator_checks.py,tests/conftest.py,tests/tolerances.py,tests/test_common.py), core (WP1), datasets/preprocessing (WP7 data), community files (WP8 docs part); wave B = every solver package (WP2–WP6, WP7 SOM, WP8 ensemble/shims/user guide) against the merged, compiled core. Consequences: there is noSKROUTE_REFERENCEswitch and no_core/_reference.py; during development the package is never installed — tests run aspython -m pytest testsfrom the worktree root (the interpreter puts the cwd onsys.path; the wheel and sdist CI jobs use thepytestentry point exactly as D16 requires); the calendar of D26 becomes 'ship in this delivery, or defer with the written procedure'. - D30 Progress callbacks and live visualisation (lead, 2026-09-04). Every solver accepts
fit(..., callback=None).callbackis any callable taking oneskroute.base.RouteEventand returningNoneor a bool; returningTrueasks the solver to stop after the current outer iteration (stop_reason_ = "callback", added to the allowed subset of every iterative solver).RouteEventis a frozen dataclass:solver(class name),stage("start","iteration","end"),iteration(outer iteration index, 0 at start),cost(objective oftouras the solver knows it,nanwhen unknown),best_cost,tour(label-space open giant tour of the CURRENT solution, depot first, orNonewhen the solver has no tour yet — MILP before its first integral solution),best_tour(label-space best-so-far tour),problem(theRoutingProblem),extra(a dict of solver-specific facts:temperature,tenure,generation,n_evaluations,kick,moves_applied,edgesfor MILP's current support,restartfor MultiStart), plus the propertiesrouteandtrips(decoded frombest_tourwith the problem's split rule). The base class implements_emit(stage, iteration, tour_idx, cost, best_tour_idx, best_cost, **extra)— a no-op when no callback is set, so the fit path stays free of overhead — and the flag_stop_requested; every iterative solver emitsstartonce,iterationonce per outer iteration (where it updateshistory_) andendonce; construction and exact solvers emitstartandend(MILP also oneiterationper cut round withextra["edges"]; since D31 the construction solvers also emit oneiterationper construction step withtour=None,cost=nanandextra["edges"]);MultiStartforwards the callback to the inner estimators only whenn_jobsisNoneor1(matplotlib is not thread-safe; with a callback the sequential path also ignores an enclosingjoblib.parallel_config) and emits its ownstart/end.check_routergains check 14: the event sequence is well formed, every non-Nonetour is a valid label permutation,best_costis non-increasing, and returningTruefrom the callback stops an iterative solver withstop_reason_ == "callback". The packageskroute.viz(extraviz:matplotlib>=3.8;plotly>=5.24optional for maps) offersplot_route(...)(points and the closed tour, one colour per trip),plot_history(...),LivePlot(coords, backend=None, map=False, every=1)(backendresolves to"matplotlib", or to"plotly"whenmap=True;"plotly"may also be asked for explicitly) — a callback that redraws the current and best tours whilefitruns, in scripts (interactive matplotlib) and notebooks (PlotlyFigureWidgetorIPython.display) — andRecorder()— a callback that keeps every event and produces a matplotlibFuncAnimation(.animate(coords), savable as GIF) or a Plotly figure with a slider (.to_plotly(coords, map=...));map=Truedraws on OpenStreetMap tiles through Plotly'sScattermapfor(lat, lon)coordinates. Nothing inskroute.vizis imported by the rest of the package. - D31 Traces of partial structures and time-lapse replay (lead, 2026-09-04). The owner wants to watch the route being built and to replay a run at high speed, for every solver family. Three optional
extrakeys are standardised onRouteEvent(viewers ignore keys they do not know):extra["edges"]— a list of(label, label)pairs describing the partial structure the solver holds whentourisNoneor incomplete (the growing path of NearestNeighbour, the partial cycle of Insertion after each insertion, the current trips of ClarkeWright after each merge, the edge set of NRBS after each connection, the LP support of MILP);extra["edge_weights"]— a list of floats in[0, 1]parallel toextra["edges"](AntColony's pheromone strength of its strongest3ntrails after each iteration);extra["ring"]— an(m, 2)float array with the positions of SOM's neurons in the units ofproblem.coordsafter each epoch. Construction solvers emit oneiterationevent per construction step withtour=None,cost=nan,best_cost=nanandextra["edges"](replayed from the kernel's recorded insertion order when the step happens inside Cython; this costs nothing without a callback);check_routertreatsnancosts as unknown (excluded from the monotonicity check) and requires thatextra["edges"]pairs are labels of the problem.Recorderstoresextraand a wall-clock timestamp per event;Recorder.animate(coords, show="both"|"best"|"current", speed=..., fps=...)draws the current tour thin and the best tour thick (plus edges, weights and ring when present),Recorder.replay(coords, speed=...)drives aLivePlotat time-lapse speed respecting the recorded timings,Recorder.save(path, fps=..)writes GIF (pillow) or MP4 (ffmpeg when available), andRecorder.to_plotlyoffers Play/Pause and a speed menu.LivePlotdraws the same three extras live. The README GIF shows the attempts and the best tour of a metaheuristic; the user guide shows a construction heuristic growing node by node and SOM's ring closing on the cities. - D32 Service time (lead, 2026-09-05). A visit takes time.
RoutingProblem(..., service_time=None),fit(..., service_time=None)androute_cost(..., service_time=None)accept a scalar or an(n,)array-like in the units oftime_matrix(finite,>= 0; requiresmax_time_work, liketime_matrix; a scalar applies to every non-depot node). The kernels receive the effective time matrixT_eff[i, j] = T[i, j] + service[j]forj != depotandT_eff[i, depot] = T[i, depot], plusT_eff[depot, j] += service[depot](a service at the depot is paid once per trip, at departure);problem.timestays the raw travel-time matrix,problem.service_timeis the(n,)float64 array (all zeros when not given),problem.time_or_costreturnsT_eff, andtrip_timestherefore include the services. The feasibility check of the depot round trips usesT_eff(message names the node and the service).check_routercheck 8 (multi-trip) gains one instance withservice_time;fit_time_unaffected.skroute.metrics.timetable(obj, route=None, *, start="08:00", units="min", as_frame=False) -> list[list[Stop]]turns a fitted estimator (or a problem and a label-space route) into a per-trip timetable of frozenStop(day, order, label, arrival, departure, travel, service, wait=0.0, start=480.0)records — minutes sincestart, withStop.arrival_time/departure_timestringsHH:MM— computed fromproblem.timeandproblem.service_time(the depot appears as the first and last stop of every day: at order 0arrival = 0anddeparture = service[depot], i.e. the vehicle leaves atstartplus the depot's service; at the returnarrivalis the end of the day); pure Python, pandas optional throughas_frame=True;timetable_summary(days)totals every day. The final signatures and rules are in §3.3 'Service time'. - D33 Real-world inputs from map services (lead, 2026-09-05). New module
skroute.preprocessing.maps(re-exported fromskroute.preprocessing), standard library only (urllib.request,json), every function honouring atimeout=and retrying a failed HTTP call up to 3 times with back-off:travel_time_matrix(coords, *, provider="osrm", mode="driving", units="min", api_key=None, departure_time=None, base_url=None, chunk_size=None, user_agent=None) -> Bunch(time, distance, units, provider, coords)—coordsare(lat, lon)pairs; the matrices are square, float64, asymmetric when the roads are, in minutes (units["time"], also"s"/"h") and metres (units["distance"]); provider"osrm"calls the table service of an OSRM server (default the public demohttps://router.project-osrm.org, whose limits are respected by tiling the table inchunk_size x chunk_sizeblocks withsources/destinations, default 50, and by pausing between requests), provider"google"delegates toGoogleDistanceMatrix(requires the key and thegoogleextra;departure_time="now"or a datetime asks forduration_in_traffic), unroutable pairs becomenanwith one WARNING.geocode(query, *, provider="nominatim", api_key=None, user_agent=None, timeout=10) -> Bunch(lat, lon, display_name, raw)— Nominatim needs a descriptiveUser-Agent(defaultscikit-route/<version> (+https://github.com/arubiales/scikit-route)) and at most one request per second (enforced); provider"google"uses the Geocoding API.fetch_pois(area, *, brand=None, name=None, amenity=None, wikidata=None, provider="overpass", base_url=None, timeout=90) -> Bunch(coords, labels, names, addresses, tags, DESCR)— one Overpass QL query (GET ?data=— the public server answers 406 to form POSTs) selecting an administrative area by name (area["boundary"="administrative"]["name"=...]) andnwrelements matching the filters, usingout centerso ways and relations become points; labels are the OSM ids (node/123),DESCRcarries the ODbL attribution ("© OpenStreetMap contributors"). Tests never touch the network: recorded JSON answers undertests/data/maps/are served through a monkeypatched opener; anetworkmarker (deselected by default, run by the nightly) exercises the live services on three points. - D34 Schedules on Google Maps (lead, 2026-09-05). New module
skroute.viz.google_maps(importable without matplotlib; nothing here needs an API key except the JavaScript page):google_maps_urls(route_or_estimator, coords, *, mode="driving", max_waypoints=9) -> list[list[str]]— one Google Maps Directions URL per trip (https://www.google.com/maps/dir/?api=1&origin=...&destination=...&waypoints=...&travelmode=driving, coordinates aslat,lonwith 6 decimals), a trip longer thanmax_waypointsintermediate stops split into consecutive legs that share their end/start point (Google's documented limit for the URL scheme);to_kml(route_or_estimator, coords, path, *, names=None, depot_name="Depot", trip_names=None, colors=None) -> Path— a KML 2.2 document with oneFolderper trip holding numberedPlacemarks (name, description with the order and the day) and aLineStringof the closed trip, distinct line colours per trip, importable into Google My Maps and Google Earth (the docstring says how: My Maps → Create → Import);google_maps_html(route_or_estimator, coords, path, *, api_key=None, names=None, trip_names=None, title=None) -> Path— a standalone HTML page loading the Maps JavaScript API withapi_key(orGOOGLE_MAPS_API_KEYfrom the environment;ValueErrornaming the variable when both are missing) that draws every trip with aDirectionsService/DirectionsRenderer(requests of at most 25 waypoints, split like the URLs), numbered markers, one colour per trip, a legend with checkboxes toggling the days and the totals (stops, driving minutes) computed in Python and embedded as JSON; the key-free interactive alternative staysplot_route_map(Plotly on OpenStreetMap tiles), which gainsnames=hover labels andtrip_names=. Both modules are covered by tests that parse the produced URLs, KML (withxml.etree) and HTML (the JSON block) — no browser, no network. - D35 Worked case: a maintenance technician's route plan (lead, 2026-09-05).
examples/technician_madrid.pysolves the owner's case: every Burger King restaurant of the Comunidad de Madrid (from OpenStreetMap throughfetch_pois, brand:wikidata Q177054), the office at Calle Ramón y Cajal 18, Leganés (throughgeocode), real driving times (travel_time_matrix, OSRM by default, Google with--google-key/GOOGLE_MAPS_API_KEY), 30 minutes of service per restaurant (--service), 8-hour days (--hours) and one technician; the objective is total driving time withextra_cost=one day's budget per extra day (fewest days first, then least driving),split="optimal", solved withMultiStart(IteratedLocalSearch)(--solver,--time-limit), and reported as: a per-day timetable on the console,technician_madrid_timetable.csv, the KML, the Google Maps URLs, the Google Maps page when a key is given, the Plotly map (technician_madrid_map.html) and a PNG of the days. The data of the run (restaurants with names and coordinates, the office, and the OSRM matrix) are committed as CSV underexamples/data/with their attribution so the example is reproducible offline (--refreshfetches live data);docs/user_guide/real_world.mdnarrates the case with the resulting table and pictures, and states the assumptions (static travel times, no lunch break, every restaurant open when visited). Version 2.1.0.
2. Repository layout and dependencies¶
Every file that will exist at the 2.0.0 tag (plus the development-only docs/gen_pages.py and docs/check_api_coverage.py, which ship in the repository but not in the wheel). Owner codes in brackets refer to §8 (L = lead). Files marked keep already exist.
scikit-route/
pyproject.toml [L] PEP 621 metadata, deps, extras, tool config (ruff, mypy, pytest, cibuildwheel);
[tool.setuptools.package-data] skroute = ["py.typed", "**/*.pxd", "**/*.pyi",
"datasets/_data/tsplib/*.tsp", "datasets/_data/costs/*.csv", "datasets/_descr/*.md"]
(MANIFEST.in only governs sdists; wheels need this list)
setup.py [L] only cythonize(glob("skroute/**/*.pyx")) with the directives of §3.5
MANIFEST.in [L] recursive-include skroute *.pyx *.pxd *.pyi *.tsp *.csv *.md; include skroute/py.typed; drops *.pkl
README.md [L] badges, 30-second example, install, links (outline §7)
CHANGELOG.md [L] Keep a Changelog; 2.0.0 entry skeleton in §7
CITATION.cff [L] cff-version 1.2.0, title scikit-route, authors Alberto Rubiales, version 2.0.0, MIT, repository-code, date-released
LICENSE keep (MIT)
CONTRIBUTING.md [WP8]
CODE_OF_CONDUCT.md [WP8] Contributor Covenant 2.1
SECURITY.md [WP8]
mkdocs.yml [L] nav in §7
.pre-commit-config.yaml [L] ruff (lint+format), cython-lint, end-of-file, trailing-whitespace
.gitignore [L] keep + *.so *.c build/ site/ .venv*/
.github/CODEOWNERS [L] one owner per path of §8
.github/FUNDING.yml keep
.github/workflows/ci.yml [L]
.github/workflows/wheels.yml [L]
.github/workflows/docs.yml [L]
.github/workflows/nightly.yml [L]
.github/ISSUE_TEMPLATE/bug_report.yml, feature_request.yml [WP8]
.github/PULL_REQUEST_TEMPLATE.md [WP8]
benchmarks/kernels.py [WP1] micro-benchmarks of the core (the prototype bench.py, ported)
benchmarks/waterloo.py [WP8] gap table of every solver on the bundled instances (feeds docs/benchmarks.md)
skroute/
__init__.py [L] __version__ re-export, PEP 562 lazy exports + `if TYPE_CHECKING:` eager imports of every
public name (mypy and mkdocstrings do not see __getattr__), __all__, all_solvers() (D27),
set_log_level(), NullHandler on the "skroute" logger (D24)
_version.py [L] __version__ = "2.0.0" (single source, read statically by setuptools)
py.typed [L]
base.py [L] RouterTags, BaseRouter, clone, is_router
problem.py [L] RoutingProblem
exceptions.py [L] NotFittedError, InfeasibleProblemError
metrics.py [L] route_cost, split_trips, timetable, Stop, timetable_summary (public recomputation helpers; D32)
utils/__init__.py [L] re-exports Bunch, check_random_state, check_is_fitted, initial_tour
utils/validation.py [L] coerce_matrix, coerce_labels, check_random_state, check_is_fitted
utils/_param_validation.py [L] Interval, Options, validate_parameter_constraints
utils/_bunch.py [L] Bunch (dict with attribute access, 25 lines)
utils/_init_tour.py [L] initial_tour(problem, init, rng)
utils/estimator_checks.py [WP8] check_router(estimator) and its structural sub-checks (the one file under utils/ the lead does not own)
_core/__init__.py [L] imports _routing or raises a one-line-fix ImportError (final, D29)
_core/_routing.pxd [WP1] the frozen contract of §3.5 — inline primitives DEFINED here with their bodies
_core/_routing.pyx [WP1] the non-inline cdef/cpdef functions
_core/_routing.pyi [WP1] typing stubs of every cpdef and `class SplitRule(IntEnum)`
exact/__init__.py [WP2] __all__ = ["BruteForce", "HeldKarp", "MILP"] (HeldKarp only if shipped, D26)
exact/_brute_force.py, _brute.pyx [WP2]
exact/_held_karp.py, _held_karp.pyx [WP2] P1
exact/_milp.py [WP2]
construction/__init__.py [WP3] __all__ = ["NearestNeighbour","Insertion","ClarkeWright","NRBS"]
construction/_nearest_neighbour.py, _insertion.py, _insertion.pyx, _clarke_wright.py, _nrbs.py [WP3]
local_search/__init__.py [WP4] __all__ = ["TwoOpt","OrOpt","LocalSearch","IteratedLocalSearch"]
local_search/_two_opt.py, _or_opt.py, _local_search.py, _iterated.py [WP4]
metaheuristics/__init__.py [L] append-only, one export per line
metaheuristics/_simulated_annealing.py, _sa.pyx, _tabu_search.py, _tabu.pyx [WP5]
metaheuristics/_genetic.py, _ga.pyx, _ant_colony.py, _aco.pyx [WP6]
metaheuristics/_som.py [WP7]
ensemble/__init__.py, _multistart.py, _legacy.py [WP8]
heuristics/__init__.py, heuristics/brute/__init__.py, heuristics/NRBS/__init__.py [WP8] deprecated shims
metaheuristics/genetics/__init__.py, simulated_annealing/__init__.py, tabu_search/__init__.py, som/__init__.py [WP8] deprecated shims
datasets/__init__.py [WP7]
datasets/_loaders.py [WP7] load_tsp, list_tsp, 27 country wrappers, 5 cost loaders
datasets/_tsplib.py [WP7] read_tsplib, read_tsplib_tour
datasets/_descr/*.md [WP7] one DESCR per dataset family
datasets/_data/tsplib/*.tsp [WP7] the 27 files, moved from _latitude_longitude/
datasets/_data/costs/*.csv [WP7] the 5 CSVs, moved from _money_cost/; .pkl deleted
preprocessing/__init__.py [WP7]
preprocessing/_distances.py [WP7] distance_matrix, euclidean_matrix, haversine_matrix, tsplib_nint
preprocessing/_convert.py [WP7] pairs_to_matrix, to_dict_of_dicts, from_dict_of_dicts, normalize_coords
preprocessing/google.py [WP7] GoogleDistanceMatrix (lazy googlemaps import) P1; CostScraper deprecated wrapper (§5.2)
tests/ NOT a package: no __init__.py anywhere below (D16)
conftest.py [WP8] fixtures of §6
reference.py [WP8] pure-Python oracles (imported as `import reference`)
tolerances.py [WP8] TINY/FAST/SLOW dicts keyed by class name + SEEDS_TO_OPTIMUM (the §6 table as data)
test_base.py [L] RoutingProblem coercion (incl. neighbours() with 4 coincident points and a non-zero
diagonal, coerce_labels dtypes, keyword-only time_matrix), BaseRouter protocol,
exceptions, installed-copy guard (SKROUTE_EXPECT_WHEEL)
test_common.py [WP8] check_router over all_solvers() + the tolerance tests driven by tolerances.py
test_core.py [WP1] hypothesis tests of every kernel vs reference.py
test_exact.py [WP2]
test_construction.py [WP3]
test_local_search.py [WP4]
test_simulated_annealing.py, test_tabu_search.py [WP5]
test_genetic.py, test_ant_colony.py [WP6]
test_som.py, test_datasets.py, test_preprocessing.py [WP7]
test_ensemble.py, test_legacy_shims.py [WP8]
benchmarks/test_waterloo.py [WP8] ALL @slow gap tests of every solver (no other file has slow-tier tests)
data/explicit_matrix.tsp, tiny.tour, ulysses16.tsp, ulysses16.opt.tour, att48.tsp, att48.opt.tour [WP7] reader fixtures
data/nrbs_barcelona_1_0.json [WP3] the pinned 1.0 NRBS result (§4.2)
docs/
gen_pages.py [WP8] run by mkdocs-gen-files: writes user_guide/_capability_table.md from all_solvers() tags;
`python docs/gen_pages.py --readme` refreshes the README table between markers
check_api_coverage.py [WP8] fails if a name in any skroute __all__ has no `:::` directive under docs/api
index.md, getting_started.md, installation.md [WP8]
user_guide/problem_model.md (objective, split rules, ATSP, the ~20 000-node dense-matrix ceiling) [WP8]
user_guide/choosing_a_solver.md (includes the generated capability table) [WP8]
user_guide/multi_trip.md, user_guide/warm_starts_and_ensembles.md [WP8]
api/base.md [L] (directives listed in §7)
api/utils.md [L] ::: skroute.utils, ::: skroute.utils.estimator_checks.check_router
api/exact.md [WP2] api/construction.md [WP3] api/local_search.md [WP4]
api/simulated_annealing.md, api/tabu_search.md [WP5] api/genetic.md, api/ant_colony.md [WP6]
api/som.md, api/datasets.md, api/preprocessing.md [WP7] api/ensemble.md [WP8]
benchmarks.md [WP8] COMMITTED table produced by benchmarks/waterloo.py on the release candidate (header: date,
commit, CPU, OS, Python/numpy versions, random_state=0, tolerances, measured baselines)
migration.md [L] 1.0.0a2 → 2.0 (outline §7)
contributing.md (includes ../CONTRIBUTING.md), changelog.md (includes ../CHANGELOG.md), about.md [WP8]
images/logo.png keep
Deleted: skroute/_utils/, skroute/_validators/, skroute/cluster/, every in-package tests/ and pytest.ini, requirements.txt, skroute/datasets/DESCR.txt, columns_*.txt, all .pkl, .c/.html artefacts, the Travis badge.
Dependencies (minimum versions)¶
| Group | Packages |
|---|---|
runtime (dependencies) |
numpy>=1.26, scipy>=1.11, joblib>=1.3 |
extra pandas |
pandas>=2.2 |
extra google |
googlemaps>=4.10 |
extra test |
pytest>=8.0, pytest-cov>=5.0, hypothesis>=6.100, pandas>=2.2 |
extra dev |
test + ruff>=0.6, mypy>=1.10, cython-lint>=0.16, pre-commit>=3.7, build>=1.2, cibuildwheel>=3.0 (needs test-environment), Cython>=3.1 |
extra docs |
mkdocs-material>=9.5, mkdocstrings[python]>=0.25, mkdocs-gen-files>=0.5, mkdocs-include-markdown-plugin>=6.0 (mkdocs-literate-nav dropped: nothing uses it) |
build ([build-system].requires) |
setuptools>=77, Cython>=3.1 — no numpy (memoryviews only) |
requires-python = ">=3.11". Classifiers 3.11, 3.12, 3.13, 3.14. Dev venv: .venv is Python 3.13.3 / numpy 2.5.2 / scipy 1.18.1 / Cython 3.3.0 / pandas 3.0.5; .venv314 is 3.14.7 (verified). No compiled .so is present in the tree (verified); build with pip install -e ..
3. Public API contract¶
3.1 Conventions¶
- Index space (internal): nodes are
0..n-1in the order of the input matrix rows;problem.depotis the depot's index; a tour is anint64array of lengthn, a permutation withtour[0] == problem.depot. Kernels readdepot = tour[0]. - Label space (public):
problem.labels[i]is the user's label of nodei(arange(n)for a plain ndarray,X.indexfor a DataFrame, key order for a dict-of-dicts, orlabels=). Everything a user sees (tour_,route_,trips_,depot_,init=) is labels. - Closed tour: the objective always includes the return to the depot.
- Trips:
trip_startsis anint64array of lengthn_trips + 1withstarts[0] == 1,starts[-1] == n; tripkistour[starts[k]:starts[k+1]]. Plain TSP hasstarts == [1, n]. - Asymmetric matrices (
not problem.symmetric) are ATSP instances: every evaluation readsC[i, j]directionally; onlyClarkeWrightrefuses them. - Diagonal entries are never read by any kernel; they must still be finite.
- Numbers: costs
float64, indicesint64_t(fromlibc.stdint, neverlong),Py_ssize_tfor positions.
3.2 skroute/exceptions.py¶
class NotFittedError(ValueError, AttributeError):
"""Raised by check_is_fitted when a fitted attribute is accessed before fit."""
class InfeasibleProblemError(ValueError):
"""Raised when a node cannot be served in a single trip within max_time_work."""
3.3 skroute/problem.py — RoutingProblem (complete contract)¶
from __future__ import annotations
import numpy as np
from ._core import _routing as core
from .exceptions import InfeasibleProblemError
from .utils.validation import coerce_matrix, coerce_labels
# Cython 3 exposes a cpdef enum to Python as the IntEnum class `_routing.SplitRule`; its members are
# NOT module-level names (`core.SPLIT_GREEDY` raises AttributeError — verified with Cython 3.3).
_SPLIT = {"greedy": int(core.SplitRule.SPLIT_GREEDY), "optimal": int(core.SplitRule.SPLIT_OPTIMAL)}
class RoutingProblem:
"""One instance in index space. Immutable after construction; shareable across solvers and threads.
Parameters
----------
X : (n, n) array-like, DataFrame or dict-of-dicts
Cost matrix. Rows are origins, columns destinations. Must be square, n >= 3, all finite.
time_matrix : same kinds as X, optional, keyword-only (D7)
Durations, same shape and labels as X, all finite and >= 0. Required iff max_time_work is given.
depot : label, optional
Label of the depot (a position for plain arrays without labels=). Default: first node.
coords : (n, 2) array-like, optional
Coordinates in row order of X. Needed by SOM; carried, never validated beyond shape.
labels : sequence of n hashables, optional
Labels for a plain ndarray X. Must equal the labels X already carries, if any.
max_time_work : float > 0, optional
Per-trip budget in the units of time_matrix. None = plain TSP.
extra_cost : float >= 0, default 0.0
Fixed charge per trip beyond the first.
people : int >= 1, default 1
Multiplies extra_cost only.
split : {"greedy", "optimal"}, default "greedy"
Decoder of the giant tour into trips (see D1).
"""
def __init__(self, X, *, time_matrix=None, depot=None, coords=None, labels=None,
max_time_work=None, extra_cost=0.0, people=1, split="greedy"):
C, lab = coerce_matrix(X, "X") # float64 C-contiguous, labels or None
n = C.shape[0]
if n < 3:
raise ValueError(f"X must have at least 3 nodes, got {n}")
if labels is not None:
given = coerce_labels(labels, n) # 1-D int64 or object array, n unique entries (contract below)
if lab is not None and not np.array_equal(lab, given):
raise ValueError("labels= disagrees with the labels carried by X")
lab = given
self.labels = np.arange(n, dtype=np.int64) if lab is None else lab
self._index = {label: i for i, label in enumerate(self.labels.tolist())}
if len(self._index) != n:
raise ValueError("labels must be unique")
self.cost = C
self.n = n
if depot is None:
self.depot = 0
else:
try:
self.depot = self._index[depot]
except (KeyError, TypeError):
raise ValueError(f"depot {depot!r} is not a label of X") from None
if split not in _SPLIT:
raise ValueError(f"split must be 'greedy' or 'optimal', got {split!r}")
self.split = split
if max_time_work is None:
if time_matrix is not None:
raise ValueError("time_matrix given but no max_time_work; pass max_time_work=<hours per trip>")
if extra_cost != 0.0 or people != 1 or split != "greedy":
raise ValueError("extra_cost, people and split have no effect without max_time_work") # D3: no silent knobs
self.time = None
self.max_time_work = np.inf
else:
if time_matrix is None:
raise ValueError("max_time_work given but no time_matrix; "
"pass time_matrix=X to use the cost matrix as durations")
if not (np.isfinite(max_time_work) and float(max_time_work) > 0): # inf would store T and set multi_trip for nothing
raise ValueError(f"max_time_work must be a finite number > 0, got {max_time_work!r}")
T, tlab = coerce_matrix(time_matrix, "time_matrix")
if T.shape != C.shape:
raise ValueError(f"time_matrix has shape {T.shape}, X has shape {C.shape}")
if tlab is not None and not np.array_equal(tlab, self.labels):
raise ValueError("time_matrix labels differ from the labels of X")
if (T < 0).any():
raise ValueError("time_matrix contains negative durations")
self.time = T
self.max_time_work = float(max_time_work)
d = self.depot
bad = (T[d, :] + T[:, d] > self.max_time_work)
bad[d] = False
if bad.any():
raise InfeasibleProblemError(
f"nodes {self.labels[bad].tolist()} cannot be served in one trip: "
f"depot round trip exceeds max_time_work={self.max_time_work}")
if not (float(extra_cost) >= 0) or not np.isfinite(extra_cost):
raise ValueError(f"extra_cost must be a finite number >= 0, got {extra_cost!r}")
if not isinstance(people, (int, np.integer)) or isinstance(people, bool) or people < 1:
raise ValueError(f"people must be an integer >= 1, got {people!r}")
self.extra_cost, self.people = float(extra_cost), int(people)
self.coords = None
if coords is not None:
xy = np.ascontiguousarray(np.asarray(coords, dtype=np.float64))
if xy.shape != (n, 2):
raise ValueError(f"coords must have shape ({n}, 2), got {xy.shape}")
self.coords = xy
self.symmetric = bool(np.array_equal(C, C.T))
self._neigh: dict[int, np.ndarray] = {}
# ----- derived, read-only -----
@property
def multi_trip(self) -> bool: return self.time is not None
@property
def fixed_cost(self) -> float: return self.extra_cost * self.people
@property
def time_or_cost(self) -> np.ndarray:
"""What kernels receive as T: the time matrix, or the cost matrix when there is no budget (never read then)."""
return self.cost if self.time is None else self.time
@property
def split_code(self) -> int: return _SPLIT[self.split]
@property
def depot_label(self): return self.labels[self.depot]
# ----- label <-> index -----
def index_of(self, label) -> int:
try: return self._index[label]
except (KeyError, TypeError): raise ValueError(f"{label!r} is not a label of X") from None
def to_index_tour(self, seq) -> np.ndarray:
"""Labels -> int64 tour with the depot at position 0.
Accepts an open tour (n labels), a closed route (depot repeated at the end)
or a multi-trip route (depot repeated between trips): every occurrence of the
depot is removed, then the depot is prepended. Raises ValueError unless the
remaining labels are exactly the non-depot labels, each once."""
idx = np.fromiter((self.index_of(x) for x in seq), dtype=np.int64, count=len(seq))
body = idx[idx != self.depot]
expected = np.delete(np.arange(self.n), self.depot)
if not np.array_equal(np.sort(body), expected):
raise ValueError("init tour must contain every label exactly once (the depot may repeat)")
return np.concatenate(([self.depot], body)).astype(np.int64)
def to_label_tour(self, tour) -> np.ndarray:
return self.labels[np.asarray(tour, dtype=np.int64)]
# ----- kernels -----
# Typed memoryviews accept only C-contiguous int64 arrays (a list or an int32 array raises TypeError /
# "Buffer dtype mismatch"); these public methods coerce first.
@staticmethod
def _as_index(a) -> np.ndarray:
return np.ascontiguousarray(a, dtype=np.int64)
def evaluate(self, tour) -> float:
"""Objective of an index tour (D1). O(n) greedy / plain, O(n*L) optimal."""
return core.problem_cost_py(self.cost, self.time_or_cost, self._as_index(tour),
self.max_time_work, self.fixed_cost, self.split_code)
def trip_starts(self, tour) -> np.ndarray:
out = np.empty(self.n + 1, dtype=np.int64)
k = core.trip_starts(self.time_or_cost, self._as_index(tour), self.max_time_work, self.split_code,
self.cost, self.fixed_cost, out)
return out[: k + 1]
def trip_costs(self, tour, starts) -> np.ndarray:
out = np.empty(len(starts) - 1)
core.trip_costs(self.cost, self._as_index(tour), self._as_index(starts), out); return out
def trip_times(self, tour, starts) -> np.ndarray:
out = np.empty(len(starts) - 1)
core.trip_times(self.time, self._as_index(tour), self._as_index(starts), out); return out
def neighbours(self, k: int = 10) -> np.ndarray:
"""k nearest neighbours of every node by C[i, :], as int64 (n, k), sorted ascending; cached.
The diagonal is excluded regardless of its value (only finiteness is required of it) and
ties are broken by node index (stable sort). Six bundled instances have coincident points
(lu980: 346 duplicate rows, ho14473: 7 370), so zero off-diagonal distances are normal input.
"""
k = min(int(k), self.n - 1)
if k not in self._neigh:
D = self.cost.copy() # one transient (n, n) copy; accepted
np.fill_diagonal(D, np.inf)
idx = np.argpartition(D, k - 1, axis=1)[:, :k]
order = np.argsort(np.take_along_axis(D, idx, 1), axis=1, kind="stable")
self._neigh[k] = np.ascontiguousarray(np.take_along_axis(idx, order, 1), dtype=np.int64)
return self._neigh[k]
def __repr__(self):
kind = "multi-trip" if self.multi_trip else "TSP"
sym = "symmetric" if self.symmetric else "asymmetric"
return f"RoutingProblem(n={self.n}, {kind}, {sym}, depot={self.depot_label!r})"
coerce_matrix(M, name) -> (ndarray, labels | None) in utils/validation.py — the exact coercion contract:
def coerce_matrix(M, name):
if isinstance(M, dict): # legacy dict-of-dicts
labels = list(M)
try:
arr = np.array([[M[i][j] for j in labels] for i in labels], dtype=np.float64)
except KeyError as e:
raise ValueError(f"{name}: dict-of-dicts is not square, missing key {e}") from None
lab = coerce_labels(labels, len(labels))
elif hasattr(M, "to_numpy") and hasattr(M, "index") and hasattr(M, "columns"): # DataFrame, duck-typed
if list(M.index) != list(M.columns):
raise ValueError(f"{name}: index and columns must hold the same labels in the same order")
arr, lab = np.ascontiguousarray(M.to_numpy(dtype=np.float64)), coerce_labels(M.index, len(M.index))
else:
arr, lab = np.ascontiguousarray(np.asarray(M, dtype=np.float64)), None
if arr.ndim != 2 or arr.shape[0] != arr.shape[1]:
raise ValueError(f"{name} must be a square 2-D matrix, got shape {arr.shape}")
if not np.isfinite(arr).all():
raise ValueError(f"{name} contains NaN or infinite values")
return arr, lab
def coerce_labels(seq, n):
"""Label dtype is ALWAYS int64 or object, whatever the input path (ndarray+labels=, DataFrame index,
dict keys), so tour_/labels_/depot_ compare equal across paths and `np.array_equal` never mixes kinds.
Integer-like labels (numpy or Python ints, never bool) -> int64; anything else (strings, mixed, tuples)
-> object. `np.asarray(["a", "b"])` would give '<U1' and a DataFrame index 'object' — hence the rule."""
items = list(seq)
if len(items) != n or len(set(items)) != n:
raise ValueError(f"labels must be {n} unique hashables")
if all(isinstance(x, (int, np.integer)) and not isinstance(x, (bool, np.bool_)) for x in items):
return np.array(items, dtype=np.int64)
return np.array(items, dtype=object)
Non-numeric matrix input raises the numpy/ValueError naturally ("could not convert string to float"), which is acceptable. Unhashable labels raise TypeError from set(), also acceptable.
Service time (D32, added 2026-09-05)¶
RoutingProblem(X, *, time_matrix=None, depot=None, coords=None, labels=None, max_time_work=None, extra_cost=0.0, people=1, service_time=None, split="greedy") — service_time sits after people and before split, in the same position in BaseRouter.fit and in skroute.metrics.route_cost; base._FIT_KWARGS lists it. Coercion (problem._coerce_service_time(value, n, depot)):
None->np.zeros(n)(also for a plain TSP, soproblem.service_timealways exists);- a real scalar (
_is_number: never a bool; a 0-d numeric ndarray is unwrapped to the scalar it holds first) ->np.full(n, float(value))withs[depot] = 0.0; rejected unless finite and>= 0:ValueError("service_time must be a finite number >= 0, got {value!r}"). The test is pure Python (problem._finite:float(value)thenmath.isfinite,Noneon any failure), so aFractionor an int beyond the float range get this message and never numpy'sTypeError; the same helper validatesmax_time_workandextra_cost; - a bool, a str/bytes, a
Decimal, a dict, a complex, a list of strings — anything that is neither a real scalar nor something numpy reads as a float array — ->ValueError("service_time must be a number or an ({n},) array-like, got {type(value).__name__}")(a type-oriented message, never "got shape ()"); - anything else ->
np.array(value, dtype=np.float64)(a copy: the caller's array is never aliased), which must have shape(n,)("service_time must be a scalar or have shape ({n},), got shape {shape}"), be finite ("service_time contains NaN or infinite values") and non-negative ("service_time contains negative durations"). Labels are not read: the array is in matrix row order, likecoords— with one guard: a pandas Series (duck-typed,indexandto_numpy) must carry the labels ofXin row order,ValueError("service_time index differs from the labels of X")otherwise (checked after the shape, liketime_matrix's labels; nothing is reordered — a Series in another order would silently move every service, the depot's zero included). - Without
max_time_work:ValueError("service_time given but no max_time_work; pass max_time_work=<hours per trip>"), checked after thetime_matrixmessage and before the D3 one (extra_cost, people and split ...).
The effective matrix is built once in __init__ and stored as problem._time_eff (float64, C-contiguous):
T_eff = T + s[np.newaxis, :] # the service of j is paid on arrival at j
T_eff[:, d] = T[:, d] # nothing on returning to the depot
T_eff[d, :] += s[d] # the depot's own service once per trip, at departure
np.fill_diagonal(T_eff, np.diagonal(T)) # the whole diagonal stays raw: no kernel reads it (§3.1)
When every service is zero _time_eff is time (no copy), so problem.time_or_cost is problem.time and a fit without service_time is bit-identical to 2.0. time_or_cost returns _time_eff (or cost for a plain TSP); evaluate, trip_starts and trip_times read _time_eff, so trip_times includes the services; problem.time stays the raw travel-time matrix and problem.service_time the (n,) array. Every solver prices durations through problem.time_or_cost (a solver reading problem.time in its search breaks the D32 equality asserted by check 8). The depot feasibility check uses T_eff (bad = T_eff[d, :] + T_eff[:, d] > max_time_work, depot excluded); with a non-zero service the message is
nodes [labels] cannot be served in one trip: depot round trip plus service time exceeds max_time_work={mtw} ({label!r}: travel {T[d, j] + T[j, d]:g} + service {s[j] + s[d]:g}, ...)
and without one the 2.0 message is unchanged. BaseRouter.fit forwards service_time to the problem and the "X is a RoutingProblem: pass it alone" check rejects it like the other problem arguments; fit_time_ is unaffected.
Aliasing (decided in review round 5): RoutingProblem never writes to its arrays, but coerce_matrix does not copy a float64 C-contiguous input — cost and time are then views of the caller's arrays (no second n×n matrix at n = 10 639, D13) — whereas service_time is always copied and _time_eff is a private matrix whenever a service is non-zero. An array must therefore not be mutated after it has been passed: a later write to the caller's time_matrix would move timetable (which reads time) but not trip_times_ (which reads _time_eff). The class docstring says so; "immutable" means the problem itself never changes.
skroute.metrics (D32): timetable(obj, route=None, *, start="08:00", units="min", as_frame=False) -> list[list[Stop]] — obj a fitted estimator (problem_ and route_; route may override the route) or a RoutingProblem (then route is required); a route with the depot repeated in its interior is read as driven (one day per segment, every non-depot label exactly once — whatever max_time_work says: a day that ends after the budget is kept and reported with UserWarning("day {k} ends at {end:g} min, over max_time_work ({budget:g} min): the route was read as driven, ..."), never re-cut; tolerance 1e-9 * max(1, budget) minutes, so a decoded day never warns), otherwise it is a giant tour (open or closed) decoded with problem.trip_starts. Note the asymmetry with route_cost, which always removes the depot occurrences and re-decodes the giant tour: on a hand-made plan the two describe different plans (the timetable the user's days, route_cost the decoder's cut), and both docstrings and multi_trip.md say so; they agree on every route fit produces. ValueError("timetable needs time_matrix and max_time_work") for a plain TSP, TypeError for anything else, NotFittedError for an unfitted estimator. start is "HH:MM" or a datetime.time (seconds and microseconds count) — the start of the day at the depot: the vehicle leaves at start + service[depot]; units ("min", "h", "s") are the units of problem.time — the timetable is always in minutes (added to the D32 signature so that the hours tables of skroute.datasets get correct clock strings). Stop is a frozen dataclass Stop(day, order, label, arrival, departure, travel, service, wait=0.0, start=480.0): day 1-based, order 0 at the departure from the depot and last at the return, arrival/departure/travel/service in minutes since start (departure = arrival + service + wait; at the departure stop arrival = 0, departure = service[depot]; at the return service = 0, departure = arrival), wait always 0.0 today (reserved for time windows), start the minutes after midnight of the day's start (excluded from the repr and from equality and hashing — field(repr=False, compare=False) — so stops with the same minutes are equal whatever clock they are rendered on); properties arrival_time/departure_time -> HH:MM rounded to the nearest minute, wrapping past midnight. as_frame=True returns a pandas.DataFrame with columns day, order, label, arrival_time, departure_time, travel, service, wait, arrival, departure (pandas imported lazily; the ImportError names scikit-route[pandas]). timetable_summary(days) -> list[dict] gives per day day, n_stops, driving, service, total, back_at; it takes the list form only — TypeError for anything else, the DataFrame of as_frame=True included (the message says to group it by day). timetable carries three @overloads (as_frame: Literal[False] -> list[list[Stop]], Literal[True] -> pandas.DataFrame, bool -> the union) so the common path is typed, not Any. The end of every day equals the corresponding trip_times_ value (converted to minutes), which is how the hypothesis test of tests/test_timetable.py proves that every day of a fitted solution fits the budget.
3.4 skroute/base.py — RouterTags, BaseRouter, clone (complete)¶
from __future__ import annotations
import inspect, logging, warnings
from dataclasses import dataclass
from time import perf_counter
import numpy as np
from .problem import RoutingProblem
from .utils._param_validation import validate_parameter_constraints
from .utils.validation import check_random_state
log = logging.getLogger("skroute")
_FIT_KWARGS = ("depot", "coords", "labels", "max_time_work", "extra_cost", "people", "split")
@dataclass(frozen=True)
class RouterTags:
kind: str = "metaheuristic" # "exact" | "construction" | "local_search" | "metaheuristic" | "ensemble" (D28)
exact: bool = False # provably optimal for the objective it accepts; sets is_optimal_
stochastic: bool = False # consumes random_state; MultiStart-able
iterative: bool = False # sets history_, n_iter_, stop_reason_
budget_aware: bool = False # the search itself sees the multi-trip objective; solvers opt IN (D28)
requires_symmetric: bool = False # raises on asymmetric X
requires_coords: bool = False # raises without coords=
max_nodes: int | None = None # hard cap on n (BruteForce, HeldKarp, MILP)
# budget_aware=True is declared explicitly by: BruteForce, ClarkeWright, TwoOpt, OrOpt, LocalSearch,
# IteratedLocalSearch, SimulatedAnnealing, TabuSearch, Genetic, AntColony, EnsembleGenetic,
# EnsembleSimulatedAnnealing; MultiStart delegates to its estimator. Everything else warns under a budget.
class BaseRouter:
"""Base class of every solver.
Subclasses: store every __init__ argument verbatim as an attribute of the same name and
nothing else; declare `_parameter_constraints`; override `_get_tags()`; implement
`_solve(problem, rng) -> int64 array` (a permutation of range(n) with problem.depot first).
Iterative solvers set self.history_, self.n_iter_, self.stop_reason_ inside _solve;
exact solvers set self.is_optimal_. Everything else is set here.
"""
_parameter_constraints: dict = {}
# ---------- scikit-learn parameter protocol ----------
@classmethod
def _get_param_names(cls):
sig = inspect.signature(cls.__init__)
return sorted(p.name for p in sig.parameters.values()
if p.name != "self" and p.kind is not p.VAR_KEYWORD and p.kind is not p.VAR_POSITIONAL)
def get_params(self, deep=True):
out = {}
for key in self._get_param_names():
value = getattr(self, key)
if deep and hasattr(value, "get_params") and not isinstance(value, type):
out.update({f"{key}__{k}": v for k, v in value.get_params().items()})
out[key] = value
return out
def set_params(self, **params):
if not params:
return self
valid = self._get_param_names()
nested = {}
for key, value in params.items():
key, delim, sub_key = key.partition("__")
if key not in valid:
raise ValueError(f"Invalid parameter {key!r} for estimator {self!r}. "
f"Valid parameters are: {valid}.")
if delim:
nested.setdefault(key, {})[sub_key] = value
else:
setattr(self, key, value)
for key, sub_params in nested.items():
getattr(self, key).set_params(**sub_params)
return self
def __repr__(self):
sig = inspect.signature(type(self).__init__).parameters
parts = []
for k, v in self.get_params(deep=False).items():
default = sig[k].default
same = (v is default) or (isinstance(v, type(default)) and not isinstance(v, np.ndarray) and v == default)
if not same:
parts.append(f"{k}={v!r}")
return f"{type(self).__name__}({', '.join(parts)})"
def __eq__(self, other): # equality of type and parameters; used by tests and clone checks
if type(self) is not type(other):
return False
a, b = self.get_params(deep=False), other.get_params(deep=False)
return all(_param_equal(a[k], b[k]) for k in a) # dict == would raise on an ndarray init=
__hash__ = None
# ---------- capability protocol ----------
def _get_tags(self) -> RouterTags:
return RouterTags()
# ---------- template method ----------
def _solve(self, problem: RoutingProblem, rng: np.random.Generator | None) -> np.ndarray:
raise NotImplementedError
def fit(self, X, *, time_matrix=None, depot=None, coords=None, labels=None,
max_time_work=None, extra_cost=0.0, people=1, split="greedy"):
"""Solve the instance and store the result in trailing-underscore attributes.
Returns
-------
self
"""
if isinstance(X, RoutingProblem):
if time_matrix is not None or any(v is not None for v in (depot, coords, labels, max_time_work)) \
or extra_cost != 0.0 or people != 1 or split != "greedy":
raise ValueError("X is a RoutingProblem: pass it alone, without other fit arguments")
problem = X
else:
problem = RoutingProblem(X, time_matrix=time_matrix, depot=depot, coords=coords, labels=labels,
max_time_work=max_time_work, extra_cost=extra_cost,
people=people, split=split)
validate_parameter_constraints(self._parameter_constraints, self.get_params(deep=False),
caller_name=type(self).__name__)
tags = self._get_tags()
name = type(self).__name__
if tags.requires_symmetric and not problem.symmetric:
raise ValueError(f"{name} requires a symmetric cost matrix")
if tags.requires_coords and problem.coords is None:
raise ValueError(f"{name} needs node coordinates: fit(X, coords=...)")
if tags.max_nodes is not None and problem.n > tags.max_nodes:
raise ValueError(f"{name} handles at most {tags.max_nodes} nodes, got {problem.n}; "
"raise max_nodes only if you accept the time/memory cost")
if problem.multi_trip and not tags.budget_aware:
if tags.exact:
raise ValueError(f"{name} optimises the plain tour and cannot certify a multi-trip optimum; "
"use BruteForce (n <= 11) or a heuristic solver")
warnings.warn(f"{name} ignores max_time_work during its search; the result is still "
"split into trips and priced under the multi-trip objective", UserWarning, stacklevel=2)
rng = check_random_state(getattr(self, "random_state", None)) if tags.stochastic else None
self._reset_fitted()
t0 = perf_counter()
tour = self._solve(problem, rng)
fit_time = perf_counter() - t0
tour = np.ascontiguousarray(tour, dtype=np.int64)
if tour.shape != (problem.n,) or tour[0] != problem.depot \
or not np.array_equal(np.sort(tour), np.arange(problem.n)):
raise RuntimeError(f"{name}._solve returned an invalid tour (bug in the solver): "
"expected a permutation of range(n) starting at the depot index")
if tags.iterative:
for attr in ("history_", "n_iter_", "stop_reason_"):
if not hasattr(self, attr):
raise RuntimeError(f"{name}._solve must set {attr} (bug in the solver)")
self.history_ = np.asarray(self.history_, dtype=np.float64)
if tags.exact and not hasattr(self, "is_optimal_"):
raise RuntimeError(f"{name}._solve must set is_optimal_ (bug in the solver)")
self._set_results(problem, tour, fit_time)
return self
def _reset_fitted(self):
for k in [k for k in vars(self) if k.endswith("_") and not k.startswith("_")]:
delattr(self, k)
def _set_results(self, problem, tour, fit_time):
starts = problem.trip_starts(tour)
lab = problem.labels
d = lab[problem.depot : problem.depot + 1] # 1-element array, keeps the label dtype
self.problem_ = problem
self.n_nodes_ = problem.n
self.labels_ = lab.copy()
self.depot_ = lab[problem.depot]
self.tour_ = lab[tour]
self.trips_ = [np.concatenate((d, lab[tour[a:b]], d)) for a, b in zip(starts[:-1], starts[1:])]
self.route_ = np.concatenate([self.trips_[0]] + [t[1:] for t in self.trips_[1:]])
self.n_trips_ = len(self.trips_)
self.trip_costs_ = problem.trip_costs(tour, starts)
if problem.multi_trip:
self.trip_times_ = problem.trip_times(tour, starts)
self.cost_ = float(problem.evaluate(tour)) # D2: recomputed, never reported
self.fit_time_ = float(fit_time)
def _param_equal(a, b) -> bool:
if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
return np.array_equal(np.asarray(a), np.asarray(b))
return a == b
def clone(estimator):
"""New unfitted estimator with the same parameters (deep copies nothing; parameters are values)."""
params = estimator.get_params(deep=False)
return type(estimator)(**{k: (clone(v) if hasattr(v, "get_params") else v) for k, v in params.items()})
def is_router(obj) -> bool:
return isinstance(obj, BaseRouter)
Top-level surface (skroute/__init__.py, lead): every solver class of D18 (AntColony/HeldKarp only if shipped), plus RoutingProblem, BaseRouter, RouterTags, clone, is_router, all_solvers, check_router, set_log_level, __version__, is lazily exported (PEP 562 __getattr__), listed in skroute.__all__ and imported eagerly under if TYPE_CHECKING: so mypy and mkdocstrings see real types. skroute.datasets, skroute.preprocessing, skroute.metrics, skroute.exceptions and skroute.utils are imported as subpackages/modules. all_solvers() (D27) imports eagerly from the __all__ lists of exact, construction, local_search, metaheuristics and ensemble, drops MultiStart, and returns the classes sorted by __name__. set_log_level(level): log.setLevel(level); if the logger's only handler is the NullHandler attached at import, add a StreamHandler (stderr) with the format "%(name)s %(levelname)s %(message)s".
Fitted attributes (exact types) — set by BaseRouter for every solver:
| attribute | type / shape | meaning |
|---|---|---|
problem_ |
RoutingProblem |
the coerced instance (reusable: Other().fit(est.problem_)) |
n_nodes_ |
int |
n |
labels_ |
1-D array (n,), label dtype |
labels in matrix row order |
depot_ |
scalar, label dtype | the depot's label |
tour_ |
1-D array (n,), label dtype |
open giant tour, depot first — the warm-start format |
route_ |
1-D array (n + n_trips,), label dtype |
as driven: depot, trip 1, depot, trip 2, …, depot |
trips_ |
list[np.ndarray], each closed [depot, …, depot] |
one per trip; len == 1 for plain TSP |
n_trips_ |
int |
len(trips_) |
trip_costs_ |
float64 (n_trips,) |
travel cost of each closed trip (fixed charge excluded) |
trip_times_ |
float64 (n_trips,) |
only when a time matrix was given; each <= max_time_work + 1e-9 |
cost_ |
float |
trip_costs_.sum() + fixed_cost * (n_trips_ - 1) |
fit_time_ |
float |
seconds in _solve |
history_ |
float64 (n_iter_,) |
iterative only: best-so-far cost after each outer iteration |
n_iter_ |
int |
iterative only: outer iterations actually run |
stop_reason_ |
str |
iterative only: "converged" | "max_iter" | "patience" | "time_limit" | "callback" (the last one when a fit(callback=) returned True, D30). Allowed subsets (each plus "callback"): TwoOpt/OrOpt/LocalSearch/SOM {"converged", "max_iter"}; ILS/Tabu/Genetic/AntColony {"max_iter", "patience", "time_limit"}; SimulatedAnnealing {"converged", "patience", "time_limit"}; MultiStart and the Ensemble wrappers copy the best estimator's value |
is_optimal_ |
bool |
exact only |
check_is_fitted(est) (in utils/validation.py) raises NotFittedError(f"This {name} instance is not fitted yet. Call 'fit' first.") when cost_ is missing.
check_random_state(seed): None or Integral → np.random.default_rng(seed); a Generator → returned as is (and therefore advanced by the fit); anything else → TypeError("random_state must be None, an int or a numpy.random.Generator"). Legacy RandomState is not accepted.
Parameter validation (utils/_param_validation.py, ≈120 lines, lead): Interval(type, low, high, closed="both"|"left"|"right"|"neither"), Options(type, {values}), "array-like", "random_state", "boolean", "verbose", None, and callables. Message format (sklearn's): The 'alpha' parameter of SimulatedAnnealing must be a float in the range (0.0, 1.0). Got 1.5 instead. Validation runs at fit time, never in __init__.
Common helper utils/_init_tour.py::initial_tour(problem, init, rng) -> int64 tour: "nearest_neighbour" → core.nearest_neighbour_tour; "random" → depot followed by rng.permutation of the rest (requires rng; raises ValueError if the solver is not stochastic); array-like → problem.to_index_tour(init); any other string → ValueError("init must be 'nearest_neighbour', 'random' or an array of labels").
Three usage examples — written in the exact shape every docstring, docs page and the README must use: a >>> session whose printed results are platform-stable facts (booleans, structural equalities, .tolist() of small arrays) for stochastic solvers, and exact numbers only for deterministic solvers and dataset constants (optimal_tour_length). numpy 2 prints scalars as np.int64(0) / np.float64(1.0), so examples convert with int(), float(), .item() or .tolist() before printing. A line whose output is run-dependent carries # doctest: +SKIP. These three run under pytest --doctest-modules in docs.yml (§7, R7).
>>> # 1. Plain TSP from numpy: Western Sahara, optimum 27603 -- labels are the file's 1-based ids
>>> from skroute import IteratedLocalSearch
>>> from skroute.datasets import load_tsp
>>> wi = load_tsp("wi29") # TSPBunch: coords, labels, depot, optimal_tour_length, ...
>>> C = wi.distance_matrix() # metric="tsplib_euc_2d": nint rounding, optima match the literature
>>> ils = IteratedLocalSearch(random_state=0).fit(C, labels=wi.labels) # route_ comparable with the published tour
>>> ils.cost_ / wi.optimal_tour_length < 1.03 # the fast-tier tolerance of §6; the run itself reaches 27603 on the author's machine
True
>>> int(ils.route_[0]) == int(ils.route_[-1]) == int(ils.depot_) == 1
True
>>> ils.n_iter_ == len(ils.history_) and ils.stop_reason_ in {"patience", "max_iter"}
True
>>> # 2. Multi-trip from Barcelona: 8-hour days, 12.83 EUR per extra day, two people.
>>> # Loader matrices are PLAIN ndarrays: pass labels=bcn.labels, otherwise depot=bcn.depot is not a label of X.
>>> import numpy as np
>>> from skroute import SimulatedAnnealing, TabuSearch, RoutingProblem
>>> from skroute.datasets import load_barcelona
>>> bcn = load_barcelona() # cost (EUR), time (h), labels (int64 ids), depot == 10000007
>>> sa = SimulatedAnnealing(random_state=0).fit(bcn.cost, time_matrix=bcn.time, labels=bcn.labels,
... depot=bcn.depot, max_time_work=8.0, extra_cost=12.83, people=2)
>>> bool(np.all(sa.trip_times_ <= 8.0)) # every trip fits, return leg included
True
>>> int(sa.route_[0]) == int(sa.route_[-1]) == 10000007 and sa.n_trips_ == len(sa.trips_)
True
>>> problem = RoutingProblem(bcn.cost, time_matrix=bcn.time, labels=bcn.labels, depot=bcn.depot,
... max_time_work=8.0, extra_cost=12.83, people=2, split="optimal")
>>> costs = {type(est).__name__: est.fit(problem).cost_ for est in (sa, TabuSearch(random_state=0))}
>>> sorted(costs) # one instance, several solvers, optimal split
['SimulatedAnnealing', 'TabuSearch']
>>> # 3. Legacy dict-of-dicts input; the depot is the first key, as 1.0's route_example[0]. Deterministic: exact output.
>>> from skroute import BruteForce
>>> cost = {1: {1: 0, 2: 5, 3: 9, 4: 10}, 2: {1: 5, 2: 0, 3: 4, 4: 8},
... 3: {1: 9, 2: 4, 3: 0, 4: 3}, 4: {1: 10, 2: 8, 3: 3, 4: 0}}
>>> hours = {1: {1: 0, 2: 1, 3: 2, 4: 2}, 2: {1: 1, 2: 0, 3: 1, 4: 2},
... 3: {1: 2, 2: 1, 3: 0, 4: 1}, 4: {1: 2, 2: 2, 3: 1, 4: 0}}
>>> bf = BruteForce().fit(cost, time_matrix=hours, max_time_work=4.0, extra_cost=3.0)
>>> bf.route_.tolist(), bf.cost_, bf.n_trips_
([1, 2, 3, 1, 4, 1], 41.0, 2)
Hand check of example 3 against the greedy rule (D1): the single trip 1-2-3-4-1 needs 1+1+1+2 = 5 h > 4, so it must break. Giant tour 1-2-3-4: leg 1→2 (t = 1), leg 2→3 (t = 2, return 2 → 4 h fits), leg 3→4 would need 2+1+2 = 5 h → close at 3, reopen at 4: trips [1,2,3,1] (cost 5+4+9 = 18) and [1,4,1] (cost 20); 18 + 20 + 3.0 = 41.0. The six giant tours evaluate to 41, 54, 41, 54, 41, 41; BruteForce returns the lexicographically first, 1-2-3-4 (§4.1). split="optimal" also gives 41.0 here ({2},{3,4} is infeasible: 2+1+2 = 5 h). This arithmetic must still be confirmed by the doctest (R7).
3.5 The core extension: skroute/_core/_routing.pxd (frozen contract)¶
Directives for every .pyx in the package (set in setup.py, not per file): language_level=3, boundscheck=False, wraparound=False, cdivision=True, initializedcheck=False, nonecheck=False, embedsignature=True. Compiler flags: -O3 unless MSVC (/O2). No cimport numpy. No OpenMP. malloc/free from libc.stdlib, never VLAs.
How the contract is shared (verified with Cython 3.3.0 in the dev venv):
- Every
cdef inlinefunction below is defined with its full body in_routing.pxd— that is how Cython inlines across extension modules. A body-lesscdef inlinedeclaration in a.pxdmakes every cimporting module fail in the C compiler ('inline' can only appear on functions, because Cython emits a function-pointer import for the symbol); it does not "still link". The listing shows the signatures only for brevity; the reference bodies are the prototype'sskbench/bench_core.pyx._routing.pyxmust not re-define them. - The non-inline
cdef/cpdeffunctions (optimal_split_cost,problem_cost_py,trip_starts,trip_costs,trip_times,move_segment*,double_bridge,rebuild_pos, the descents,local_search_generic,nearest_neighbour_tour) are declared body-less in the.pxdand defined in_routing.pyx; cimporting modules reach them through the module's C-API. - What is frozen is the set of signatures and the documented semantics. WP1 owns the bodies and may change them without an R2 issue as long as the hypothesis suite stays green.
noexcept nogilapplies to every function below exceptproblem_cost_pyandtrip_starts: those two hold the GIL,malloc/freetheir owndp/predscratch for the optimal split (raisingMemoryErroron failure) and are the only two such functions in the file.- The
cpdef enum SplitRuleis reached from Python as the IntEnum class_routing.SplitRule(_routing.SplitRule.SPLIT_GREEDY == 0); its members are not module attributes..pyxfiles use the bare names viacimport; the.pyideclaresclass SplitRule(IntEnum): SPLIT_GREEDY = 0; SPLIT_OPTIMAL = 1; C-level code comparesint splitagainst the enum values.
# skroute/_core/_routing.pxd
from libc.stdint cimport int64_t, uint8_t
cpdef enum SplitRule: # Python: _routing.SplitRule.SPLIT_GREEDY / .SPLIT_OPTIMAL (IntEnum)
SPLIT_GREEDY = 0
SPLIT_OPTIMAL = 1
# NOTE: every `cdef inline` function in this file carries its body here (elided in the SPEC listing).
# ------------------------------------------------------------------ evaluation
# C, T: (n, n) C-contiguous float64; tour: int64 permutation, tour[0] is the depot.
# max_time: +inf means plain TSP (T is then never read). fixed_cost = people * extra_cost.
# dp (float64[n]) and pred (int64[n]) are caller-owned scratch buffers used ONLY by the
# optimal split; pass zero-length views when split == SPLIT_GREEDY. Never share scratch across threads.
cdef inline double tour_cost(const double[:, ::1] C, const int64_t[::1] tour) noexcept nogil
cdef inline double greedy_split_cost(const double[:, ::1] C, const double[:, ::1] T, const int64_t[::1] tour,
double max_time, double fixed_cost) noexcept nogil
cdef double optimal_split_cost(const double[:, ::1] C, const double[:, ::1] T, const int64_t[::1] tour,
double max_time, double fixed_cost,
double[::1] dp, int64_t[::1] pred) noexcept nogil
cdef inline double problem_cost(const double[:, ::1] C, const double[:, ::1] T, const int64_t[::1] tour,
double max_time, double fixed_cost, int split,
double[::1] dp, int64_t[::1] pred) noexcept nogil
# dispatch: max_time == inf -> tour_cost; split == SPLIT_GREEDY -> greedy; else optimal
cpdef double problem_cost_py(const double[:, ::1] C, const double[:, ::1] T, const int64_t[::1] tour,
double max_time, double fixed_cost, int split)
# Python entry point; holds the GIL, malloc/frees its own dp/pred scratch (MemoryError on failure).
# NOT noexcept nogil. Used by RoutingProblem.evaluate and tests.
cpdef Py_ssize_t trip_starts(const double[:, ::1] T, const int64_t[::1] tour, double max_time, int split,
const double[:, ::1] C, double fixed_cost, int64_t[::1] out)
# writes out[0..k] (out[0] == 1, out[k] == n) and returns k = n_trips; out has length n + 1.
# C and fixed_cost are needed only by the optimal split, for which it malloc/frees its own dp/pred
# scratch while holding the GIL (NOT noexcept nogil, MemoryError on failure). Plain TSP -> k == 1.
cpdef void trip_costs(const double[:, ::1] C, const int64_t[::1] tour, const int64_t[::1] starts,
double[::1] out) noexcept nogil # closed-trip travel cost per trip
cpdef void trip_times(const double[:, ::1] T, const int64_t[::1] tour, const int64_t[::1] starts,
double[::1] out) noexcept nogil # closed-trip duration per trip
# ------------------------------------------------------------------ move deltas
# Position domains (position 0 is the depot and never moves):
# 2-opt and swap: 1 <= i < j <= n-1.
# Or-opt: 1 <= i, i + L - 1 <= n-1 (the segment never wraps), 0 <= j <= n-1 (j == 0 places the segment
# right after the depot), j not in [i-1, i+L-1].
# Every [i..j] range in this file is INCLUSIVE, except double_bridge, whose segments are half-open.
# delta = cost(after) - cost(before) for the PLAIN closed tour. Successor of position n-1 is position 0.
cdef inline double two_opt_delta(const double[:, ::1] C, const int64_t[::1] tour,
Py_ssize_t i, Py_ssize_t j) noexcept nogil
# reverse tour[i..j], i < j. Exact iff the matrix is symmetric. O(1).
cdef inline double two_opt_delta_asym(const double[:, ::1] C, const int64_t[::1] tour,
Py_ssize_t i, Py_ssize_t j) noexcept nogil
# same move, exact for asymmetric matrices, O(j - i).
cdef inline double or_opt_delta(const double[:, ::1] C, const int64_t[::1] tour,
Py_ssize_t i, Py_ssize_t L, Py_ssize_t j, bint reverse) noexcept nogil
# move segment tour[i..i+L-1] (L in 1..3) so that it follows the node at position j
# (j not in [i-1, i+L-1]); optionally reversed. reverse=False is exact for asymmetric
# matrices; reverse=True is exact only when symmetric. O(1).
cdef inline double swap_delta(const double[:, ::1] C, const int64_t[::1] tour,
Py_ssize_t i, Py_ssize_t j) noexcept nogil
# exchange the nodes at positions i and j; i < j required; adjacent (j == i+1) handled.
# Exact for asymmetric matrices. O(1).
# ------------------------------------------------------------------ apply moves (in place)
# `_pos` variants keep pos[node] == position consistent; the plain variants do not touch pos.
cdef inline void reverse_segment(int64_t[::1] tour, Py_ssize_t i, Py_ssize_t j) noexcept nogil
# reverses tour[i..j] INCLUSIVE, i < j — the move priced by two_opt_delta(C, tour, i, j)
cdef inline void reverse_segment_pos(int64_t[::1] tour, int64_t[::1] pos, Py_ssize_t i, Py_ssize_t j) noexcept nogil
# same, inclusive
cdef inline void swap_positions(int64_t[::1] tour, Py_ssize_t i, Py_ssize_t j) noexcept nogil
cdef inline void swap_positions_pos(int64_t[::1] tour, int64_t[::1] pos, Py_ssize_t i, Py_ssize_t j) noexcept nogil
cdef void move_segment(int64_t[::1] tour, Py_ssize_t i, Py_ssize_t L, Py_ssize_t j, bint reverse) noexcept nogil
# the Or-opt move matching or_opt_delta; O(|i - j| + L) by rotating the affected span
cdef void move_segment_pos(int64_t[::1] tour, int64_t[::1] pos, Py_ssize_t i, Py_ssize_t L,
Py_ssize_t j, bint reverse) noexcept nogil
cpdef void double_bridge(const int64_t[::1] tour, Py_ssize_t p1, Py_ssize_t p2, Py_ssize_t p3,
int64_t[::1] out) noexcept nogil
# A B C D -> A C B D with A = tour[0..p1), B = [p1..p2), C = [p2..p3), D = [p3..n); 1 <= p1 < p2 < p3 <= n-1.
# Orientation-preserving: exact on ATSP. Writes to out (length n).
cpdef void rebuild_pos(const int64_t[::1] tour, int64_t[::1] pos) noexcept nogil
# ------------------------------------------------------------------ descents
# Return value = cost_after - cost_before, always <= 0 (callers do `cost += returned`); 0.0 means "local
# optimum for this move, nothing changed". A pass = one sweep over the nodes whose don't-look bit is active.
# cand: int64 (n, k) candidate lists from RoutingProblem.neighbours(k); dont_look: uint8[n] (0 = active).
# Both use Bentley's neighbour-list scan with the pruning `C[a, succ(a)] > C[a, c]` and reset the
# don't-look bits of the touched endpoints on improvement. Stop at a local optimum or after max_passes.
# The pos/cand/dont_look buffers are caller-owned and persist across calls (LocalSearch calls with max_passes=1).
cpdef double two_opt_descent(const double[:, ::1] C, int64_t[::1] tour, int64_t[::1] pos,
const int64_t[:, ::1] cand, uint8_t[::1] dont_look,
bint first_improvement, int max_passes) noexcept nogil
cpdef double or_opt_descent(const double[:, ::1] C, int64_t[::1] tour, int64_t[::1] pos,
const int64_t[:, ::1] cand, uint8_t[::1] dont_look,
int max_segment, bint allow_reverse, int max_passes) noexcept nogil
cpdef double local_search_generic(const double[:, ::1] C, const double[:, ::1] T, int64_t[::1] tour,
int64_t[::1] pos, const int64_t[:, ::1] cand, double max_time,
double fixed_cost, int split, int moves, int max_segment, int max_passes,
int64_t[::1] scratch_tour, double[::1] dp, int64_t[::1] pred) noexcept nogil
# Full-re-evaluation FIRST-IMPROVEMENT descent over the candidate neighbourhoods for the
# multi-trip objective and/or asymmetric matrices. moves is a bit mask: 1 = two_opt,
# 2 = or_opt (no reversal, segment lengths 1..max_segment), 4 = swap. `first_improvement=False`
# is honoured only on the symmetric plain path (TwoOpt/LocalSearch document this).
# O(n) per candidate move; documented ceiling ~2000 nodes.
# ------------------------------------------------------------------ construction
cpdef void nearest_neighbour_tour(const double[:, ::1] C, int64_t depot, int64_t[::1] out) noexcept nogil
Semantics of the two decoders, stated once (kernels implement exactly this):
- greedy:
t = 0; trips = 1; cost = 0; fork in 0..n-2:a = tour[k], b = tour[k+1]; ift + T[a,b] + T[b,d] <= max_timethent += T[a,b]; cost += C[a,b]elsecost += C[a,d] + C[d,b]; t = T[d,b]; trips += 1; finallycost += C[tour[n-1], d]; returncost + (trips-1) * fixed_cost. A trip start is every positionk+1where the else-branch fired, plus position 1. (This is the prototype'smultitrip_cost, verified.) - optimal (Prins): customers
c_1..c_m=tour[1..n-1];dp[0] = 0; forj in 0..m-1: open a trip atc_{j+1}, extendi = j+1, j+2, …while the open pathopen = T[d,c_{j+1}] + Σ_{k<i} T[c_k,c_{k+1}] <= max_time— this quantity is monotone ini, so breaking there is exact; a closed tripopen + T[c_i,d] <= max_timeis NOT monotone whenTviolates the triangle inequality (road matrices occasionally, the asymmetric test fixtures systematically), so the loop must not stop at the first infeasible closed trip. For eachiwhose closed trip is feasible:cand = dp[j] + C[d,c_{j+1}] + Σ C + C[c_i,d] + (fixed_cost if j > 0 else 0); ifcand < dp[i]:dp[i] = cand; pred[i] = j. Returndp[m]; trip starts by followingpredfromm. O(n·L), L = longest feasible open path.reference.optimal_splitimplements exactly this rule (its O(n²) DP tests every(j, i)pair, which is the same set once the open-path bound is applied). Because D5 guarantees every single-customer trip is feasible,dp[m]is always finite.
skroute/_core/__init__.py:
try:
from . import _routing
except ImportError as e: # pragma: no cover
raise ImportError("scikit-route's compiled core is missing. Install a wheel (pip install scikit-route) "
"or build from source with a C compiler: pip install -e .") from e
skroute/metrics.py (public recomputation, used by tests and users): route_cost(X, route, *, depot=None, labels=None, time_matrix=None, max_time_work=None, extra_cost=0.0, people=1, service_time=None, split="greedy") -> float builds a RoutingProblem (service_time as in §3.3, D32), converts the label route with to_index_tour — every depot occurrence removed, the giant tour re-decoded with split, so a hand-made multi-trip plan is priced as the decoder cuts it while timetable keeps its days as driven — and returns evaluate. depot=None means route[0] (the first label of the route — so a route_/tour_ produced with any depot= re-evaluates without repeating the depot); a given depot must equal route[0], else ValueError("depot must be the first label of route"). split_trips(route, depot=None) -> list[np.ndarray] splits a label route at depot occurrences (depot=None → route[0]) and returns closed trips [depot, …, depot] in route order; an open tour (depot only at the front) is returned as one closed trip. reference.route_cost_from_labels mirrors the same default.
4. Per-solver specifications¶
4.0 Glossary (mandatory names)¶
n_iter (outer iterations), max_passes (outer iterations of a local search, see §4.3), patience (outer iterations without improvement of the best-so-far before stopping; None disables; SA counts them only once the current cost has first fallen below the initial cost, see §4.4), time_limit (seconds, checked once per outer iteration, None disables; documented as breaking bit-exact reproducibility), init ("nearest_neighbour" | "random" | array-like of labels), n_candidates (k of the candidate lists; None = all n-1 nodes, i.e. the full neighbourhood), random_state, verbose (0 silent; 1 logs every max(1, n_outer // 10) outer iterations at INFO; 2 logs every iteration; every verbose docstring ends with the D24 sentence on enabling the skroute logger), max_nodes. local_search (one meaning everywhere): None, or a tuple of move names ⊆ {"two_opt", "or_opt"}; a single string is accepted and normalised to a 1-tuple; there is no "both". Defaults: ILS ("two_opt", "or_opt"), Genetic None, AntColony ("two_opt",). SA: t0, t_min, alpha, n_moves, moves. GA: pop_size, n_generations, crossover, p_crossover, mutation, p_mutation, tournament_size, n_elite, local_search. Tabu: tenure. ILS: perturbation_strength, acceptance, temperature, local_search. ACO: n_ants, alpha, beta, rho. SOM: n_units, learning_rate, lr_decay, radius, radius_decay. Clarke–Wright: shape (savings weight λ in s_ij = C[d,i] + C[j,d] - shape·C[i,j]). Ensemble: estimator, n_restarts, n_jobs, prefer. Move names: "two_opt", "or_opt", "swap". Improvement test everywhere: new < best - 1e-9 * max(1.0, abs(best)).
Every iterative solver: history_[k] = best-so-far after outer iteration k; n_iter_ = len(history_); stop_reason_. Every stochastic solver draws all randomness from the rng handed to _solve, in batches per outer iteration (D10). Generic path = local_search_generic / full evaluation when problem.multi_trip or not problem.symmetric.
Acceptance tests below are in addition to check_router (§6). "tiny" = the tiny_instance fixture (n = 5..9 symmetric and asymmetric Euclidean instances with coordinates, optimum by reference.brute_force); "alicante" = the multi-trip fixture (8 nodes, budget = 1.5 × max round trip, optimum by reference.brute_force under both split rules). Gaps are cost_/optimum - 1. Tiny/fast-tier/alicante acceptance tests live in the WP's own test file; every slow-tier gap test lives in tests/benchmarks/test_waterloo.py (WP8), driven by tests/tolerances.py and parametrised over all_solvers() — WP files never contain slow-tier tests.
4.1 skroute.exact¶
BruteForce(max_nodes=11) — exact/_brute_force.py + _brute.pyx. Tags: kind="exact", exact, budget_aware, max_nodes. Enumerates the (n-1)! permutations of positions 1..n-1 in lexicographic order by next_permutation (Knuth 7.2.1.2 Algorithm L, O(1) amortised) inside a nogil loop — not Heap's algorithm, whose order is not lexicographic — evaluating each with problem_cost (so it is exact for the chosen split rule too) and keeping the first strictly better tour; when problem.symmetric and not problem.multi_trip it skips permutations with tour[1] > tour[n-1] to halve the work (reversal changes the split, so never under a budget; the kept orientation is the lexicographically smaller one). is_optimal_ = True. Ties: the lexicographically first optimal permutation, identical to itertools.permutations in reference.brute_force. Complexity O((n-1)!·n); 10! at n = 11 ≈ 3.6 M tours in seconds. Acceptance: equals reference.brute_force on every tiny instance (symmetric, asymmetric) and on alicante under split="greedy" and "optimal".
HeldKarp(max_nodes=20) — _held_karp.py + _held_karp.pyx. Priority P1 (D26): ships only if WP2 finishes it by the feature freeze; MILP already solves n ≤ 20 instantly, so a deferral costs users nothing. Tags: kind="exact", exact, budget_aware=False, max_nodes. Bitmask DP over the n-1 non-depot nodes: dp[S][j] = cheapest path from the depot through set S ending at j, int8/int64 parent table for reconstruction, malloced (2^(n-1)·(n-1) doubles ≈ 80 MB at n = 20; the cap is documented, max_nodes may be raised to 23). Direction-aware, so ATSP works. Raises under a budget (D6). is_optimal_ = True. Acceptance: equals BruteForce on every tiny instance; runs wi29? no (29 > 20) — runs n = 16 random in < 0.1 s.
MILP(time_limit=60.0, max_nodes=300, mip_rel_gap=0.0) — _milp.py. Tags: kind="exact", exact, budget_aware=False, max_nodes. Dantzig–Fulkerson–Johnson with lazy subtour cuts on scipy.optimize.milp (HiGHS): symmetric → n(n-1)/2 binary edge variables with degree-2 equalities; asymmetric → n(n-1) arc variables with in/out-degree-1 equalities. Loop: solve; round x; find connected components of the support (scipy.sparse.csgraph.connected_components, weak); if one component → optimal; else add one cut per component Σ_{e ⊂ S} x_e <= |S| - 1 (edges inside S, or arcs with both ends in S) and re-solve with the remaining wall budget. DFJ over MTZ because MTZ's relaxation is weak (hours at n ≈ 200 where DFJ took 39 s on qa194). lower_bound_ = the largest valid bound seen: the objective of every solve that returned res.status == 0 (a DFJ relaxation solved to optimality is a valid bound) and res.mip_dual_bound of a time-limited solve when it is not None; res.fun of a time-limited solve is the PRIMAL value of the incumbent (an upper bound) and is never used as a bound, and a solve that stops without an incumbent returns fun is None and mip_dual_bound is None (verified with scipy 1.18), in which case the previous bound is kept. When the last solve returned an integral single-component solution, lower_bound_ == cost_ and is_optimal_ = True. On time-out: is_optimal_ = False, the returned tour is the best single-component integral solution seen or, failing that, core.nearest_neighbour_tour polished by core.two_opt_descent (WP2 depends on core M2 for this path only; never another WP's solver); gap_ = max(0.0, (cost_ - lower_bound_) / cost_), n_cuts_, n_solves_. Realistic sizes: ~200 symmetric nodes within a minute, ~60 asymmetric. Acceptance: equals BruteForce on tiny (symmetric and asymmetric); cost_ == 27603 on wi29 and 6656 on dj38 (fast tier); 9352 on qa194 (@slow, time_limit=150).
4.2 skroute.construction¶
NearestNeighbour() — Tags: kind="construction", budget_aware=False. Greedy from the depot over C, ties by lowest index; O(n²) in the core kernel. Acceptance: valid; tiny gap ≤ 0.50; fast tier ≤ 0.50 (measured with exactly this rule: wi29 31.8 %, dj38 46.4 %; slow tier qa194 24.5 %, uy734 25.4 %, zi929 19.5 %, lu980 26.7 % — recorded in benchmarks.md).
Insertion(strategy="farthest"), strategy in {"farthest", "cheapest", "nearest"} — _insertion.py + _insertion.pyx. Start with the depot and the farthest (cheapest: nearest) node; repeatedly pick the unrouted node by the strategy (farthest: max over unrouted of min distance to the partial tour; cheapest: min insertion cost; nearest: min distance to the tour) and insert it at its cheapest position; incremental min_dist[node] array; O(n²); direction-aware so exact on ATSP. Tags: kind="construction", budget_aware=False. There are no CheapestInsertion/FarthestInsertion classes (D18). Acceptance: tiny ≤ 0.30; fast tier: farthest ≤ 0.25, cheapest ≤ 0.30 (the tolerance tests run Insertion(strategy=s) for each strategy).
ClarkeWright(shape=1.0) — Tags: kind="construction", requires_symmetric, budget_aware=True. Parallel savings: every non-depot node starts as its own trip with creation index = its node index; savings s_ij = C[d,i] + C[j,d] - shape·C[i,j] sorted descending (stable, then by (i, j)); merge two trips at their endpoints when neither is interior and, under a budget, the merged closed trip's duration <= max_time_work; a merged trip keeps the smaller creation index of its two parts; without a budget it degenerates to the greedy-edge heuristic and merges everything into one tour. The returned giant tour is the concatenation of the trips in increasing creation index, each oriented so that its first node is the endpoint with the smaller C[d, ·] (ties: the smaller node index); the base class re-decodes it (greedy may merge trips further and never breaks feasibility; the docs state that the reported trips may differ from the savings trips). O(n² log n). Acceptance: fast tier ≤ 0.25; alicante ≤ 0.25 gap under greedy split; refuses an asymmetric matrix.
NRBS(mean_priority=1.0, std_priority=1.0, mean_connection=1.0, std_connection=1.0, distance_weight=1.0) — Tags: kind="construction", budget_aware=False. The five exponents default to the value 1.0 (1.0.0a2 had no defaults at all and rejected ints); 1.0's misspelt distance_weigth is renamed distance_weight. Faithful port of the 2020 heuristic: per node, μ_i and σ_i over the full row of C including the zero diagonal (population std), priority μ_i^a · σ_i^b sorted descending; connection score of j for i: μ_j^c · σ_j^e / max(C[i,j], 1e-12)^f sorted descending (the depot participates like any node; pairs with C[i,j] == 0 — coincident points, present in lu980 and rw1621 — therefore get the maximum score and are connected first, instead of an inf/nan whose sort order is platform-dependent). Two passes over the priority order: for each node with degree < 2, connect it to the highest-scoring candidate with degree < 2 that is not already its neighbour and does not close a cycle before all nodes are covered — cycle detection by union-find plus degree counters (replacing the deepcopy per candidate), which preserves the 2020 selection order and therefore its results; the hard-coded 19 becomes n. The resulting Hamiltonian path is closed and rotated to the depot; if the passes leave the graph disconnected (degenerate ties), the remaining endpoints are joined greedily by nearest endpoint. Complexity O(n² log n). Acceptance: valid on tiny; on Barcelona with all five parameters = 0.5 the plain tour sequence and cost equal the 1.0 result pinned in tests/data/nrbs_barcelona_1_0.json (abs=0.01 on the cost). Pinning procedure (once, WP3): the 1.0 tree is gone from modernization/v2 (commit e15a6af), so git worktree add /tmp/skroute10 533f320, a Python ≤ 3.11 venv (the 1.0 _cost kernel is Cython 0.29 and returns a C float, i.e. float32), replace skroute._utils._utils._cost by tests/reference.tour_cost (float64), run NRBS(0.5, 0.5, 0.5, 0.5, 0.5).fit(10000007, ids, cost_dict) on Barcelona built with pairs_to_matrix + to_dict_of_dicts, store {"cost": ..., "route": [...]} in the JSON with the commit and the command in a "provenance" field. Proposals reported 446.59 — verify, never copy. Fast tier ≤ 0.50.
4.3 skroute.local_search¶
Common parameters: init="nearest_neighbour", n_candidates=10, max_passes=50, verbose=0 (no time_limit, no patience, no random_state: these three descents are deterministic). Tags: kind="local_search", budget_aware=True, iterative=True. Iteration accounting (binding for all three): one outer iteration = one call of each listed descent kernel with max_passes=1; the pos, cand and dont_look buffers are allocated once and persist across calls; history_[k] = cost after outer iteration k (= history_[k-1] + Σ returned gains, gains being cost_after - cost_before <= 0); n_iter_ = outer iterations run; stop_reason_ = "converged" when an iteration returns 0.0 for every listed move, "max_iter" after max_passes iterations — the only two values these solvers emit. Symmetric plain TSP → two_opt_descent/or_opt_descent with pos and don't-look bits; otherwise → local_search_generic with the corresponding move mask and max_segment (generic path is first-improvement only; first_improvement=False is honoured only on the symmetric plain path — documented). n_candidates=None scans the full neighbourhood (neighbours(n-1)), which gives a markedly better local optimum at small n (lu980: 9.4 % vs 16.2 % for 2-opt alone); the default stays 10 for speed.
TwoOpt(first_improvement=True, ...) — 2-opt only. Acceptance: never worse than its init; tiny ≤ 0.10; fast tier ≤ 0.20.
OrOpt(max_segment=3, ...) — segment relocation 1..max_segment, both orientations when symmetric. Acceptance: tiny ≤ 0.12; fast tier ≤ 0.25.
LocalSearch(moves=("two_opt", "or_opt"), first_improvement=True, max_segment=3, ...) — alternates the listed descents (iteration accounting above) until none improves. moves ⊆ {"two_opt", "or_opt"}; "swap" is reachable only through local_search_generic's mask and is not accepted here (Options). Acceptance: tiny ≤ 0.10; fast tier ≤ 0.12; slow (qa194, lu980) ≤ 0.15 (measured 11.9 % / 7.8 % from NN with 10-NN lists; the margin absorbs scan-order differences).
IteratedLocalSearch(n_iter=1000, patience=100, perturbation_strength=1, acceptance="better", temperature=None, local_search=("two_opt", "or_opt"), n_candidates=10, init="nearest_neighbour", time_limit=None, random_state=None, verbose=0) — Tags: kind="local_search", stochastic, iterative, budget_aware. Descend from init (same iteration accounting as LocalSearch, run to convergence); then each outer iteration: copy the incumbent, apply perturbation_strength kicks, descend to convergence, accept if better ("better") or with Metropolis probability at fixed temperature ("metropolis", temperature=None → 0.5 % of the initial cost); track the best. Kick: for n >= 8 a double bridge with p1 < p2 < p3 = np.sort(rng.choice(np.arange(1, n), size=3, replace=False)) (a sample without replacement, never three sorted uniforms that may coincide), pre-drawn per iteration; for n < 8 (double bridge is undefined at n = 3 and degenerate below 8) the kick is a random segment reversal reverse_segment(tour, i, j) with i < j drawn without replacement from 1..n-1 (at n = 3 the only kick is the swap (1, 2)). local_search per the glossary (tuple of moves). Multi-trip and ATSP use the generic descent. stop_reason_ ∈ {"max_iter", "patience", "time_limit"}. This is the recommended default solver. Acceptance: tiny and alicante equal the optimum at seeds 0, 1, 2; fast tier ≤ 0.03; slow ≤ 0.06 (measured 4.16 % on lu980, stopped by patience at iteration 340).
4.4 skroute.metaheuristics¶
SimulatedAnnealing(t0="auto", t_min="auto", alpha=0.995, n_moves=None, moves=("two_opt", "or_opt", "swap"), patience=None, init="nearest_neighbour", time_limit=None, random_state=None, verbose=0) — _simulated_annealing.py + _sa.pyx. Tags: kind="metaheuristic", stochastic, iterative, budget_aware. Outer iteration = one temperature level of n_moves (default 10·n) proposals; per level Python pre-draws u ~ U(0,1), ri, rj ~ U{1..n-1}, mv ~ U{moves} and calls anneal_level(...). Draw → move mapping (binding): 2-opt: i, j = min(ri, rj), max(ri, rj), invalid if i == j; Or-opt: i = ri, L = 1 + (rj % 3), j = rj, invalid if i + L - 1 > n-1 or j in [i-1, i+L-1]; swap: i, j = min, max, invalid if i == j. An invalid draw is a rejected proposal: it counts towards n_moves, consumes its u and changes nothing — so n_moves means "proposals per level" identically for SA and EnsembleSimulatedAnnealing. Symmetric plain path: O(1) deltas (two_opt_delta, or_opt_delta, swap_delta), apply on accept. Generic path: copy tour → scratch, apply on scratch, problem_cost(scratch), accept by copying back. The kernel keeps a separate best buffer and copies into it on strict improvement (the 1.0 aliasing bug is impossible). t0="auto": sample 1000 random moves on the initial tour and set t0 = -median(Δ⁺)/ln(0.5) so a median uphill move is accepted with probability 0.5 (t0 = 1.0 if no uphill move); t_min="auto" → 1e-4·t0. Geometric cooling T *= alpha. Stop when T < t_min ("converged"), patience levels without improvement ("patience"), or time_limit. patience=None by default: with t0="auto" the hot phase is non-improving by design — measured with the prototype kernel, the current cost first drops below the NN start at level 436 (wi29), 413 (dj38), 836 (qa194), 1011 (uy734), 1023 (lu980), so a patience=50 default would return the NN tour (25–46 % gap). When a user sets patience, the count starts only once the current cost has first fallen below the initial cost (glossary); the docstring says so. t0_ is stored. stop_reason_ ∈ {"converged", "patience", "time_limit"}. Acceptance: tiny and alicante optimum at seeds 0, 1, 2; fast tier ≤ 0.03; slow ≤ 0.10 (measured 0.0/0.0/3.5/5.4/7.0 % for wi29/dj38/qa194/uy734/lu980 with a 2-opt-only kernel; the three-move mix is untested, hence the margin); two fits with random_state=0 bit-identical.
TabuSearch(n_iter=1000, tenure="auto", n_candidates=10, patience=200, init="nearest_neighbour", time_limit=None, random_state=None, verbose=0) — _tabu_search.py + _tabu.pyx. Tags: kind="metaheuristic", stochastic, iterative, budget_aware. Rewrite. Neighbourhood: 2-opt moves (i, j) where tour[j] is in the candidate list of tour[i-1] (symmetric plain path, O(1) deltas); generic path: for each such candidate pair, the 2-opt move and the no-reversal Or-opt relocations of the segments starting at i with L = 1, 2, 3, with full evaluation. Each iteration applies the best admissible move even if worsening. Tabu attributes are the removed edges (arcs when asymmetric): tabu_until[a, b] int32 (n, n) matrix (tenure counters never exceed n_iter; int64 would be 905 MB at n = 10 639 — the documented practical ceiling is ~5 000 nodes, 100 MB); a move is tabu if any edge it adds is tabu, unless it beats the incumbent (aspiration). tenure="auto" → rng.integers(ceil(sqrt(n)), 2*ceil(sqrt(n)) + 1, size=n_iter), i.e. uniform on [⌈√n⌉, 2⌈√n⌉] both ends inclusive, pre-drawn (robust tabu); an int is a fixed tenure (Interval(Integral, 1, None, closed="left")). history_ = best-so-far per iteration; stop_reason_ ∈ {"max_iter", "patience", "time_limit"}. Acceptance: tiny and alicante optimum at seeds 0, 1, 2; fast tier ≤ 0.08; slow ≤ 0.15.
Genetic(pop_size=100, n_generations=500, crossover="ox", p_crossover=0.9, mutation="inversion", p_mutation=0.2, tournament_size=3, n_elite=2, local_search=None, patience=100, init="nearest_neighbour", time_limit=None, random_state=None, verbose=0) — _genetic.py + _ga.pyx. Tags: kind="metaheuristic", stochastic, iterative, budget_aware. Chromosome = tour[1:] (int64 (pop_size, n-1) array); initial population: one individual from init, the rest random permutations; fitness = problem_cost of every row in one kernel call (evaluate_population). Per generation: tournament selection (size tournament_size), real crossover — "ox" (a segment [a, b] of parent 1, the rest in parent-2 order) or "pmx" (segment copy plus mapping repair) — with probability p_crossover, mutation with probability p_mutation per child: "inversion" (reverse a random segment = a 2-opt move), "swap", "insertion"; optional memetic polish local_search (glossary: None or a tuple ⊆ {"two_opt", "or_opt"}, a single string normalised to a 1-tuple) applied to every child with the §4.3 descents run to convergence (generic path under multi-trip/ATSP); generational replacement with the n_elite best parents kept and exact-duplicate children rejected (re-mutated once). The caller's data is never mutated. All randomness pre-drawn per generation (pop_size pairs of cut points, tournament indices, uniforms). Stop: n_generations ("max_iter"), patience, time_limit. Acceptance: tiny and alicante optimum at seeds 0, 1, 2; plain GA: fast tier ≤ 0.15, slow ≤ 0.30 on qa194 only (measured with exactly these defaults: 4.9 % wi29, 8.6 % dj38, 18.8 % qa194; at n ≈ 1000 a 50 000-evaluation GA essentially returns its elitist NN individual, so no plain slow test there); memetic local_search=("two_opt",): fast tier ≤ 0.05, slow ≤ 0.08 on qa194 and lu980 (@slow, several minutes: pop × generations 2-opt descents); OX/PMX children are permutations (hypothesis).
AntColony(n_ants=None, alpha=1.0, beta=2.0, rho=0.02, n_iter=200, n_candidates=20, local_search=("two_opt",), patience=50, time_limit=None, random_state=None, verbose=0) — _ant_colony.py + _aco.pyx. Tags: kind="metaheuristic", stochastic, iterative, budget_aware. Priority P1 (D26): ships in 2.0 only if WP6 finishes it with tests and docs by the feature freeze; otherwise it moves to 2.1 and its name is not exported. Deferral procedure (applies to every P1 item, with its own files): WP6's deferral PR removes docs/api/ant_colony.md and its nav line, the CHANGELOG line, its rows in tests/tolerances.py, and deletes tests/test_ant_colony.py, _ant_colony.py, _aco.pyx; the lead removes the export from metaheuristics/__init__.py and skroute/__init__.py in the same PR (the one cross-ownership PR allowed besides R2); the migration guide never mentions a deferred item. stop_reason_ ∈ {"max_iter", "patience", "time_limit"}. MAX–MIN Ant System: n_ants=None → min(n, 50); pheromone τ (n, n) initialised to 1/(ρ·L_NN) with bounds τ_max = 1/(ρ·L_best), τ_min = τ_max/(2n); each ant starts at the depot and chooses the next node by roulette over τ^α · (1/C)^β restricted to the unvisited candidate list (all unvisited nodes when the list is exhausted), consuming one pre-drawn uniform per step; optional 2-opt polish per ant; the deposit uses the problem cost (so the budget steers the colony) of the iteration-best ant, alternating with the global best every 5 iterations; evaporation τ *= (1-ρ). Acceptance: tiny optimum at seeds 0, 1, 2; fast tier ≤ 0.08; slow ≤ 0.15 (n ≤ 1000).
SOM(n_units=None, learning_rate=0.8, lr_decay=0.99997, radius=None, radius_decay=0.9997, n_iter=100_000, random_state=None, verbose=0) — _som.py, numpy only. Tags: kind="metaheuristic", stochastic, iterative, budget_aware=False, requires_coords. 1-D Kohonen ring over min-max-normalised coordinates (aspect-preserving); n_units=None → 8n, radius=None → n_units/10; each sample picks one random city (pre-drawn indices), finds the winner by vectorised argmin, updates the ring with a Gaussian of the ring distance (wrapped) and decays learning_rate and radius. Outer iteration = an epoch of max(1, n_iter // 100) samples. After each epoch the ring is decoded to a tour (cities ordered by winner index, ties by city index, rotated to the depot) and evaluated with problem.evaluate; history_[k] = best-so-far cost up to epoch k (monotone, D9/R8 — never the current cost), the returned tour is the best epoch's tour; n_iter_ = epochs run; n_samples_ = samples actually drawn; stop_reason_ = "converged" (radius < 1 or learning_rate < 1e-3, checked at epoch end) or "max_iter". No time_limit/patience parameters. Ceiling (stated in the docstring, problem_model.md and migration item 9): 2.0 evaluates every solution on a dense cost matrix, so SOM too needs the (n, n) matrix — the practical ceiling is ~20 000 nodes (3.2 GB); the four bundled instances above it (vm22775, sw24978, bm33708, ch71009) can be read and subsampled (load_tsp(name, n_nodes=5000)) but not solved whole; coordinate-only fitting (fit(None, coords=xy) with Euclidean cost on the fly) is deferred to 2.1 with on-the-fly distances (D18). Acceptance: fast tier ≤ 0.15 on wi29/dj38 coordinates; slow ≤ 0.15 on qa194; raises ValueError without coords; pytest.skip on asymmetric fixtures (no meaningful coordinates).
4.5 skroute.ensemble¶
MultiStart(estimator, n_restarts=10, n_jobs=None, prefer="threads", random_state=None, verbose=0) — _multistart.py. Tags: delegate to the estimator's tags with kind="ensemble", stochastic=True. Not in all_solvers() (needs an estimator, D27); tests/test_ensemble.py runs check_router(MultiStart(SimulatedAnnealing(), n_restarts=4)) and the n_jobs invariance test. Refuses a non-stochastic estimator (ValueError("MultiStart needs a stochastic estimator (one with random_state)")). _solve: seeds = np.random.SeedSequence(<int drawn from rng>).spawn(n_restarts); clone(estimator).set_params(random_state=np.random.default_rng(seed_k)); joblib.Parallel(n_jobs, prefer=prefer) runs est.fit(problem) on the shared RoutingProblem; keeps the best cost_ (ties → lowest index); exposes estimators_, costs_ (float64 (n_restarts,)), best_index_, best_estimator_, and copies history_/n_iter_/stop_reason_ from the best estimator when present. Results are identical for any n_jobs. get_params(deep=True) exposes estimator__*. Threads by default because kernels are nogil and a 900 MB matrix must not be pickled; the docs say GA/SOM scale less on threads.
EnsembleGenetic(n_genetics=10, n_jobs=None, random_state=None, verbose=0, *, pop_size=100, n_generations=500, crossover="ox", p_crossover=0.9, mutation="inversion", p_mutation=0.2, tournament_size=3, n_elite=2, local_search=None, patience=100, init="nearest_neighbour", time_limit=None) and EnsembleSimulatedAnnealing(n_simulateds=10, n_jobs=None, random_state=None, verbose=0, *, t0="auto", t_min="auto", alpha=0.995, n_moves=None, moves=("two_opt","or_opt","swap"), patience=None, init="nearest_neighbour", time_limit=None) — _legacy.py. Subclasses of BaseRouter; the inner knobs take the 2.0 defaults of §4.4 (1.0 defaulted n_simulateds=20, now 10 — a changed default, listed in the migration table). Tags: kind="ensemble", stochastic=True, iterative=True, budget_aware=True. _solve(problem, rng): ms = MultiStart(Genetic(**inner), n_restarts=self.n_genetics, n_jobs=self.n_jobs, prefer="threads", random_state=rng, verbose=self.verbose).fit(problem) (the outer rng Generator is passed so the whole run consumes exactly the outer random_state); copy history_, n_iter_, stop_reason_, estimators_, costs_, best_index_, best_estimator_ from ms onto self; return problem.to_index_tour(ms.tour_). Same for EnsembleSimulatedAnnealing with SimulatedAnnealing(**inner) and n_simulateds. Parameters are explicit so get_params works; they carry a DeprecationWarning in the docstring only (removal in 3.0), not at runtime.
4.6 Legacy name map (the eight names)¶
| 1.0.0a2 | 2.0 home | notes |
|---|---|---|
skroute.heuristics.brute.BruteForce |
skroute.exact.BruteForce |
skroute.heuristics shim re-exports with DeprecationWarning |
skroute.heuristics.NRBS.NRBS |
skroute.construction.NRBS |
the five exponents default to the value 1.0 (1.0.0a2 had no defaults); distance_weigth → distance_weight; fit(start, ids, cost) → fit(cost, depot=start) |
skroute.metaheuristics.genetics.Genetic |
skroute.metaheuristics.Genetic |
p_c→p_crossover, p_m→p_mutation, pop→pop_size, gen→n_generations, k→tournament_size, early_stopping→patience |
…genetics.EnsembleGenetic |
skroute.ensemble.EnsembleGenetic |
n_genetics kept |
…simulated_annealing.SimulatedAnnealing |
skroute.metaheuristics.SimulatedAnnealing |
temp→t0, delta→alpha (1.0 silently rescaled it into 0.9–1), tol→t_min, neighbours→n_moves |
…simulated_annealing.EnsembleSimulatedAnnealing |
skroute.ensemble.EnsembleSimulatedAnnealing |
n_simulateds kept as a name (default changes 20 → 10); the SA knobs take the 2.0 defaults of §4.4 |
…tabu_search.TabuSearch |
skroute.metaheuristics.TabuSearch |
searchs→n_iter, tabu_length/tabu_var→tenure, p_m dropped |
…som.SOM |
skroute.metaheuristics.SOM |
units→n_units, lr→learning_rate, fit(nodes, epochs) → fit(X, coords=..., ) with n_iter in __init__ |
Top-level exports: see §3.4 ("Top-level surface") — every solver of D18 plus RoutingProblem, clone, is_router, all_solvers, check_router, set_log_level, __version__.
Shim contents (WP8, exact): each legacy __init__.py re-exports exactly the 1.0 __all__ of that package — heuristics/brute: BruteForce; heuristics/NRBS: NRBS; metaheuristics/genetics: Genetic, EnsembleGenetic; metaheuristics/simulated_annealing: SimulatedAnnealing, EnsembleSimulatedAnnealing (1.0's __all__ misspelt it SimmulatedAnnealing; the shim exports the real name); metaheuristics/tabu_search: TabuSearch; metaheuristics/som: SOM; skroute/heuristics/__init__.py exposes the brute and NRBS subpackages. Each shim emits at import warnings.warn("skroute.<old path> is deprecated since 2.0 and will be removed in 3.0; import <Name> from skroute.<new path>", DeprecationWarning, stacklevel=2). tests/test_legacy_shims.py asserts identity with the new classes (shim.Genetic is skroute.metaheuristics.Genetic) and the warning text.
Every public name of 1.0.0a2 (this table is reproduced verbatim in docs/migration.md; nothing public from 1.0 may be missing from it):
| 1.0 name | status | 2.0 replacement | reason |
|---|---|---|---|
| the 8 classes above | kept (moved) | see the table above | — |
EnsembleGenetic, EnsembleSimulatedAnnealing |
kept (moved) | skroute.ensemble.*; or MultiStart(Genetic(...)) |
explicit-parameter wrappers over MultiStart |
preprocessing.dfcolumn_to_dict |
replaced | pairs_to_matrix |
result depended on row order |
preprocessing.matrix_to_dict |
replaced | to_dict_of_dicts |
referenced an undefined variable |
preprocessing.normalize(df, lat, lon) |
replaced | normalize_coords(coords) |
numpy in, numpy out |
preprocessing.df_to_tuple |
removed | Bunch.coords/labels, or df[[...]].to_numpy() |
one-line pandas idiom |
preprocessing.matrix_parse |
removed | pairs_to_matrix + distance_matrix |
superseded |
preprocessing.DataLossWarning |
removed | — | nothing emits it any more |
preprocessing.CostScraper |
deprecated wrapper | GoogleDistanceMatrix(api_key).fetch(coords, labels) |
§5.2; removed in 3.0 |
cluster.KMeansTruncate |
removed | sklearn.cluster.KMeans(max_iter=1) directly |
scikit-learn is no longer a dependency |
cluster.{KMeans, DBSCAN, AffinityPropagation, GaussianMixture} re-exports |
removed | import from scikit-learn | same |
27 load_<country> + 5 cost loaders |
kept | same names; return a Bunch/TSPBunch (§5.1) |
return shape changed: "DataFrame" → frame (with as_frame=True), "feature_names" dropped, DESCR kept |
load_costs_qatar |
deprecated alias | load_qatar_costs |
1.0 loaded Valencia |
mode= in TSP loaders |
deprecated | n_nodes= |
1.0 returned 0 rows for wi29 "small" |
fit(route_example, time_matrix, cost_matrix) |
changed | fit(cost, time_matrix=..., depot=..., init=...) |
D7; boxed warning in the guide |
Defaults that changed (results with default parameters differ from 1.0; also in docs/migration.md):
| class | 1.0 default | 2.0 default |
|---|---|---|
BruteForce, TabuSearch |
max_time_work=8, extra_cost, people in __init__ |
plain TSP unless max_time_work= is passed to fit |
Genetic |
p_c=0.6, p_m=0.4, pop=400, gen=1600, k=3 |
p_crossover=0.9, p_mutation=0.2, pop_size=100, n_generations=500, tournament_size=3, patience=100 |
EnsembleGenetic |
pop=400, gen=1000 |
pop_size=100, n_generations=500 |
SimulatedAnnealing |
temp=12.0, neighbours=250, delta=0.78, tol=1.29 |
t0="auto", n_moves=10·n, alpha=0.995, t_min="auto", patience=None |
EnsembleSimulatedAnnealing |
n_simulateds=20 |
n_simulateds=10 |
TabuSearch |
searchs=1250, p_m=0.6, tabu_length=45, tabu_var=10 |
n_iter=1000, tenure="auto" (p_m dropped) |
SOM |
radius_decay=0.9991, lr_decay=0.9991, fit(..., epochs=10_000) |
radius_decay=0.9997, lr_decay=0.99997, n_iter=100_000 |
NRBS |
no defaults (distance_weigth) |
all five 1.0 (distance_weight) |
5. Datasets and preprocessing API¶
5.1 skroute.datasets¶
Bunch (utils/_bunch.py): dict subclass with attribute access and a __repr__ listing keys. TSPBunch(Bunch) (datasets/_loaders.py) adds the real method distance_matrix(self, *, force=False) (not a key: keys() lists only data fields) with the result cached under the private attribute _distance_matrix.
Loader matrices carry no labels (they are plain float64 ndarrays): pass labels=b.labels to fit/RoutingProblem (or use as_frame=True, whose DataFrames carry the ids), otherwise depot=b.depot is rejected as "not a label of X" — every example and fixture in this document does so. The depot of every cost dataset is row 0.
TSPLIB instances — load_tsp(name, *, n_nodes=None, random_state=2019, mode=None) -> TSPBunch with fields name (e.g. "wi29"), coords (float64 (n, 2), x/y as in the file), labels (int64, the file's 1-based ids), depot (first label), edge_weight_type ("EUC_2D" for all 27), optimal_tour_length (from the table in the brief; None when subsampled), DESCR, and the method distance_matrix(*, force=False) → preprocessing.distance_matrix(coords, metric="tsplib_euc_2d"), cached, refusing n > 20_000 unless force=True (ch71009 would need 40 GB). The DESCR of the four instances above 20 000 nodes (vm22775, sw24978, bm33708, ch71009) states that 2.0 solves only dense matrices, that the whole instance cannot be solved in 2.0, and shows load_tsp(name, n_nodes=5000). They stay bundled in 2.0 (R6 decided: readers and subsampling keep working offline; a lazy download is a 2.1 option behind the same load_tsp). n_nodes subsamples without replacement with default_rng(random_state), keeping the first node; mode="small"|"medium"|"big" is accepted for 2.x with a DeprecationWarning and maps to max(10, round(0.005 n)) / round(0.2 n) / all (1.0 returned 0 rows for wi29 "small"). list_tsp() -> list[str] returns the 27 names. The 27 country functions (load_sahara, load_djibouti, load_qatar, … exactly the 1.0 names) are one-line wrappers load_tsp("wi29", **kwargs). Matrices are never built by the loader.
Cost datasets — load_alicante_murcia(*, as_frame=False), load_barcelona, load_madrid, load_valencia, load_qatar_costs (the 1.0 load_costs_qatar loaded Valencia; it remains as a deprecated alias that now loads Qatar). Return Bunch(cost, time, distance, coords, labels, depot, units, DESCR, frame): square float64 matrices pivoted from the long tables with pairs_to_matrix(..., symmetric=True) (verified: the files are upper triangles including the diagonal — Barcelona 190 rows for 19 nodes, Madrid 171/18, Valencia 105/14, Alicante 36/8; Qatar 18 336 rows for 192 nodes without the diagonal); cost in EUR (Spanish files) or kilometres (Qatar, which has no money column), time in hours (Qatar's seconds/3600), distance in metres, coords as (lat, lon), labels int64 ids in first-appearance order, depot = first id (10000002, 10000007, 10000016, 10000022, 1), units = {"cost": "EUR"|"km", "time": "h", "distance": "m"}. as_frame=True requires pandas (ImportError("pandas is required for as_frame=True: pip install scikit-route[pandas]")) and returns cost/time/distance as labelled DataFrames plus frame (the long table). Parsing uses the stdlib csv module.
TSPLIB reader — read_tsplib(path_or_file) -> Bunch(name, comment, type, dimension, edge_weight_type, edge_weight_format, coords | cost, display_coords): keywords tolerant of KEY : value and KEY: value (verified: dj38 mixes both and lacks EOF), CRLF, missing EOF; NODE_COORD_SECTION for EUC_2D, CEIL_2D, MAN_2D (P0) and ATT, GEO (P1, D26; coordinates kept raw; conversion happens in distance_matrix); EDGE_WEIGHT_SECTION for EXPLICIT with FULL_MATRIX, UPPER_ROW, LOWER_ROW, UPPER_DIAG_ROW, LOWER_DIAG_ROW (returns cost; P0 — the hypothesis round-trip and tests/data/explicit_matrix.tsp need it); read_tsplib_tour(path) -> int64 array of 1-based ids (P0). Pure Python, no pandas, no regex separators. An unsupported EDGE_WEIGHT_TYPE raises ValueError("EDGE_WEIGHT_TYPE GEO is not supported in this version").
5.2 skroute.preprocessing¶
distance_matrix(coords, metric="euclidean", *, block_size=2048) -> float64 (n, n);metric in {"euclidean", "manhattan", "tsplib_euc_2d", "tsplib_ceil_2d", "tsplib_man_2d", "tsplib_att", "tsplib_geo", "haversine"}(manhattan/tsplib_man_2dclose issue #24, §10;MAN_2D = nint(|dx| + |dy|)). TSPLIB metrics follow the TSPLIB 95 definitions exactly:nint(x) = floor(x + 0.5)(D15);CEIL_2D:ceil(sqrt(dx² + dy²));ATT(P1):r = sqrt((dx² + dy²) / 10.0); t = nint(r); d = t + 1 if t < r else t;GEO(P1), spelled out because it is NOT nint-rounded:PI = 3.141592; per coordinatedeg = int(x)(truncation towards zero, as in the TSPLIB reference code —nintevaluates the ulysses16 optimal tour to 6917, not 6859);m = x - deg; rad = PI * (deg + 5.0 * m / 3.0) / 180.0(same fory);RRR = 6378.388;q1 = cos(lon_i - lon_j); q2 = cos(lat_i - lat_j); q3 = cos(lat_i + lat_j);d = int(RRR * acos(0.5 * ((1.0 + q1) * q2 - (1.0 - q1) * q3)) + 1.0)(truncation plus one). Regression tests (WP7, active when the P1 metric ships): the published optimal tourstests/data/ulysses16.opt.tourandtests/data/att48.opt.tourevaluate to 6859 (GEO) and 10628 (ATT) onulysses16.tsp/att48.tsp.haversineon(lat, lon)degrees with radius6371.0088km. Computed blockwise to bound peak memory; warns above 20 000 nodes (1.8 GB at 15 000).euclidean_matrix(coords)andhaversine_matrix(latlon)are one-line conveniences.pairs_to_matrix(origin, destination, value, *, symmetric=True, labels=None, fill=None) -> (matrix, labels): sequences (or DataFrame columns) of equal length; labels in first-appearance order unless given;symmetric=Truemirrors missing entries; missing entries without a mirror raise unlessfillis given; the diagonal defaults to 0. Replacesdfcolumn_to_dict, whose result depended on row order.to_dict_of_dicts(matrix, labels=None) -> dictandfrom_dict_of_dicts(d) -> (matrix, labels)(the 1.0matrix_to_dictreferenced an undefined variable).normalize_coords(coords) -> float64 (n, 2): aspect-preserving min-max into[0, 1]²(what SOM uses).skroute.preprocessing.google.GoogleDistanceMatrix(api_key, mode="driving", *, batch_size=10)(P1, D26),.fetch(coords, labels=None) -> Bunch(distance, time, labels, units)with metres and hours:googlemapsis imported lazily inside__init__(ImportError("googlemaps is required: pip install scikit-route[google]")); requests are batchedbatch_size × batch_sizeorigins × destinations (the API caps at 100 elements per request; 1.0 issued one request per pair — 18 336 for Qatar); progress vialogging; tests mock the client.CostScraperis a thin deprecated wrapper, not a bare alias (a bare alias would bind 1.0'snodesargument tomodeand fail with an opaque googlemaps error after spending the user's quota):class CostScraper: def __init__(self, api, nodes, mode="driving")warnsDeprecationWarning("CostScraper is deprecated since 2.0 and will be removed in 3.0; use GoogleDistanceMatrix(api_key).fetch(coords, labels)"), storeslabels = [n[0] for n in nodes],coords = [n[1:] for n in nodes];scrap()callsfetchand returns its Bunch;pandas()returns the long table (requires pandas);to_pickle()raisesNotImplementedError("to_pickle was removed in 2.0; use pandas().to_pickle(...)"). IfGoogleDistanceMatrixis deferred,CostScrapergoes with it and both appear as "removed, returns in 2.1" in the migration table.
6. Testing conventions¶
Layout: §2 and D16 — tests/ is not a package; pyproject.toml carries [tool.pytest.ini_options] testpaths = ["tests"], pythonpath = ["tests"], addopts = "-m 'not slow' --strict-markers", doctest_optionflags = "NORMALIZE_WHITESPACE ELLIPSIS NUMBER", markers = ["slow: ...", "benchmark: ..."]. Command: pytest tests (slow deselected by addopts); pytest tests -m slow for benchmarks; pytest tests --cov=skroute --cov-report=term-missing in CI. Markers: slow (Waterloo n ≥ 194, MILP on qa194, anything > 10 s) and benchmark (alias on the same tests for tooling). Coverage target: 90 % of .py lines (kernels are covered through test_core.py; no Cython linetrace in CI). Nothing in the suite touches the network. Every slow-tier gap test lives in tests/benchmarks/test_waterloo.py (WP8), driven by tests/tolerances.py and parametrised over all_solvers().
tests/conftest.py:
import numpy as np, pytest
import reference # tests/ is on sys.path via pythonpath = ["tests"] (D16)
from skroute import all_solvers, RoutingProblem
from skroute.datasets import load_tsp, load_alicante_murcia, load_barcelona
from skroute.preprocessing import distance_matrix
OPTIMA = {"wi29": 27603, "dj38": 6656, "qa194": 9352, "uy734": 79114, "zi929": 95345, "lu980": 11340}
def _euclid(n, seed, asymmetric=False):
"""(C, coords). Every instance keeps the coordinates that generated it (SOM needs them)."""
rng = np.random.default_rng(seed)
xy = rng.random((n, 2)) * 100
C = distance_matrix(xy)
if asymmetric:
C = C * rng.uniform(0.7, 1.3, C.shape); np.fill_diagonal(C, 0.0)
return np.ascontiguousarray(C), xy
@pytest.fixture(scope="session", params=[(5, False), (7, False), (9, False), (6, True), (8, True)],
ids=lambda p: f"n{p[0]}{'-asym' if p[1] else ''}")
def tiny_instance(request):
n, asym = request.param
C, xy = _euclid(n, seed=n, asymmetric=asym)
return {"C": C, "coords": xy, "n": n, "asymmetric": asym, "optimum": reference.brute_force(C)[0]}
@pytest.fixture(scope="session")
def small_euclidean(): # n = 12, for reproducibility and label round-trip tests
C, xy = _euclid(12, seed=12)
return {"C": C, "coords": xy, "n": 12, "asymmetric": False}
@pytest.fixture(scope="session")
def medium_euclidean(): # n = 40, where seeds 0 and 1 give different tours (check 11)
C, xy = _euclid(40, seed=40)
return {"C": C, "coords": xy, "n": 40, "asymmetric": False}
@pytest.fixture(scope="session")
def alicante(): # multi-trip fixture, 8 nodes; the depot is row 0 == label d.depot
d = load_alicante_murcia()
budget = 1.5 * float((d.time[0, :] + d.time[:, 0]).max())
fit_kw = dict(labels=d.labels, depot=d.depot, max_time_work=budget, extra_cost=10.0, people=2) # LABEL space
ref_kw = dict(depot=0, max_time_work=budget, extra_cost=10.0, people=2) # INDEX space
opt = {s: reference.brute_force(d.cost, d.time, split=s, **ref_kw)[0] for s in ("greedy", "optimal")}
return {"bunch": d, "kwargs": fit_kw, "ref_kwargs": ref_kw, "optimum": opt}
@pytest.fixture(scope="session")
def barcelona():
return load_barcelona()
@pytest.fixture(scope="session", params=["wi29", "dj38"])
def fast_instance(request):
b = load_tsp(request.param)
return {"name": b.name, "C": b.distance_matrix(), "coords": b.coords, "labels": b.labels,
"asymmetric": False, "optimum": OPTIMA[b.name]}
@pytest.fixture(scope="session", params=["qa194", "uy734", "zi929", "lu980"])
def slow_instance(request):
b = load_tsp(request.param)
return {"name": b.name, "C": b.distance_matrix(), "coords": b.coords, "labels": b.labels,
"asymmetric": False, "optimum": OPTIMA[b.name]}
def pytest_generate_tests(metafunc):
if "Solver" in metafunc.fixturenames:
metafunc.parametrize("Solver", all_solvers(), ids=lambda s: s.__name__) # D27: no MultiStart here
def make(Solver, **overrides):
"""Instantiate with random_state=0 when accepted; used by every parametrised test."""
params = {"random_state": 0} if "random_state" in Solver._get_param_names() else {}
params.update(overrides)
return Solver(**params)
def fit_kwargs(Solver, inst):
"""coords= for requires_coords solvers; those are skipped on asymmetric instances (no meaningful coordinates)."""
if make(Solver)._get_tags().requires_coords:
if inst.get("asymmetric"):
pytest.skip(f"{Solver.__name__} needs coordinates; asymmetric instances have none")
return {"coords": inst["coords"]}
return {}
tests/reference.py (WP8, pure Python/numpy, ≤ 200 lines) — everything works in index space: tour_cost(C, tour), greedy_split(C, T, tour, max_time, fixed_cost) -> (cost, starts), optimal_split(...) (O(n²) DP over every (j, i) pair with the open-path bound of §3.5, so it agrees with the kernel on non-metric T), problem_cost(...), brute_force(C, T=None, *, depot=0, max_time_work=None, extra_cost=0.0, people=1, split="greedy") -> (cost, tour) via itertools.permutations (depot is an index; the alicante fixture passes ref_kw, never the label), two_opt_delta_by_recompute, or_opt_apply, ox, pmx. The one label-space helper is route_cost_from_labels(X, route, *, depot=None, labels=None, time_matrix=None, ...), which mirrors skroute.metrics.route_cost (depot=None → route[0]). Every core kernel is tested against these.
tests/tolerances.py (WP8) — the tolerance table below as data, the only place tolerance numbers live: TINY, FAST, SLOW dicts keyed by class name (Insertion keyed as "Insertion[farthest]"/"Insertion[cheapest]", Genetic as "Genetic"/"Genetic[memetic]" with the matching set_params), SEEDS_TO_OPTIMUM = {"IteratedLocalSearch", "SimulatedAnnealing", "TabuSearch", "Genetic", "AntColony"}, MEASURED (the per-instance baselines quoted in §4, rendered into benchmarks.md). A class in all_solvers() without an entry fails with KeyError("add a tolerance for <Name> in tests/tolerances.py"); benchmarks/waterloo.py prints the same dicts next to the measured gaps.
check_router(estimator) (skroute/utils/estimator_checks.py, WP8, public): takes an unfitted instance (so MultiStart(SimulatedAnnealing()) can be checked too), runs the structural checks 1–11, 13 and 14 below in-process on instances it builds itself — n = 6 symmetric Euclidean with coordinates, n = 6 asymmetric, n = 3 and n = 4 symmetric and asymmetric, and the alicante bunch loaded from skroute.datasets — and raises AssertionError prefixed with the check number. check_router.checks is the list of (name, fn) pairs, each fn(estimator). tests/test_common.py exposes every entry of check_router.checks as a parametrised pytest test over all_solvers() (via make(Solver), so fixtures such as capsys are usable there) and adds the tolerance tests (12) from tests/tolerances.py; it is the merge gate of every solver PR:
__init__stores every parameter verbatim and sets no other attribute (vars(est)equals itsget_params(deep=False));get_params/set_params/cloneround-trip;eval(repr(est))equalsest(with the class name imported).- Not fitted →
check_is_fittedraisesNotFittedError; no trailing-underscore attribute beforefit. fitreturnsself;route_[0] == route_[-1] == depot_;route_minus depot occurrences is a permutation oflabels_minus depot;tour_[0] == depot_;trips_closed;n_trips_ == len(trips_) == count(depot in route_) - 1.cost_ == pytest.approx(reference.route_cost_from_labels(...), rel=1e-9)andcost_ == pytest.approx(trip_costs_.sum() + fixed*(n_trips_-1), rel=1e-12)(two float summation orders; never exact equality).- ndarray, DataFrame (
pytest.importorskip("pandas")) and dict-of-dicts inputs give identicaltour_up to labels; string labels survive;depot=by label works;labels=on an ndarray works. - Invalid inputs raise
ValueErrorwith the messages of §3.3 (non-square, NaN, unknown depot,max_time_workwithouttime_matrix,time_matrixwithoutmax_time_work, RoutingProblem plus kwargs, and the D32 ones:service_timewithoutmax_time_work, a negative scalar, a wrong shape, a negative entry — so afitoverride that swallows or reordersservice_timeis caught); infeasible node →InfeasibleProblemError. - Tags honoured:
requires_symmetric→ raises on asymmetric;requires_coords→ raises without coords; exact andbudget_aware=False→ raises with a budget; non-exactbudget_aware=False→UserWarning;max_nodes→ raises above. - Multi-trip (alicante; skipped for solvers with
exact and not budget_aware, which raise under a budget per D6 and check 7; non-exact budget-unaware solvers run it under theUserWarning):trip_times_ <= budget + 1e-9,trip_times_absent for plain TSP; same tour, both decoders: after a fit undersplit="greedy",p_opt = RoutingProblem(d.cost, time_matrix=d.time, split="optimal", **kw)andp_opt.evaluate(est.problem_.to_index_tour(est.tour_)) <= est.cost_ + 1e-9. Comparing two independent heuristic fits (fit(optimal).cost_ <= fit(greedy).cost_) is NOT asserted — different searches return different tours; that inequality is asserted only for exact budget-aware solvers (BruteForce) intest_exact.py. D32: a scalarservice_time = budget / 8coerces to the array of §3.3 (0 at the depot; no fit needed), and a fit with the per-node arrays(budget / 8at every customer,budget / 16at the depot, so the once-per-trip departure term is exercised) hasproblem_.service_time == s(float64, copied),problem_.timeraw,trip_times_ <= budget + 1e-9with each value equal to the trip's driving time plus the services of its nodes pluss[depot],problem_.trip_timesreproducingtrip_times_, and equals — sametour_,trips_,cost_,trip_times_— the fit on the time matrix with the services folded in by hand (folded[:, others] += budget / 8,folded[depot, others] += s[depot], diagonal raw). A solver that prices durations onproblem.timefails here (tests/test_utils.py). - Nothing written to stdout/stderr (
capsys);verbose=1emits at least one record onlogging.getLogger("skroute")for iterative solvers. - Iterative:
len(history_) == n_iter_,history_non-increasing,history_[-1] == pytest.approx(cost_),stop_reason_in the solver's documented subset (§3.4 table); if"time_limit" in Solver._get_param_names(),time_limit=1e-6stops withstop_reason_ == "time_limit"after at most one outer iteration (TwoOpt/OrOpt/LocalSearch/SOM have notime_limitand skip this half). - Stochastic: two fits with
random_state=0givearray_equal(tour_), equalcost_,array_equal(history_); seed difference is deterministic, not statistical: seeds 0 and 1 give differenthistory_arrays (or differentn_iter_) onsmall_euclidean, or differenttour_onmedium_euclidean(n = 40), whichever the solver exposes — on n = 12 every metaheuristic finds the optimum with both seeds andtour_differs only by orientation, a coin flip; a passedGeneratoris advanced (bit_generator.statechanges across the fit). - Tolerances (in
test_common.py, not incheck_router), fromtests/tolerances.pyby class name andRouterTags.kind: exact solvers equal the tiny optimum (rel=1e-9); classes inSEEDS_TO_OPTIMUMequal it at seeds 0, 1, 2; every other class satisfies itsTINYentry;FASTentries onfast_instance;SLOWentries intests/benchmarks/test_waterloo.py. - Smallest legal sizes: every solver fits symmetric and asymmetric instances with n = 3 and n = 4 (built inline with coordinates), returns a valid tour, and exact solvers equal
reference.brute_forcethere (covers the ILS kick below n = 8, Or-opt with L = 3 at n = 4, the Tabu neighbourhood, SA proposals and BruteForce's halving at their edges). - Callback protocol (D30, D31):
fit(callback=<non-callable>)raisesTypeErrornaming the argument; with a recording callback the events formstart, iteration*, endper emitting solver (iterationindexed1, 2, ..., strictly increasing; one per entry ofhistory_for an iterative solver), every non-Nonetour is a valid label tour priced exactly by itscost/best_cost, a missing tour has anancost — unknown, excluded from the monotonicity check — andbest_costis non-increasing over the finite values and ends atcost_withtour_(route/tripsdecoding toroute_/trips_); the D31 extras are well formed when present (extra["edges"]a list of(label, label)tuples of labels of the problem,extra["edge_weights"]a parallel list of numbers in[0, 1],extra["ring"]a finite float(m, 2)array); the same trace holds under string labels with a non-first depot (event tours are object arrays starting at the depot label); recording does not change the result; neither the callback nor a stop request survives the fit; returningTruestops an iterative solver after one outer iteration withstop_reason_ == "callback"and silences the trace of a non-iterative one (at most one moreiterationevent beforeend; a construction solver's result is unchanged).
Tolerance table (tests/tolerances.py; gap = cost_/optimum - 1, defaults, random_state=0; optimum <= cost_ + 1e-9 is asserted first — a violation is a reader/rounding/evaluator bug). Measured baselines per instance are recorded in MEASURED and benchmarks.md, so a regression is distinguishable from a tie-break difference:
| Solver | tiny (n ≤ 9) | fast tier (wi29, dj38) | slow (qa194, uy734, zi929, lu980) |
|---|---|---|---|
| BruteForce, HeldKarp (if shipped) | 0 | — (cap) | — |
| MILP | 0 | 0 | 0 on qa194 (@slow, 150 s) |
| NearestNeighbour | ≤ 0.50 | ≤ 0.50 (dj38 measures 46.4 %) | ≤ 0.35 |
| Insertion[farthest] / Insertion[cheapest] | ≤ 0.30 | ≤ 0.25 / 0.30 | ≤ 0.25 / 0.30 |
| ClarkeWright | ≤ 0.30 | ≤ 0.25 | ≤ 0.25 |
| NRBS | valid | ≤ 0.50 | ≤ 0.60 (qa194 only) |
| TwoOpt / OrOpt | ≤ 0.10 / 0.12 | ≤ 0.20 / 0.25 | ≤ 0.20 / 0.25 (OrOpt alone measures 21.7 % on lu980) |
| LocalSearch | ≤ 0.10 | ≤ 0.12 | ≤ 0.15 (measured 11.9 % qa194) |
| IteratedLocalSearch | 0 (3 seeds) | ≤ 0.03 | ≤ 0.06 (measured 4.16 % lu980) |
| SimulatedAnnealing | 0 (3 seeds) | ≤ 0.03 | ≤ 0.10 (measured 7.0 % lu980) |
| TabuSearch | 0 (3 seeds) | ≤ 0.08 | ≤ 0.15 |
| Genetic (plain) | 0 (3 seeds) | ≤ 0.15 (measured 8.6 % dj38) | ≤ 0.30, qa194 only (measured 18.8 %) |
Genetic[memetic] (local_search=("two_opt",)) |
0 (3 seeds) | ≤ 0.05 | ≤ 0.08 on qa194 and lu980 (@slow, minutes) |
| AntColony (if shipped) | 0 (3 seeds) | ≤ 0.08 | ≤ 0.15 (n ≤ 1000) |
| SOM (+coords) | valid | ≤ 0.15 | ≤ 0.15 (qa194) |
| EnsembleGenetic / EnsembleSimulatedAnnealing | 0 (3 seeds) | ≤ the wrapped solver's entry | — |
MultiStart(SA, 4) (test_ensemble.py) |
0 | ≤ min(SA) | ≤ SA, and n_jobs=1 == n_jobs=2 |
Tightening a tolerance is a release-notes item; loosening one requires the lead's approval and a CHANGELOG line.
Hypothesis (tests/test_core.py, settings(derandomize=True, deadline=None, max_examples=200)): strategies generate n in 3..12, symmetric/asymmetric finite matrices, random permutations with the depot first, budgets in [max round trip, 3 × max round trip]. Properties: tour_cost == reference; greedy_split_cost/optimal_split_cost equal the reference and optimal <= greedy; every trip from trip_starts fits the budget and the trips cover positions 1..n-1; two_opt_delta == cost(after) - cost(before) on symmetric matrices and two_opt_delta_asym on asymmetric; or_opt_delta(reverse=False) and swap_delta exact on asymmetric; applying a move then its inverse is the identity; pos[tour[i]] == i after every _pos move and every descent; two_opt_descent/or_opt_descent/local_search_generic never increase the cost, return a permutation, and the returned gain equals the cost difference; double_bridge is a permutation with the depot fixed; from_dict_of_dicts(to_dict_of_dicts(M)) == M; read_tsplib round-trips generated EUC_2D/EXPLICIT files; nint matches floor(x+0.5) on half-integers. Reproducibility is asserted, never assumed (check 11); no exact-float golden values across platforms (D19).
Example test (WP5):
# tests/test_simulated_annealing.py
import numpy as np, pytest
import reference
from skroute.metaheuristics import SimulatedAnnealing
from skroute.metrics import route_cost
@pytest.mark.parametrize("seed", [0, 1, 2])
def test_reaches_optimum_on_tiny(tiny_instance, seed):
C, opt = tiny_instance["C"], tiny_instance["optimum"]
sa = SimulatedAnnealing(random_state=seed).fit(C)
assert sa.cost_ == pytest.approx(opt, rel=1e-9)
assert sa.cost_ == pytest.approx(route_cost(C, sa.route_))
assert sa.history_[-1] == pytest.approx(sa.cost_) and np.all(np.diff(sa.history_) <= 1e-12)
def test_multi_trip_respects_budget_and_matches_reference(alicante):
d, kw = alicante["bunch"], alicante["kwargs"] # kw carries labels= and the LABEL depot
sa = SimulatedAnnealing(random_state=0).fit(d.cost, time_matrix=d.time, **kw)
assert np.all(sa.trip_times_ <= kw["max_time_work"] + 1e-9)
assert sa.cost_ == pytest.approx(alicante["optimum"]["greedy"], rel=1e-9)
assert sa.cost_ == pytest.approx(reference.problem_cost(d.cost, d.time, sa.problem_.to_index_tour(sa.tour_),
kw["max_time_work"], 10.0 * 2, "greedy"))
def test_same_seed_is_bit_identical(small_euclidean):
a, b = (SimulatedAnnealing(random_state=7).fit(small_euclidean["C"]) for _ in range(2))
assert np.array_equal(a.tour_, b.tour_) and a.cost_ == b.cost_ and np.array_equal(a.history_, b.history_)
7. Documentation and community deliverables¶
Docstrings: numpydoc (Parameters, Attributes, Notes with the algorithm and complexity, References, Examples that run). Every public class documents its tags in a Notes line ("Supports: symmetric and asymmetric matrices, multi-trip objective; stochastic"). mkdocstrings renders them; mkdocs build --strict fails on broken cross-references.
mkdocs.yml (Material theme, mkdocstrings with python handler docstring_style: numpy, include-markdown, search):
nav:
- Home: index.md
- Getting started: getting_started.md
- Installation: installation.md
- User guide:
- The problem model: user_guide/problem_model.md
- Choosing a solver: user_guide/choosing_a_solver.md
- Multi-trip routing: user_guide/multi_trip.md
- Warm starts and ensembles: user_guide/warm_starts_and_ensembles.md
- API reference:
- Base and problem: api/base.md
- Exact solvers: api/exact.md
- Construction heuristics: api/construction.md
- Local search: api/local_search.md
- Simulated annealing: api/simulated_annealing.md
- Tabu search: api/tabu_search.md
- Genetic algorithm: api/genetic.md
- Ant colony: api/ant_colony.md # removed by the deferral PR if AntColony is deferred (D26)
- Self-organising map: api/som.md
- Ensembles: api/ensemble.md
- Datasets: api/datasets.md
- Preprocessing: api/preprocessing.md
- Utilities: api/utils.md
- Benchmarks: benchmarks.md
- Migration from 1.0: migration.md
- Contributing: contributing.md
- Changelog: changelog.md
- About: about.md
Every public symbol has a documentation home. api/base.md (lead) carries exactly: skroute.RoutingProblem, skroute.base.BaseRouter, skroute.base.RouterTags, skroute.clone, skroute.is_router, skroute.all_solvers, skroute.set_log_level, skroute.exceptions, skroute.metrics; api/utils.md (lead) carries skroute.utils (Bunch, check_is_fitted, check_random_state, initial_tour) and skroute.utils.estimator_checks.check_router; each solver page carries one ::: per class of its __all__; api/ensemble.md also documents EnsembleGenetic/EnsembleSimulatedAnnealing. docs/check_api_coverage.py (WP8, ≈15 lines, run in docs.yml before mkdocs build) fails if any name in any __all__ under skroute/ has no ::: directive under docs/api/.
Capability table, generated once, copied nowhere by hand. docs/gen_pages.py (WP8, run by mkdocs-gen-files) imports skroute.all_solvers() and writes docs/user_guide/_capability_table.md with the columns solver · kind · exact · stochastic · multi-trip aware (budget_aware) · ATSP (not requires_symmetric) · needs coords · max n; choosing_a_solver.md includes it with include-markdown; the README carries the same table between <!-- capability-table:start -->/<!-- capability-table:end --> markers refreshed by python docs/gen_pages.py --readme in a pre-commit hook (the hook fails if the README is stale).
README outline: logo; badges (PyPI version, Python versions, CI, docs, license, coverage); one-paragraph pitch; install (pip install scikit-route, extras); the wi29 example with IteratedLocalSearch and the Barcelona multi-trip example exactly as in §3.4 (>>> form, labels=, keyword time_matrix=; the README is doctested too); the generated capability table between its markers; links to docs, migration guide, contributing; citation ("Cite via CITATION.cff — GitHub → Cite this repository"); license.
CHANGELOG.md skeleton:
# Changelog
All notable changes to this project are documented here. Format: Keep a Changelog; versioning: SemVer.
## [2.0.0] - 2026-MM-DD
### Added
- Solvers: HeldKarp (if shipped), MILP, NearestNeighbour, Insertion, ClarkeWright, TwoOpt, OrOpt, LocalSearch,
IteratedLocalSearch, AntColony (if shipped), MultiStart; RoutingProblem; skroute.metrics; check_router; set_log_level.
- Optimal split of the giant tour (`split="optimal"`), TSPLIB reader, distance_matrix with TSPLIB metrics.
### Changed
- fit() takes the cost matrix (numpy / DataFrame / dict-of-dicts) and returns self; time_matrix is keyword-only;
results in route_, trips_, cost_ ...
- Objective: trips never exceed max_time_work including the return leg; people multiplies only extra_cost.
- Default hyper-parameters changed for every solver (table in the migration guide); results with defaults differ from 1.0.
- Genetic: real OX/PMX crossover; SimulatedAnnealing: aliasing bug fixed, moves and auto temperature;
TabuSearch rewritten (edge attributes); NRBS: union-find cycle check, hard-coded 19 removed, distance_weigth -> distance_weight; SOM in numpy.
- Datasets return Bunch objects (feature_names dropped, DataFrame -> as_frame=True); pandas, googlemaps optional;
tensorflow, scikit-learn, tqdm dropped; Python >= 3.11; numpy 2 compatible.
### Removed
- skroute.cluster (KMeansTruncate and the scikit-learn re-exports); preprocessing.df_to_tuple, matrix_parse, DataLossWarning;
pickled datasets (CSV instead); the tuple return of fit(); route_example; CostScraper.to_pickle.
### Fixed
- load_costs_qatar loaded Valencia; matrix_to_dict undefined variable; datasets __all__; TSPLIB sep="\s".
### Deprecated
- skroute.heuristics.*, skroute.metaheuristics.<legacy subpackages>, CostScraper (wrapper), load_costs_qatar, mode= in loaders (removed in 3.0).
Migration guide outline (docs/migration.md, lead): (1) install and imports (the shim table of §4.6); (2) fit signature before/after for each of the eight classes with a runnable snippet, opened by a boxed warning: "The matrix order is reversed and time_matrix is keyword-only in 2.0: 1.0's fit(route, time, cost) becomes fit(cost, time_matrix=time, depot=route[0]); passing the time matrix positionally raises TypeError"; (3) the return value → attributes; (4) route_example → matrix rows + depot=/init=; (5) the three behaviour changes of the objective with the Barcelona worked example (numbers produced by the code); (6) the hyper-parameter rename table of §4.6 and the "Defaults that changed" table with the sentence "results with default parameters differ from 1.0"; (7) the "Every public name of 1.0.0a2" table of §4.6, verbatim; (8) datasets: Bunch fields, as_frame, load_tsp, labels=b.labels; (9) SOM with coords= and the dense-matrix ceiling (~20 000 nodes; coordinate-only fitting deferred to 2.1); (10) Ensemble* → MultiStart; (11) extras and dropped dependencies; (12) CostScraper → GoogleDistanceMatrix (or "returns in 2.1" if deferred).
CI workflows (lead):
ci.yml— on push/PR. Joblint(ubuntu, 3.12):pip install -e ".[dev]",ruff check .,ruff format --check .,cython-lint skroute,mypy skroute. Jobtestmatrixos ∈ {ubuntu-latest, macos-latest, windows-latest} × python ∈ {3.11, 3.12, 3.13, 3.14}: checkout,setup-python,pip install -e ".[test]"(builds the extension),pytest tests --cov=skroute --cov-report=xml(slow deselected byaddopts), upload coverage withcodecov/codecov-action@v4and theCODECOV_TOKENrepository secret (one job; the badge is dropped if the secret is not configured). Jobtest-prerelease(ubuntu,3.15-dev,continue-on-error: true): same steps. Jobsdist(ubuntu, 3.12):python -m build --sdist,pip install dist/*.tar.gzin a fresh venv,python -c "import skroute, sys; assert 'site-packages' in skroute.__file__, skroute.__file__", thenSKROUTE_EXPECT_WHEEL=1 pytest tests -x— thepytestentry point, neverpython -m pytest(D16).wheels.yml— on tagsv*andworkflow_dispatch.cibuildwheel(≥ 3.0) matrix: Linux x86_64 and aarch64 on the defaultmanylinux_2_28images (manylinux2014's CentOS 7 base is EOL; glibc ≥ 2.28 covers every distribution supported in 2026), macOS x86_64 and arm64, Windows AMD64;build = "cp311-* cp312-* cp313-* cp314-*";enable = ["cpython-prerelease"]only in a separatecontinue-on-errorjob for cp315;test-requires = ["pytest", "hypothesis", "pandas"],test-command = "pytest {project}/tests -x -q"(no-mclause —cmd.exedoes not understand single quotes, andaddoptsalready deselectsslow),test-environment = { SKROUTE_EXPECT_WHEEL = "1" }sotest_base.py::test_runs_against_installed_copyproves the wheel, not the checkout, is under test; sdist job;pypa/gh-action-pypi-publishwith trusted publishing on tag (TestPyPI onworkflow_dispatch). No abi3, no free-threaded wheels in 2.0.docs.yml— on push tomainandworkflow_dispatch:pip install -e ".[docs,test]",pytest --doctest-modules skroute docs README.md --doctest-glob="*.md" --ignore=docs/changelog.md --ignore=docs/contributing.md -q(pytest collects doctests only under the paths given, sodocsandREADME.mdmust be listed;--doctest-glob="docs/**/*.md"matched nothing — verified; the two ignored pages only include other files),python docs/check_api_coverage.py,mkdocs build --strict,mkdocs gh-deploy --force(Pages).nightly.yml— cron0 3 * * *andworkflow_dispatch: ubuntu + macos, 3.13:pytest tests -m slow -q --junitxml,python benchmarks/waterloo.py --out /tmp/benchmarks.md, compared with the committeddocs/benchmarks.md; when any gap moves by more than 0.5 percentage points the job opens or updates a PR "benchmarks: nightly update" with the regenerated page (peter-evans/create-pull-request). The committed page is regenerated by WP8 on the release candidate (release checklist item).
Community files (WP8, content fixed here so nobody has to ask): CONTRIBUTING.md (dev setup, pre-commit install, ownership rules of §8, how to add a solver: file, __all__, docs page, check_router green, a tests/tolerances.py entry, CHANGELOG line requested from the lead); CODE_OF_CONDUCT.md (Contributor Covenant 2.1, enforcement contact al.rubiales.b@gmail.com — the address in pyproject.toml); SECURITY.md ("Report privately via GitHub → Security → Report a vulnerability, or e-mail al.rubiales.b@gmail.com; acknowledgement within 14 days; only the latest 2.x minor receives fixes; never open a public issue for a vulnerability"); bug_report.yml (fields: version, OS, Python, minimal matrix or dataset name, code, expected, actual, traceback); feature_request.yml (fields: problem, proposed API as a code block, alternatives considered, would you implement it); PR template (checklist: tests, docs page, CHANGELOG request, only my files, tolerances entry). docs/about.md: origin (2020, Alberto Rubiales, the Secoex routing problem), the 2026 rewrite, licence, how to cite (CITATION.cff), link to the benchmarks page. docs/installation.md: pip install scikit-route (wheels for CPython 3.11–3.14 on Linux x86_64/aarch64 manylinux_2_28, macOS x86_64/arm64, Windows AMD64), the extras table, source builds need a C compiler + Cython ≥ 3.1, no conda package yet (§10, #34). docs/index.md: the pitch, the two §3.4 examples, the capability table.
8. Work breakdown¶
Dates (D26): week 0 starts 2026-09-07; core M1 2026-09-10, M2 2026-09-16; feature freeze 2026-10-16 (P1 items ship or are deferred by written PR); release candidate 2026-10-23 (docs/benchmarks.md regenerated, nightly green on three OSes, every open issue dispositioned per §10); 2.0.0 on 2026-10-30.
Execution (D29): the work packages are executed by agents in parallel git worktrees merged by the lead, in two waves. Wave A: the spine (lead-owned files of §3 plus utils/estimator_checks.py, tests/conftest.py, tests/tolerances.py, tests/test_common.py), WP1 core, WP7 data (datasets + preprocessing) and WP8's community files, all at once; the lead merges, builds the core and runs the whole suite. Wave B: WP2–WP6, WP7 SOM and WP8's ensemble/shims/benchmarks/user guide against the compiled core. Nothing is developed against a stand-in: there is no SKROUTE_REFERENCE switch and no _core/_reference.py. Milestones M1/M2 collapse into 'WP1 complete before wave B'.
Rules: a PR touches only its owner's files (enforced by CODEOWNERS + required review); every __init__.py under skroute/ except the subpackage ones listed as owned is lead-only and append-only; a new core primitive is requested by issue to WP1 (never edited in); a solver PR merges only when check_router is green for it, its tests/tolerances.py entry exists and passes on the tiny and fast tiers, its docs page exists, and its acceptance tests pass on the three OSes. With six implementers: merge WP5 into WP6 and WP7's SOM into WP4; never merge WP1 with anything.
| WP | Owner of (and only of) | Depends on | Definition of done |
|---|---|---|---|
| L lead | pyproject.toml, setup.py, MANIFEST.in, README.md, CHANGELOG.md, CITATION.cff, mkdocs.yml, .pre-commit-config.yaml, .gitignore, .github/CODEOWNERS, .github/workflows/*, skroute/__init__.py, _version.py, py.typed, base.py, problem.py, exceptions.py, metrics.py, utils/** except utils/estimator_checks.py, metaheuristics/__init__.py, tests/test_base.py, docs/api/base.md, docs/api/utils.md, docs/migration.md |
— | week-0 spine merged; every export added on landing; release cut after nightly green on 3 OSes |
| WP1 core | skroute/_core/** except _core/__init__.py, tests/test_core.py, benchmarks/kernels.py |
tests/reference.py (WP8, day 1) |
every function of §3.5 implemented (inline bodies in the .pxd) and .pyi-stubbed; hypothesis suite green; benchmarks/kernels.py reproduces the baseline (tour cost ≤ 30 µs at n = 10 639, 2-opt+Or-opt on fi10639 ≤ 150 ms) |
| WP2 exact | skroute/exact/**, tests/test_exact.py, docs/api/exact.md |
core M1 (M2 only for MILP's time-out fallback) | BruteForce/MILP per §4.1 (HeldKarp P1); MILP hits 27603/6656 fast and 9352 slow; fit(optimal).cost_ <= fit(greedy).cost_ for BruteForce on alicante |
| WP3 construction | skroute/construction/**, tests/test_construction.py, tests/data/nrbs_barcelona_1_0.json, docs/api/construction.md |
core M1 | four classes per §4.2; NRBS 1.0 regression pinned by the stated procedure; ClarkeWright multi-trip test |
| WP4 local search | skroute/local_search/**, tests/test_local_search.py, docs/api/local_search.md |
core M2 | four classes per §4.3; ILS reaches optimum on tiny/alicante at 3 seeds; fast-tier gaps within table (slow-tier gaps run in WP8's test_waterloo.py, whose entries WP4 must make green) |
| WP5 SA + Tabu | skroute/metaheuristics/_simulated_annealing.py, _sa.pyx, _tabu_search.py, _tabu.pyx, tests/test_simulated_annealing.py, tests/test_tabu_search.py, docs/api/simulated_annealing.md, docs/api/tabu_search.md |
core M1 | both per §4.4; bit-identical reproducibility test; generic path covered by alicante and an asymmetric tiny |
| WP6 GA + ACO | skroute/metaheuristics/_genetic.py, _ga.pyx, _ant_colony.py, _aco.pyx, tests/test_genetic.py, tests/test_ant_colony.py, docs/api/genetic.md, docs/api/ant_colony.md |
core M1 (M2 for memetic) | Genetic per §4.4 with OX/PMX hypothesis tests; AntColony is P1 — ship or hand the lead a written deferral by the feature-freeze date |
| WP7 SOM + data | skroute/metaheuristics/_som.py, skroute/datasets/**, skroute/preprocessing/**, tests/test_som.py, tests/test_datasets.py, tests/test_preprocessing.py, tests/data/* except nrbs_barcelona_1_0.json, docs/api/som.md, docs/api/datasets.md, docs/api/preprocessing.md |
none (SOM needs core M1 only for evaluation) | loaders and reader per §5 (GEO/ATT/GoogleDistanceMatrix P1); .pkl deleted; nint test on qa194 (two half-integer entries); ulysses16 = 6859 / att48 = 10628 if GEO/ATT ship; Google client mocked; SOM per §4.4 (epochs, best-so-far history) |
| WP8 ensemble, tests infra, docs | skroute/ensemble/**, skroute/utils/estimator_checks.py, all shim __init__.py under heuristics/ and metaheuristics/<legacy>/, tests/conftest.py, tests/reference.py, tests/tolerances.py, tests/test_common.py, tests/test_ensemble.py, tests/test_legacy_shims.py, tests/benchmarks/**, benchmarks/waterloo.py, docs/** except api/* and migration.md (includes gen_pages.py, check_api_coverage.py, the committed benchmarks.md), CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, .github/ISSUE_TEMPLATE/*, .github/PULL_REQUEST_TEMPLATE.md |
base (day 0) | reference.py, conftest.py, tolerances.py and check_router on day 1 (structural checks 1–11, 13 and 14); MultiStart n_jobs-invariant; Ensemble wrappers per §4.5; shims warn with the exact text; site builds --strict with API coverage green; benchmarks.md committed on the release candidate |
Interfaces each package codes against: the fitted-attribute table (§3.4), RoutingProblem (§3.3), _routing.pxd (§3.5), initial_tour (§3.4), the glossary (§4.0), the fixtures (§6). Nothing else is shared.
9. Open risks and how to behave¶
- R1 Core is late or incomplete. Wave B does not start until WP1's hypothesis suite is green (D29). If a kernel of §3.5 turns out to be missing or wrong when a solver needs it, the solver owner reports it (never patches
_core/), and the lead re-runs WP1 for that kernel before merging the solver; the.pxdsignature does not change. - R2 A
.pxdsignature must change. Open an issue titledcore: <function>; WP1 and the lead approve; WP1 updates.pxd/.pyx/.pyiand every cimporting.pyxin the same PR (the only cross-ownership PR allowed), announced in the channel before merging. - R3 A tolerance fails on one OS. Do not loosen it silently: post the three-OS gaps in the PR; the lead either accepts a documented loosening (CHANGELOG line) or asks for a default change. Never pin an exact float across platforms (libm
expdiffers by ulps and flips Metropolis decisions). - R4 Multi-trip metaheuristics are slow above ~2000 nodes (no O(1) delta exists; full evaluation is 0.6–2.8 µs at n = 194–980). Documented ceiling; do not add Python-level shortcuts. If a user needs more, the answer is 2.1 (segment-based split deltas), not a hack.
- R5 Cython compile surprises.
cpdefmethods oncdef classwithnoexcept nogiland pointer arguments incpdefsignatures do not compile; the contract uses free functions and memoryviews only — do not introduce classes into.pxd.cdef inlinefunctions must be defined (with their bodies) in the.pxd: a body-less inline declaration compiles in_routingitself but breaks every cimporting module in the C compiler ('inline' can only appear on functions) — there is no "it still links" path; only non-inlinecdef/cpdeffunctions are shared through the module C-API. Verified with Cython 3.3.0: the bodies-in-.pxdvariant compiles andcimports cleanly withnoexcept nogiland thecpdef voidsignatures. If a body must change, WP1 changes it in the.pxd; report, do not copy code. - R6 Wheel size (~10.5 MB data, 3–4 MB compressed). Decided for 2.0: the four instances > 20 000 nodes stay bundled (readers and
n_nodes=subsampling keep working offline; their DESCR says they cannot be solved whole in 2.0 — §4.4 SOM, §5.1). If PyPI or users complain, they move to a lazy download cache in 2.1 — keepload_tspas the only entry point so that change is invisible. - R7 Hand-computed example numbers. Two of the four proposals shipped examples that violated their own rules. Every docstring, docs page and README example is a
>>>session collected bypytest --doctest-modules skroute docs README.md --doctest-glob="*.md"indocs.yml(the paths must be listed — pytest collects doctests only under the paths given). Doctest compares the written output on every CI run, so stochastic solvers print only platform-stable facts (route_[0] == route_[-1] == depot_,n_iter_ == len(history_),bool(np.all(trip_times_ <= 8.0)), a gap below the tolerance) or carry# doctest: +SKIP; exact numbers appear only for deterministic solvers (BruteForce/HeldKarp/MILP),optimal_tour_lengthand dataset facts; floats useNUMBER; scalars are converted (int(),float(),.tolist()) so numpy 2'snp.int64(0)repr never appears.docs.ymlruns on ubuntu while authors paste from macOS: a float pinned from one machine is exactly the cross-platform pin D19 forbids (libmexpdiffers by ulps and flips Metropolis decisions). The example 3 arithmetic in §3.4 is a worked check for exactly this reason. - R8
history_semantics drift. The base class asserts monotonicity only in tests (check_router10); if a solver's natural trace is not monotone (SOM), record best-so-far per epoch, not current — as §4.4 now specifies — and say so in the docstring. - R9 Threads and Python-heavy solvers.
MultiStart(prefer="threads")gives near-linear speed-up for SA/ILS/Tabu (nogil kernels) and little for GA/SOM; the docs say so andprefer="processes"is one keyword away. Do not change the default per solver. - R10 Label dtype edge cases. Mixed-type labels (ints and strings) become object arrays;
np.concatenateof a 1-element label slice keeps the dtype (that is why_set_resultssliceslab[d:d+1]instead of wrapping the scalar). If a label type breaksnp.array_equal, coerce incoerce_labels, never in a solver. - R11 Optimal split and BruteForce exactness. Under
split="greedy", BruteForce is exact over greedy-decoded giant tours (a partition that closes a trip while the next node still fits is unrepresentable); undersplit="optimal"it is exact for the distance-constrained multi-trip problem. The user guide states this in one paragraph; tests check both. - R12 Scope creep. Christofides with a greedy matching, MTZ "because it is simpler", a pure-Python fallback,
prange, free-threaded wheels, on-the-fly distances: all are explicitly out of 2.0. Propose them for 2.1 in an issue; do not open a PR. P1 items (D26) are the only ones with a ship-or-defer switch.
10. Disposition of the 34 open GitHub issues (lead; release checklist: comment and close each one)¶
| Issues | Disposition |
|---|---|
| #1–#6, #8–#13, #16, #21 (documentation spelling/unification, datasets/preprocessing docs) | closed by 2.0: every page is rewritten (§7); mkdocstrings + check_api_coverage.py |
| #7 (file names to the Python convention) | closed by 2.0: layout of §2 (_private.py modules, public __all__) |
| #14, #15, #18 (cluster docs/tests) | closed won't-fix: skroute.cluster is removed (§4.6 table); use scikit-learn directly |
| #19, #20 (tests for datasets/preprocessing) | closed by 2.0: tests/test_datasets.py, tests/test_preprocessing.py (WP7) |
| #23 (euclidean distance) | closed by 2.0: distance_matrix(metric="euclidean") (§5.2) |
| #24 (Manhattan distance) | trivially added in 2.0: metric="manhattan" in distance_matrix plus TSPLIB MAN_2D (~5 lines, WP7) |
| #25 (cosine similarity) | closed won't-fix: not a routing metric; a user-supplied matrix is accepted by every solver |
| #26 (SOM distance in C++/Cython) | closed won't-fix: vectorised numpy is enough at the ≤ 20 000-node ceiling (§4.4) |
#27–#28, #30–#32 (distance_mode per solver) |
closed by 2.0: superseded by matrix input (numpy/DataFrame/dict) + distance_matrix(metric=) |
| #29 (Tabu speed/features) | closed by 2.0: TabuSearch rewritten in Cython (§4.4) |
| #33 (four tutorial notebooks) | deferred to 2.1: the user guide pages replace them for 2.0; an examples gallery is a 2.1 item |
| #34 (conda package) | post-release task (lead): conda-forge feedstock after the PyPI release; installation.md says "no conda package yet" |
| #37 (GA top-X % population kernel) | deferred to 2.1 as Genetic(selection="truncation"); noted in docs/api/genetic.md |
#38 (unify swap_route/mutate) |
closed by 2.0: one shared move set in _routing.pxd (§3.5) |
Amendments during implementation (lead)¶
- 2026-09-03 GEO: the degree part of a DDD.MM coordinate is
int(x)(truncation), notnint(x); only truncation reproduces the published ulysses16 optimum 6859 (WP7 verified both variants). - 2026-09-03 Core: the O(1) descents accept a move when
delta < -1e-9 * max(1, cost of the removed edges)(a local scale — the current tour cost is not available inside the nogil kernel);local_search_genericuses the §4.0 test on the full cost.problem_cost_py/trip_startsrelease the GIL around the kernel call.trip_startsraisesValueErrorif the optimal split has no feasible partition (unreachable throughRoutingProblemthanks to D5). The module also exposes*_pywrappers of every inline primitive (validated positions,ValueErroron misuse) so the hypothesis suite can call them; solver.pyxfiles stillcimportthe inline bodies. - 2026-09-03 Datasets:
read_tsplibreturnscoords,cost(one of themNone) andlabels; the Spanish cost tables'hourscolumn equals(secs + 420) / 3600off-diagonal (a fixed 7-minute stop per leg), sotimeis notdistance / speed— documented in the DESCR.GEO,ATTandGoogleDistanceMatrix(P1) shipped in wave A. -
2026-09-03 mypy runs with
python_version = "3.12"because numpy >= 2.4 stubs use PEP 695typestatements; the code supports 3.11. -
2026-09-03 Review round 1 (foundation). Spine:
RoutingProblem.__repr__prints the plain depot label (.item());neighbours()breaks ties at the k-th distance by node index (block-wise partition with tie detection);RoutingProblemcopies its matrices (superseded in review round 5: a float64 C-contiguous input is kept as a view, see §3.3 "Aliasing") (immutable, never aliases the caller's arrays) and validatestour/startsbuffers before handing them to the boundscheck-free kernels; the sub-modulesexceptions,metrics,utils,datasets,preprocessing,base,problemare reachable after a bareimport skroute;check_routerenforces thestop_reason_subsets by class name (_ALLOWED_STOP_REASONS) and usescapfd; check 11 escalates to n = 80 asymmetric and n = 150 instances before declaring two seeds indistinguishable (IteratedLocalSearch and AntColony reach the same optimum with every seed at n <= 40). Core: the greedy decoder never reads the diagonal (the oracle mirrors it), depot-only tours are rejected,or_opt_descentprices and resets from the real anchor and scans from both segment ends and from the anchor,problem_cost_py/trip_startsvalidate and allocate with the GIL held and release it around the kernel. Data:optimal_tour_lengthof kz9976 is 1061881 (proven) and of ch71009 4566506 (best known; bm33708 and ch71009 are presented as open problems); the 27 country wrappers accept a positionalmodefor 1.0 compatibility;pairs_to_matrixrejects conflicting duplicate pairs and keeps mixed-type labels throughcoerce_labels; the TSPLIB reader tolerates keyword lines inside a section and a UTF-8 BOM. -
2026-09-03 Wave B (solvers), recorded deviations: kernels of HeldKarp and Insertion are
_hk.pyxand_insert.pyx(a.py/.pyxpair of the same module name lets the.soshadow the.py); BruteForce prices the reversed orientation when the kept one is within 1e-9 of the incumbent so ties equalitertools.permutationsbit for bit;MILP.lower_bound_starts from a trivial valid assignment bound, is clipped tocost_, usesmip_dual_boundwhenmip_rel_gap > 0, acceptstime_limit=None, and its ATSP time-out fallback polishes withlocal_search_generic;Insertion(strategy="cheapest")is O(n^3) worst case; NRBS divides bymax(C[i, j], 1e-12)^fwithithe node; TwoOpt TINY tolerance is 0.11; convergence of the descents requires a zero-gain full sweep with every don't-look bit active;IteratedLocalSearch.n_iter_counts kick iterations only,temperature=Noneis 0.5 % of the init tour cost,local_search=Noneis allowed (kicks only); the deterministic descents rejectinit="random"at validation; TabuSearch's symmetric path also evaluates no-reversal Or-opt relocations and excludes the full-reversal 2-opt move, applies the best move overall when every move is tabu; SAmovesaccepts a list/tuple/str; the fitter tournament winner is Genetic's parent 1;improvement(new, inf)accepts the first solution; AntColony floors zero off-diagonal costs and uses its ownn_candidatesfor the polish; extra attributesGenetic.n_duplicates_,AntColony.pheromone_,SOM.n_samples_; SOM initialises weights uniformly inside the bounding box; MultiStart copies the inner best estimator'sstop_reason_(check 10 is enforced by class name, so wrappers of solvers withpatienceare not battery-checked beyond MultiStart(SimulatedAnnealing())); shim warnings for two-name legacy packages name both new homes; the MILP slow-tier test and benchmark usetime_limit=150. -
2026-09-03 Review round 2 (solvers). MILP normalises the objective so HiGHS's absolute feasibility tolerance (1e-6) cannot certify a suboptimal tour, cross-checks
lower_bound_ >= cost_ - 1e-9*max(1, |cost_|)before settingis_optimal_, and on time-out returns the better of HiGHS's incumbent and the NN + 2-opt fallback; the exact kernels validate their buffers.Insertion(strategy="cheapest")honours the documented first-edge tie rule; ClarkeWright checks a merge on the trip oriented as it will be emitted (endpoint nearer to the depot first), summed like the greedy decoder, and may emit the reverse orientation when only that one fits; the NRBS "greedy endpoint join" branch is unreachable with the union-find rule and was removed from the text; NRBS exponents must be >= 0. IteratedLocalSearch:temperature=Noneis 0.5 % of |init cost| (1.0 when zero) and the Metropolis rule accepts non-positive deltas outright. TabuSearch: tabu marks areit + tenure + 1(a fixed tenure is clamped tomin(tenure, n_iter, 2**31 - 1)), and on an asymmetric matrix a 2-opt reversal marks all reversed arcs and is tabu if any added arc is tabu; SimulatedAnnealing rejects a non-finitet0. AntColony floors the three1/(rho*L)divisors like the heuristic weights (zero-cost tours keep a finite trail); Genetic builds candidate lists lazily (only for the memetic polish); the memetic descents shareskroute/metaheuristics/_memetic.pxd. SOM floors the Gaussian width at 1e-3 so the update never underflows. Behaviour changes for a given seed relative to wave B: TabuSearch (tenure), Insertion[cheapest] on exact ties, ClarkeWright under asymmetric time matrices. - 2026-09-04 D31 round (traces), recorded deviations from the D30/D31 text: ClarkeWright emits one step event before the first merge (every customer its own out-and-back trip, so a viewer sees the initial state) and one per accepted merge —
merges + 1events, notmerges; the step events also carry the solver-specific keysn_trips(ClarkeWright) andn_edges(NRBS); AntColony's trail cap ismin(3n, n(n-1)/2)on a symmetric matrix andmin(3n, n(n-1))on an asymmetric one (every arc competes); MILP emitsedge_weights(the support's variable values, clipped to[0, 1]) alongsideedges; SOM'sringappears oniterationevents only (starthas no decoded ring yet). The four construction solvers emitstartexplicitly and read the answer before their first step, so aTruereturned atstartsilences every step event (an iterative solver still runs one iteration first); check 14 gained its non-iterative half — after aTruea non-iterative solver emits at most one moreiterationevent beforeend, and a construction solver's result is unchanged — and §6 gained the numbered item 14 with the D30/D31 wording.
Amendments after critique¶
One line per issue id of the three adversarial reviews (2026-09-03). "fixed" = applied as proposed or with the variant stated; "rejected" = kept the original decision, with the reason.
- ambiguity-01 / feasibility-01 — fixed:
cdef inlineprimitives are defined with their bodies in_routing.pxd; non-inline functions declared there and defined in.pyx; frozen = signatures + semantics (D11, §3.5 header, R5, WP1 row). - ambiguity-02 / feasibility-02 — fixed:
_SPLITusesint(core.SplitRule.SPLIT_*); §3.5 documents the IntEnum exposure and the.pyiclass SplitRule(IntEnum). - ambiguity-03 / feasibility-19 — fixed:
neighbours()partitions a copy with the diagonal set toinf, stable ties by index;test_base.pycase with 4 coincident points and a non-zero diagonal. - ambiguity-04 / user-value-02 / feasibility-16 — fixed:
all_solvers()defined (D27, §3.4): sorted by__name__, no-argument-constructible classes incl. the two Ensemble wrappers, neverMultiStart(tested intest_ensemble.py);check_routertakes an unfitted instance. Theinclude_ensembles/include_aliasesflags were not adopted: aliases no longer exist and the wrappers are ordinary solvers. - ambiguity-05 / feasibility-17 / user-value-01 — fixed: alicante fixture carries
fit_kw(labels + label depot) andref_kw(depot=0, index); every loader-based example passeslabels=; §5.1 states loader matrices carry no labels.RoutingProblem.from_bunchrejected: one keyword suffices and it would be a second entry point to document and test. - ambiguity-06 / feasibility-05 / user-value-09 — fixed (feasibility-05 variant):
tests/is not a package,pythonpath = ["tests"],import reference; cibuildwheeltest-command = "pytest {project}/tests -x -q"+test-environment = { SKROUTE_EXPECT_WHEEL = "1" };test_base.pyinstalled-copy guard; sdist job assertssite-packages; neverpython -m pytest(D16). cibuildwheeltest-sourcesnot used (the pythonpath route needs no copy). - ambiguity-07 / feasibility-23 — fixed: check 10 runs the
time_limithalf only when the parameter exists; the allowedstop_reason_subsets are listed per solver (§3.4 table, §4.3, §4.4).time_limitwas not added to the deterministic descents or SOM (they would then need a clock in a deterministic path). - ambiguity-08 — fixed: SOM outer iteration = epoch of
max(1, n_iter // 100)samples,history_best-so-far per epoch,n_samples_,stop_reason_in{"converged", "max_iter"}; the "documented exception" sentence is gone. - ambiguity-09 / feasibility-14 — fixed: check 8 compares both decoders on the same fitted tour;
fit(optimal) <= fit(greedy)asserted only for BruteForce intest_exact.py. - ambiguity-10 / user-value-23 — fixed:
RouterTags.kind(D28); tolerances moved out ofcheck_routerintotests/test_common.py, driven bytests/tolerances.py(single source, keyed by class name,KeyErrorfor a missing class). - ambiguity-11 / feasibility-18 / user-value-04 — fixed by removing
CheapestInsertion/FarthestInsertion(D18):Insertion(strategy=...)is the only spelling; roster count restated as 15 (+AntColony; 14 without HeldKarp) + MultiStart + 2 wrappers. - ambiguity-12 — fixed:
local_searchisNoneor a tuple ⊆ {"two_opt", "or_opt"}, a string normalised to a 1-tuple,"both"deleted; defaults ILS("two_opt","or_opt"), GeneticNone, AntColony("two_opt",). - ambiguity-13 — fixed: position domains per move (2-opt/swap
1 <= i < j <= n-1; Or-opt1 <= i,i+L-1 <= n-1,0 <= j <= n-1,j ∉ [i-1, i+L-1]), all ranges inclusive exceptdouble_bridge;reverse_segment*andswap_deltaannotated. - ambiguity-14 / feasibility-25 — fixed: kick positions drawn without replacement (
rng.choice(..., replace=False)); forn < 8the kick is a random segment reversal (n = 3 → swap (1, 2)); new check 13 fits every solver at n = 3 and n = 4, symmetric and asymmetric. - ambiguity-15 — fixed: one outer iteration = one call of each listed descent with
max_passes=1, persistent buffers,history_[k]= cost after iteration k,"converged"/"max_iter"rule;LocalSearch(moves=...)accepts onlytwo_opt/or_opt; ILS's inner descent uses the same accounting. - ambiguity-16 — fixed:
utils/estimator_checks.pyis WP8's (the oneutils/file the lead does not own);check_router(estimator)runs structural checks 1–11 and 13 on self-built instances;check_router.checksexposes(name, fn)pairs fortest_common.py. - ambiguity-17 — fixed: Ensemble wrappers' tags,
_solve(MultiStart withrandom_state=rng,prefer="threads",n_jobs/verbosepropagated), copied attributes and return value spelled out (§4.5). - ambiguity-18 / feasibility-07 / feasibility-08 / user-value-08 — fixed:
docs.ymlrunspytest --doctest-modules skroute docs README.md --doctest-glob="*.md" --ignore=docs/changelog.md --ignore=docs/contributing.md;doctest_optionflags = "NORMALIZE_WHITESPACE ELLIPSIS NUMBER"; §3.4 examples rewritten as>>>sessions printing platform-stable facts, exact numbers only for deterministic solvers; numpy 2 scalar reprs avoided viaint()/float()/.tolist(); R7 rewritten. - ambiguity-19 — fixed:
route_cost(depot=None)meansroute[0], a given depot must equal it;split_trips(route, depot=None)returns closed trips and treats an open tour as one trip;reference.route_cost_from_labelsmirrors it;labels=added toroute_cost. - ambiguity-20 — fixed at the time (reference switch in
_core/__init__.py), then SUPERSEDED by D29: the core is built before any solver work starts, so there is noSKROUTE_REFERENCEswitch and no_core/_reference.py. - ambiguity-21 / feasibility-09 — fixed:
lower_bound_= largest valid bound seen (status-0 relaxation objectives, non-Nonemip_dual_bound),res.funof a time-limited solve never used,gap_clipped at 0; time-out fallback iscore.nearest_neighbour_tour+core.two_opt_descent(WP2 depends on M2 for that path only), never another WP's solver. - ambiguity-22 — fixed: the SA draw → move mapping and the "invalid draw = rejected proposal that counts towards
n_moves" rule (§4.4). - ambiguity-23 — fixed:
coerce_labels(seq, n)defined (int64 for int-like non-bool labels, object otherwise,nunique) and used by the dict and DataFrame paths ofcoerce_matrix; label dtype is always int64 or object. - ambiguity-24 — fixed:
max_time_workmust be finite and > 0;extra_cost/people/splitwithoutmax_time_workraiseValueError("extra_cost, people and split have no effect without max_time_work")(D3 spirit);fit()'s RoutingProblem passthrough check already covered these. - ambiguity-25 — fixed:
tenure="auto"=rng.integers(ceil(sqrt(n)), 2*ceil(sqrt(n)) + 1, size=n_iter)(inclusive both ends), int = fixed tenure withInterval(Integral, 1, None, closed="left"); generic path evaluates 2-opt and Or-opt L = 1, 2, 3 per candidate pair. - ambiguity-26 — fixed: descents return
cost_after - cost_before <= 0; callers add it. - ambiguity-27 — fixed:
trip_startsandproblem_cost_pyhold the GIL, malloc/free their scratch, may raiseMemoryError, and are the only non-noexcept nogilfunctions (§3.5 header and listing). - ambiguity-28 — fixed:
tiny_instanceeverywhere; check 11 rewritten (see feasibility-15) with the newmedium_euclidean(n = 40) fixture. - ambiguity-29 — fixed: a merged Clarke–Wright trip keeps the smaller creation index, trips concatenated by increasing index and oriented by the endpoint nearer the depot;
shapeadded to the glossary. - ambiguity-30 — fixed: all slow-tier gap tests live in
tests/benchmarks/test_waterloo.py(WP8), stated in §4.0, §6 and WP4's definition of done. - ambiguity-31 — fixed:
TSPBunch(Bunch)with a realdistance_matrix()method cached in_distance_matrix;keys()lists data only. - ambiguity-32 / feasibility-22 — fixed:
[tool.setuptools.package-data]list inpyproject.toml(py.typed, .pxd, .pyi, .tsp, .csv,_descr/*.md); MANIFEST.in includes*.mdandpy.typed. - ambiguity-33 — fixed: "the five exponents default to the value 1.0 (1.0.0a2 had no defaults)";
distance_weigth → distance_weightin §4.2, §4.6 and the CHANGELOG. - ambiguity-34 — fixed:
n_simulatedsdefault change 20 → 10 stated; "Defaults that changed" table added to §4.6 and migration item (6). - ambiguity-35 — fixed: check 4 uses
pytest.approx(..., rel=1e-12). - ambiguity-36 / user-value-03 — fixed:
_euclidreturns(C, coords); every instance dict carriescoords;fit_kwargs(Solver, inst)passes them forrequires_coordsandpytest.skips on asymmetric instances;check_routerbuilds its instances with coordinates. - feasibility-03 — fixed: SA
patience=Noneby default (measured first-improvement levels 413–1023 quoted), patience counted only after the current cost first falls below the initial cost;EnsembleSimulatedAnnealingdefault updated; glossary updated. - feasibility-04 — fixed: the optimal-split loop breaks on the monotone OPEN path, tests closed feasibility per
i;reference.optimal_splituses the same rule. - feasibility-06 — fixed:
test-commandwithout the-m 'not slow'clause (addopts deselects slow;cmd.exequoting) in D16 andwheels.yml. - feasibility-10 — fixed: BruteForce enumerates lexicographically with
next_permutation; halving keepstour[1] < tour[n-1]; ties matchitertools.permutations. - feasibility-11 — fixed: NearestNeighbour fast tier ≤ 0.50; measured baseline recorded (§4.2, tolerance table,
benchmarks.md). - feasibility-12 — fixed: Genetic plain ≤ 0.15 fast / ≤ 0.30 qa194 only; memetic
("two_opt",)≤ 0.05 / ≤ 0.08 (qa194, lu980,@slow, minutes);patiencedefault 100 for Genetic and EnsembleGenetic. - feasibility-13 — fixed: TwoOpt/OrOpt ≤ 0.20 / 0.25 on fast and slow tiers;
n_candidates=None= full neighbourhood added as an option (default stays 10). - feasibility-15 — fixed: check 11 asserts different
history_/n_iter_onsmall_euclideanor differenttour_onmedium_euclidean(n = 40), andbit_generator.stateadvancement; "statistical" removed. - feasibility-20 — fixed:
RoutingProblem._as_indexcoercestour/startswithnp.ascontiguousarray(..., dtype=np.int64)inevaluate,trip_starts,trip_costs,trip_times. - feasibility-21 — fixed: default
manylinux_2_28images; manylinux2014 requirement dropped. - feasibility-24 — fixed:
int max_segmentadded tolocal_search_generic(aftermoves); generic path documented as first-improvement only;LocalSearchgainsmax_segment=3. - feasibility-26 — fixed: GEO and ATT formulas spelled out (truncation + 1 for GEO); regression tests ulysses16 = 6859 and att48 = 10628 via bundled
.opt.tourfiles (active when the P1 metrics ship). - feasibility-27 — fixed:
tabu_untilisint32; practical ceiling ~5 000 nodes documented. The hash-table variant was not adopted (out of 2.0 scope; the ceiling is documented instead). - feasibility-28 — fixed: NRBS connection score divides by
max(C[i,j], 1e-12), zero-distance pairs connected first. - feasibility-29 — fixed: slow-tier SA ≤ 0.10, ILS ≤ 0.06, LocalSearch ≤ 0.15, and the measured baselines recorded in
tolerances.MEASURED/benchmarks.md. - feasibility-30 — fixed:
BaseRouter.__eq__compares parameter by parameter withnp.array_equalfor ndarrays (_param_equal). - feasibility-31 — fixed:
if TYPE_CHECKING:eager imports of every public name inskroute/__init__.py;all_solvers()imports eagerly from the subpackage__all__lists. - user-value-05 — fixed: "Every public name of 1.0.0a2" table and "Defaults that changed" table in §4.6, reproduced verbatim in
docs/migration.md(items 6 and 7); CHANGELOG Removed/Changed lines updated (cluster,df_to_tuple,matrix_parse,DataLossWarning, datasets return shape, defaults). - user-value-06 — fixed:
CostScraperis a thin deprecated wrapper reproducing the 1.0 constructor,scrap()andpandas();to_pickle()raises with a message; goes withGoogleDistanceMatrixif that is deferred. - user-value-07 — fixed: the dense-matrix ceiling (~20 000 nodes) is stated in §4.4 SOM, §5.1 (DESCR of the four large instances with the
n_nodes=5000hint), migration item (9) andproblem_model.md; coordinate-only fitting deferred to 2.1 (D18); R6 decided: the four instances stay bundled. - user-value-10 — fixed: §10 dispositions all 34 open issues (closed-by-2.0, trivially added #24 →
manhattan/MAN_2D, won't-fix #25/#26, deferred #33/#37, post-release #34); release checklist item. - user-value-11 — fixed:
NullHandleron theskroutelogger,skroute.set_log_level(), mandatory closing sentence of everyverbosedocstring, shown once ingetting_started.md(D24). - user-value-12 — fixed in part: D26 adds the P1 ship-or-defer rule with a feature-freeze date (2026-10-16), RC (2026-10-23) and release (2026-10-30) to HeldKarp,
GEO/ATTinread_tsplib, andGoogleDistanceMatrix; nav/CHANGELOG/tolerance rows are conditional. Rejected forEXPLICIT/CEIL_2D(needed by the reader round-trip test and trivial) and forSPLIT_OPTIMALinsidelocal_search_generic(D1 promises the optimal split to every budget-aware solver; making the hottest kernel greedy-only would break that promise for LocalSearch/ILS/Tabu undersplit="optimal"). - user-value-13 — fixed: the deferral procedure (files, nav, CHANGELOG, tolerances, exports, one cross-ownership PR) is written in §4.4 and applies to every P1 item.
- user-value-14 — fixed:
CITATION.cff[L] in §2 and the L row; README cites via it;codecov/codecov-action@v4withCODECOV_TOKEN(badge dropped if the secret is absent). - user-value-15 — fixed: SECURITY.md, CODE_OF_CONDUCT.md contact, issue-form fields,
about.md,installation.md,index.mdcontents specified in §7. - user-value-16 — fixed:
api/base.mddirectives listed,api/utils.mdadded to the nav,docs/check_api_coverage.pyindocs.yml. - user-value-17 — fixed:
docs/gen_pages.py(mkdocs-gen-files) writes the capability table; README carries it between markers refreshed by a pre-commit hook;mkdocs-literate-navremoved from the docs extra. - user-value-18 — fixed:
docs/benchmarks.mdis committed on the RC with a provenance header; the nightly job opens a PR when a gap moves by > 0.5 pp. - user-value-19 — fixed:
time_matrixis keyword-only infitandRoutingProblem(D7); every example, fixture and test updated; migration item (2) opens with the boxed warning. - user-value-20 — fixed: exact shim contents (1.0
__all__per package,SimmulatedAnnealingtypo noted), warning text withstacklevel=2, and whattest_legacy_shims.pyasserts (§4.6). - user-value-21 — fixed: example 1 passes
labels=wi.labelsand shows the depot as id 1;fast_instance/slow_instancecarrylabels. - user-value-22 — fixed: the NRBS pin procedure (worktree at
533f320, Python ≤ 3.11, float64tour_costsubstitution,tests/data/nrbs_barcelona_1_0.jsonwith provenance, sequence and cost pinned) replaces "the 1.0 checkout". - user-value-24 — fixed in part:
budget_awaredefaults toFalseand the opt-in list is written next toRouterTags(D28). The proposed smoke check ("budget-awarecost_≤ budget-unaware decode of its plain tour") is rejected: for a stochastic heuristic the two fits are different searches and the inequality is not guaranteed — it would be flaky by construction, exactly what checks 8 and 11 were just cured of. - user-value-25 — fixed: one statement of the top-level surface in §3.4 ("Top-level surface"); §4.6 points to it.
- 2026-09-05 maps (review r5): keyword-only extras beyond the D33 signatures —
travel_time_matrix(pause=1.0, timeout=60),geocode(base_url=None),fetch_pois(user_agent=None); every provider validatestimeout/pause/user_agent(base_url,user_agentandpauseare OSRM-only,timeoutis forwarded togooglemaps.Client; one point issues no request); a diagonal block of one point is never requested (OSRM refuses a one-coordinate table), so n=3/chunk_size=2 is 3 requests and chunk_size=1 gives 2·C(n,2); a missing OSRMdistancestable warns once (RuntimeWarning);fetch_poisreturnslabelsas an object ndarray (names/addresses/tags stay lists), rounds[timeout:]up to a whole second and gives the socket that plus 10 s; the Nominatim throttle runs before every attempt; body-read failures are retried likeURLError;MapServiceErroralways carries the redacted URL and the real HTTP status;GoogleDistanceMatrix(timeout=None)is sent to the client only when given. - 2026-09-05 export (review r5): a plain route visiting a node twice raises
ValueErrorin the three exports;names=/trip_names=given as onestrraiseTypeError; the JavaScript page queues Directions requests two at a time with one retry onOVER_QUERY_LIMIT; KMLStylelistsIconStylebeforeLineStyle; the legacygoogle.maps.Markeris kept and its deprecation documented.