> ## Documentation Index
> Fetch the complete documentation index at: https://docs.solvice.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Distance Matrix Integration

> How the VRP solver fetches, caches, and consumes travel-time and distance data from Solvice Maps

# 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](https://maps.solvice.io). There is no straight-line/Euclidean fallback mode in the current API: if the matrix fetch fails, the request fails.

<Info>
  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.
</Info>

## Why a Real Road Network, Not Straight-Line Distance

```mermaid theme={null}
flowchart LR
    subgraph "Request"
        A["Vehicle start/end coordinates"]
        B["Job locations"]
        C["Shipment pickup/delivery locations"]
    end

    subgraph "Solvice Maps"
        D["POST /table/sync or /table"]
        E["Real road network"]
        F["TomTom traffic-aware durations"]
    end

    subgraph "VRP Solver"
        G["Problem.dist() / Problem.time()"]
        H["Constraint checks (TimeWindow, VehicleRange)"]
        I["Objective cost (per_travel_km, per_travel_hour)"]
    end

    A --> D
    B --> D
    C --> D
    D --> G
    E --> D
    F --> D
    G --> H
    G --> I

    style A fill:#e1f5fe
    style B fill:#e1f5fe
    style C fill:#e1f5fe
    style D fill:#fff3e0
    style E fill:#fff3e0
    style F fill:#fff3e0
    style G fill:#e8f5e9
    style H fill:#e8f5e9
    style I fill:#e8f5e9
```

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:

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

<Note>
  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`.
</Note>

### 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:

```mermaid theme={null}
flowchart TD
    N["N unique coordinates"] --> Q{"N <= 150?"}
    Q -->|Yes| SYNC["POST /table/sync<br/>single round trip"]
    Q -->|No| ASYNC["Async 3-step path"]

    ASYNC --> A1["POST /table<br/>submit job, get ID"]
    A1 --> A2["GET /table/{id}<br/>poll every 500ms"]
    A2 --> A3["GET /table/{id}/response<br/>fetch completed matrix"]

    SYNC --> R["MatrixTable { durations, distances, snapped_sources }"]
    A3 --> R

    style SYNC fill:#e8f5e9
    style ASYNC fill:#fff3e0
    style R fill:#50c878,color:#000
```

* **`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`.

<Warning>
  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.
</Warning>

### 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:

```mermaid theme={null}
flowchart TD
    REQ["N coordinates for this solve"] --> GATE{"N > 200?"}
    GATE -->|Yes| BYPASS["Bypass cache entirely<br/>full fetch via /table/sync or /table"]
    GATE -->|No| PROBE["Probe cache for every (i,j) pair"]

    PROBE --> FULL{"All pairs cached?"}
    FULL -->|Yes| HIT["full_hit — return from cache,<br/>no network call"]
    FULL -->|No| NEW{"How many <b>row sources</b>?<br/>(fully-new coords, plus cached<br/>coords with an orphan gap)"}

    NEW -->|"1 to 5, and fewer than N"| PARTIAL["partial_hit — fetch only the<br/>missing row(s) + column(s) via<br/>POST /table/sync sources/destinations"]
    NEW -->|"0 row sources, or too many"| MISS["miss — full fetch,<br/>then populate the cache"]

    style HIT fill:#e8f5e9
    style PARTIAL fill:#fff3e0
    style MISS fill:#ffebee
    style BYPASS fill:#ffebee
```

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:

| Constant                  | Value          | What it controls                                                                                                                                                                                                                                                                                                                                                                                         |
| ------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CACHE_MAX_COORDS`        | 200            | Above this coordinate count, the cache is bypassed entirely — probe cost is `O(N²)` and approaches full-fetch latency at scale                                                                                                                                                                                                                                                                           |
| `PARTIAL_FETCH_THRESHOLD` | 5              | Maximum number of **row sources** for the partial-fetch path; beyond this, fall through to a full fetch. Row sources are the fully-new coordinates *plus* any already-cached coordinate with an "orphan gap" — a missing pair to another cached coordinate. A request where every coordinate is cached but some pairs between them are not has **zero** fully-new coordinates and is still a partial hit |
| `CACHE_MAX_ENTRIES`       | 5,000,000      | Hard cap on cached directed pairs, evicted by moka's W-TinyLFU policy                                                                                                                                                                                                                                                                                                                                    |
| `CACHE_TTL_SECS`          | 3,600 (1 hour) | A cached edge expires after an hour so traffic-aware durations don't go stale as the day progresses                                                                                                                                                                                                                                                                                                      |

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`.

<Note>
  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.
</Note>

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.

## 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.

```json theme={null}
{
  "options": {
    "runtime": { "traffic": { "departure_time": "2026-07-01T08:00:00+02:00" } }
  }
}
```

**`vehicles[].profile`** selects which routing profile the matrix is fetched under (`"car"`, `"truck"`, …). Vehicles with different profiles get different matrices, fetched independently.

<Note>
  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.
</Note>

<Info>
  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`.
</Info>

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:

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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`.
  </Accordion>
</AccordionGroup>

## 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

<Check>
  **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.
</Check>

## Related Topics

<CardGroup cols={2}>
  <Card title="Constraint System" icon="cog" href="/guides/vrp/v3/concepts/constraint-system">
    How `TimeWindow` and `VehicleRange` consume the fetched matrix
  </Card>

  <Card title="Performance Guide" icon="gauge" href="/guides/vrp/v3/concepts/performance-guide">
    How problem size and matrix fetch time interact with the solver's time budget
  </Card>

  <Card title="Objective Function" icon="star" href="/guides/vrp/v3/concepts/scoring-explanation">
    How `per_travel_km` and `per_travel_hour` turn matrix distances into cost
  </Card>
</CardGroup>
