Distance Matrix Integration
EveryPOST /v3/routing/solve request needs to know the travel time and distance between every pair of locations it references before the solver can evaluate a single move. The solver never computes these itself — it always fetches a real road-network matrix from Solvice Maps (routing.solvice.io), the same service documented at maps.solvice.io. There is no straight-line/Euclidean fallback mode in the current API: if the matrix fetch fails, the request fails.
This guide describes the actual Solvice Maps integration shipped in this solver: how locations are extracted and ordered, which HTTP path is used at which problem size, how the in-process cache absorbs repeated dispatch-style re-solves, and which matrix-related request fields exist in the schema today versus which are rejected as not-yet-supported. It does not describe a generic or hypothetical routing-engine abstraction — every option named below is a real field or a real constant in this codebase.
Why a Real Road Network, Not Straight-Line Distance
A matrix built from real road geometry is what makes aTimeWindow check, a VehicleRange limit, or a per_travel_hour cost rate meaningful — every one of those consumes the same distances/durations matrix that Solvice Maps returns. The fetch runs between request validation and problem construction, before the solver ever touches the problem.
How It Works
1. Locations Are Extracted and Deduplicated
Before any network call, the solver walks the request exactly once to build the ordered, deduplicated list of coordinates that will become the matrix’s rows and columns. That traversal isintern_all_coords_v3 in src/api/convert/coords.rs, run from validate_and_intern_v3; its output is both what the matrix is fetched for and what the conversion indexes into, so the two can’t disagree:
1
Coordinate extraction, in a fixed traversal order
For each vehicle, for each shift: the shift’s start, then its end, then any multi-trip reload-depot coordinates, then any windowed break locations — all before moving to the next shift. Then every job’s
location. Then every shipment’s pickup.location and delivery.location, in request-array order.Only a floating break in its windowed shape (a windows entry plus an explicit location) adds a matrix row. Trigger-based floating breaks and fixed breaks carry no routable location at all, and are rejected upstream if they try to.2
Deduplication to microdegree precision
Each coordinate is rounded to 6 decimal places (~0.1 m) before lookup. Two locations that round to the same microdegree pair — e.g. two vehicles sharing one depot — collapse to a single matrix row/column, and jobs at the same address share a matrix row while keeping distinct identities in the route.
3
Matrix fetch
The ordered coordinate list is passed to
SolviceMapsClient::table(), which returns an N×N matrix of durations (seconds) and distances (metres), plus a snapped_sources list — each input coordinate’s nearest point on the routable road graph. One matrix is fetched per distinct vehicles[].profile in the request.This is a narrower ordering rule than “all vehicle starts, then all distinct ends, then all jobs” — reload-depot and windowed-break coordinates are interleaved per-shift, and shipment pickup/delivery locations come after jobs. If you’re inspecting a solve response to line up matrix indices with request locations, use this exact per-shift traversal order, not a simplified summary of it. Source:
intern_all_coords_v3, src/api/convert/coords.rs.2. The Solvice Maps Round Trip
The solver never assumes a fixed engine name is available to the caller — the choice of HTTP path is made internally, based purely on how many distinct locations the request has:coords.len() <= 150usesPOST /table/sync— one round trip, no polling. Measured againstrouting.solvice.io/table/sync, steady-state latency runs roughly 250 ms at N=20 up to roughly 440 ms at N=150.coords.len() > 150uses the async job path:POST /tableto submit,GET /table/{id}polled every 500 ms until"SUCCEEDED"(or a 120-second timeout), thenGET /table/{id}/responseto fetch the finished matrix.
TOMTOM as the routing engine with a departureTime, so every fetched matrix is a traffic-aware duration — there is no separate free-flow/traffic toggle. By default that instant is “now”; set options.runtime.traffic.departure_time to plan against another one. Source: TRAFFIC_ENGINE = "TOMTOM" in src/api/solvice_maps/wire.rs, used identically by the sync, partial, and async fetch paths.
Every request also asks for both "duration" and "distance" annotations, so a single fetch always returns both matrices — there’s no way to request only one. Unreachable pairs come back as JSON null and are normalized to an internal unreachable sentinel; when a fetched matrix actually contains one, the solver additionally activates its Reachable constraint so no route is allowed to traverse that leg, and a task only reachable through it comes back unassigned with UNREACHABLE_LOCATION.
3. The Pairwise Cache
A production dispatch workload re-solves the same driver’s route dozens of times a day as new jobs arrive — and 99% of the coordinate pairs are identical between consecutive solves. Rather than re-fetching the full matrix every time, the solver keeps an in-process cache of individual directed edges: Cache keys are directed(from_coord, to_coord) pairs — distances on a road network are not symmetric, so A→B and B→A are cached independently. Coordinates are quantized to microdegree precision (CoordKey, ~11 cm at the equator) before hashing, so the cache absorbs the same floating-point noise that collect_coords() deduplicates against. A second, smaller cache stores each coordinate’s snapped-to-road-graph point separately, so snapped_location in the response survives a cache hit instead of silently coming back empty.
The real constants:
These four live in
src/api/solvice_maps/client.rs; the sync/async cutover constant SYNC_TABLE_MAX_LOCATIONS (150) lives in src/api/solvice_maps/transport.rs.
ADR-0008 (
docs/adr/0008-matrix-cache.md) — the design record for this cache — states a 12-hour TTL (43,200 s). The shipped constant in src/api/solvice_maps/client.rs is 3,600 s (1 hour), with an inline comment explaining the change: once the client started requesting TomTom traffic-aware durations (engine=TOMTOM + departureTime=now), a cached edge has to expire faster than the traffic conditions it was measured under, so the TTL was tightened after the ADR was written. Accepted ADRs are a historical record and are not edited after the fact — the 1-hour figure in the table above is the one that governs actual cache behavior today./table/sync calls issued concurrently — one for new-coord-as-source, one for new-coord-as-destination — assembled into the missing row/column), which is dramatically cheaper than a full N×N fetch once N approaches 100+. Above CACHE_MAX_COORDS, none of this applies and every request pays the full fetch cost described in the previous section.
Matrix-Related Request Fields
Two request fields interact with the matrix fetch, and both are live.options.runtime.traffic.departure_time (RFC 3339, any offset) picks the instant the traffic lookup is made for. Omit it and the matrix reflects traffic at solve time; set it to plan tomorrow’s 08:00 routes with morning-rush travel times, or to replay a past run under its original conditions. The one cost: an explicit departure time bypasses the pairwise cache, so such a request always pays a full fetch.
vehicles[].profile selects which routing profile the matrix is fetched under ("car", "truck", …). Vehicles with different profiles get different matrices, fetched independently.
There is no caller-supplied matrix.
options.runtime.matrix was a Phase-1 stub that never shipped and has been removed from the schema, so sending it is an unknown-field rejection rather than a “not supported” one. Every solve fetches live from Solvice Maps.There is also no shared
locations[] table and no depots[] collection. Both were considered and dropped: plain locations carry no data beyond a coordinate, and co-located tasks already deduplicate correctly on matching inline coordinates, so the indirection had no payoff. Place has exactly one form today — an inline { "coordinate": [lon, lat] } (or the bare [lon, lat] array). Source: the Place enum and its hand-written Deserialize, src/api/v3_types.rs.speed_factor (a multiplier in (0, 5], default 1.0) scales driving: effective travel time is matrix_time / speed_factor, so above 1.0 is a faster vehicle. service_factor does the same for the work done at stops (service_duration_s / service_factor) and can be overridden per shift. The two are independent and share that direction and range; service_factor deliberately does not touch setup_duration_s, depot or reload service time, waiting, or break durations. Source: Vehicle.speed_factor / Vehicle.service_factor, src/api/v3_types.rs.
What the Matrix Feeds Into
The fetcheddistances/durations matrix becomes Problem.dist() / Problem.time() internally, which every constraint and every cost term reads from:
Constraint checks
Constraint checks
TimeWindow uses time() between consecutive stops (via precomputed Savelsbergh forward-slack arrays) to determine whether an insertion lands inside [from, to]. VehicleRange sums dist()/time() along a route to check against vehicle.limits.max_distance_m / max_route_duration_s. Reachable, when active, rejects any leg the provider reported as having no road connection.Objective cost
Objective cost
objective.costs.per_travel_km and per_travel_hour are rate-multiplied against the same distance/duration sums that build every route’s total_distance and total_time. There is no separate “distance model” for the objective versus the constraints — both consume the identical matrix.Response shape
Response shape
Each stop in the solve response reports both its requested
location and, when Solvice Maps could snap it, a snapped_location — the nearest point on the routable graph. See StopOutput.snapped_location, src/api/v3_types.rs.Performance Considerations
There is no published timing table for matrix generation at different problem sizes in this codebase, and this guide will not invent one. What is true, and traceable to the source above:- Below
SYNC_TABLE_MAX_LOCATIONS(150 unique coordinates), the sync path is used and is fast enough (roughly 250-440 ms end to end, per the measurements cited insrc/api/solvice_maps/client.rs) that it is not the dominant cost for typical VRP sizes. - Above 150, the async submit/poll/fetch path adds at least one 500 ms poll interval, and scales with matrix size since the response itself grows as N².
- The pairwise cache only applies below
CACHE_MAX_COORDS(200 coordinates). For dispatch-style workloads — the same fleet re-solved repeatedly as new jobs arrive through the day — this is where the cache does the most work: afull_hitskips the network call outright, and apartial_hit(up to 5 genuinely new coordinates) fetches only the new row and column instead of the whole matrix. - Larger location sets always take longer to fetch than smaller ones — there’s no way around an N² payload — but repeated solves against a largely-unchanged coordinate set are the case this cache is built for, not the first cold solve of a brand-new problem.
options.runtime.time_limit_s is pure search time — matrix fetch and queue wait are bounded separately and are not deducted from it — so a slow matrix fetch shows up as added wall-clock latency on top of your configured time limit, not as a smaller search budget. Source: RuntimeOptions.time_limit_s, src/api/v3_types.rs.
Best Practices
Distance matrix guidelines:
- Put every coordinate inline. There is no shared
locations[]table and nodepots[]collection — aPlaceis a{ "coordinate": [lon, lat] }on the job, shipment leg, shift, reload, or windowed break that uses it. Repeating the same depot coordinate across vehicles costs nothing; it deduplicates to one matrix row. - Only pin
traffic.departure_timewhen you mean it. Planning tomorrow’s morning rush is exactly what it’s for, but it bypasses the pairwise cache, so a dispatch loop that pins “now” on every call pays a full fetch each time instead of a cache hit. - Use
speed_factor/service_factorfor per-vehicle driving and per-stop work-rate adjustments — those are the real levers on top of the fetched matrix, not a matrix override. - Expect repeated solves over a stable coordinate set to get faster, not slower, thanks to the pairwise cache — this is by design for dispatch-style re-solve workloads, not a coincidence.
- For very large or one-off problems (200+ unique coordinates), remember the cache is bypassed entirely — the full fetch cost applies every time.
Related Topics
Constraint System
How
TimeWindow and VehicleRange consume the fetched matrixPerformance Guide
How problem size and matrix fetch time interact with the solver’s time budget
Objective Function
How
per_travel_km and per_travel_hour turn matrix distances into cost