Skip to main content

Distance Matrix Integration

Every POST /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 a TimeWindow 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 is intern_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() <= 150 uses POST /table/sync — one round trip, no polling. Measured against routing.solvice.io/table/sync, steady-state latency runs roughly 250 ms at N=20 up to roughly 440 ms at N=150.
  • coords.len() > 150 uses the async job path: POST /table to submit, GET /table/{id} polled every 500 ms until "SUCCEEDED" (or a 120-second timeout), then GET /table/{id}/response to fetch the finished matrix.
Both paths request 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.
The departureTime value must be millisecond precision with a literal Z suffix (2026-06-16T11:52:10.123Z), not chrono’s default RFC 3339 output (which emits nanosecond precision and a numeric +00:00 offset). That exact formatting bug previously caused Solvice Maps to reject every traffic-aware request with HTTP 400, tripping the circuit breaker and returning 503s to every caller. This is now covered by a dedicated regression test (departure_time_now_uses_api_accepted_format, src/api/solvice_maps/wire/tests.rs) — it’s mentioned here only because it illustrates how tightly the traffic-aware fetch is coupled to exact wire formatting.

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.
A full cache hit skips the network entirely. A partial hit still costs one round trip (two /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. 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.
Two vehicle-level fields genuinely affect timing after the matrix is fetched, independent of the fetch itself. 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 fetched distances/durations matrix becomes Problem.dist() / Problem.time() internally, which every constraint and every cost term reads from:
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.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.
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 in src/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: a full_hit skips the network call outright, and a partial_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.
If your workload is a one-off, large, cold solve rather than a repeated dispatch re-solve, matrix fetch time will scale with problem size and the cache will not help. Note that 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:
  1. Put every coordinate inline. There is no shared locations[] table and no depots[] collection — a Place is 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.
  2. Only pin traffic.departure_time when 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.
  3. Use speed_factor / service_factor for per-vehicle driving and per-stop work-rate adjustments — those are the real levers on top of the fetched matrix, not a matrix override.
  4. 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.
  5. For very large or one-off problems (200+ unique coordinates), remember the cache is bypassed entirely — the full fetch cost applies every time.

Constraint System

How TimeWindow and VehicleRange consume the fetched matrix

Performance 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