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

# Real-time re-optimization

> Update a running day after a delay, cancellation, no-show, urgent order or breakdown, so the solver repairs the plan you have instead of starting over

> [!NOTE]
>
> **V3 is in beta.** General availability (GA) is planned for January 2027.

## Overview

A plan built in the morning is out of date by the first coffee break. The V3
Routing API keeps no state, so a repair is a new request that carries the
current plan with it: you edit `problem` to describe what changed, send the
routes you want kept as `plan`, and pick the endpoint that fits the size of
the change.

| You want to | Use |
| - | - |
| Let the solver rearrange the rest of the day around a change | `POST /v3/routing/solve` with a `plan` (a warm start) |
| Find the slot for one new task without touching other stops | [`POST /v3/routing/suggest`](https://docs.solvice.io/guides/vrp/v3/suggest) |
| See the new timeline and cost of a plan you edited by hand | [`POST /v3/routing/evaluate`](https://docs.solvice.io/guides/vrp/v3/evaluate) |
| Repair a day of more than 400 tasks, or search longer than 60 seconds | [`POST /v3/routing/runs`](https://docs.solvice.io/guides/vrp/v3/runs), with the same body as solve |

Three facts about `plan` shape every pattern below. The response's `plan` is
the same object a request takes, so it goes back in unchanged. A plan that
names a task, vehicle, shift or depot that is not in `problem` is a `400`
pointing at `/plan/routes/{i}` or `/plan/routes/{i}/stops/{k}`, so every
edit to `problem` has a matching edit to `plan`. And a response's `plan`
never carries `locked_count` or `pinned`: you set them again on every call.

## Disruption, field, call

| Event | What to change in `problem` | What to pass in `plan` | Endpoint |
| - | - | - | - |
| New urgent task | Add the job to `jobs` with its `time_windows`; give it a `priority` above `1` so it outranks other must-serve tasks when not everything fits, or a `drop_cost` if it may be refused | The current routes, with the new task **left out**; `locked_count` on every dispatched prefix | `suggest` when a dispatcher picks the slot and the other stops must not move; `solve` with `plan` when the solver may reshuffle the unlocked stops to fit it |
| Cancelled task or no-show | Remove the job from `jobs`. For a no-show that will be retried later today, keep it and set its `time_windows` to the retry window instead | Remove its stop from `stops`; lower `locked_count` by one if the stop was inside the locked prefix. A retried no-show is left out of `plan` | `evaluate` to see the new timeline only; `solve` with `plan` to use the freed time |
| Driver running late | Delay not yet started: set `shifts[].from` to the real start. Already on the road: remove the stops that are done from `jobs`, set the shift's `start.location` to the vehicle's position and `from` to now. A stop that is now reached after its window closes is moved or dropped (windows are hard), so price lateness with `time_windows[].per_late_hour` or `objective.per_late_hour` to keep it | The remaining stops in their dispatched order, under `locked_count` for the part the driver has already been told | `solve` with `plan` |
| Vehicle breakdown | Remove the vehicle from `vehicles`, and from `jobs` the stops it already completed. To keep the vehicle for its other shifts, add `eligible_vehicles: { "excluded": ["van-3"] }` to the tasks it can no longer serve instead | Remove its route. Its undone tasks are now outside `plan`, so the search places them on the other routes | `solve` with `plan`; `suggest` when a dispatcher reassigns the orphaned tasks one by one |
| Stop completed (freeze the past) | Nothing. Keep the job in `jobs` so the day's timeline stays whole | Keep its stop at the head of the route and raise `locked_count` so it covers every completed stop. A route with a `reload` stop cannot take `locked_count`: remove the completed stops and move `start.location` instead | `solve` with `plan`, or `evaluate` for the timeline |
| Shift extended | Move `shifts[].to` later, or allow `max_overtime_s` priced by `costs.per_overtime_hour` | Unchanged | `solve` with `plan`: tasks that were `unassigned` with category `blocked` or `time_limit` may now fit |

Every row sends the whole `problem`, not a delta: the API has no memory of
the previous request.

## What is hard and what is a hint

A task in a plan can be tied to a vehicle or not, and to a position or not.
The four cases use different fields, and only three are hard rules.

| | Position free | Position fixed |
| - | - | - |
| **Vehicle free** | A plain `plan` stop. A **hint**: the search starts the task where you put it, and moves or drops it when a better plan exists or a hard rule says it cannot stay | Relative order only: a `sequence` or `consecutive` relation in `problem.relations` (hard) fixes the order of its members on whichever route serves them. A `sequence` with one member pins that job as the first stop of its route. There is no field for "position 3 on any vehicle" |
| **Vehicle fixed** | `"pinned": true` on the `plan` stop (solve only, **hard**): the stop stays on that route's vehicle, on any of its shifts unless it belongs to a `same_day` or `same_route` group, and is never removed, but the search may reorder it within the route. In `problem`, `eligible_vehicles: { "allowed": ["van-1"] }` is also hard but weaker: it keeps the task off other vehicles yet lets it go `unassigned`, and `preferences[].violation_cost` is a soft price | `plan.routes[].locked_count` (**hard**): the first `locked_count` stops stay where they are, on that vehicle, in that order. A prefix only: it counts every listed stop from the top, cannot be combined with a `reload` stop on the route, and cannot exceed the route's stop count |

What each means when a hard rule is broken:

* An **unlocked** stop (the hint) that violates capacity, skills, eligibility,
  a limit or a hard time window is taken out of its position, reinserted
  where it fits, or returned in `unassigned[]` with a reason.
* A **locked** or **pinned** stop that violates a hard rule can be neither
  moved nor dropped, so solve returns `400`, item `invalid_value`, pointer
  `/plan`, naming the stop, the vehicle and the rule. On an async run the run
  ends `failed` instead. Send the same body to `evaluate` to see every broken
  rule stop by stop before you decide what to relax.
* `pinned` is accepted and ignored by `evaluate` and `suggest`. `locked_count`
  is inert on `evaluate`; on `suggest`, no insertion lands before the locked
  stops.
* A pinned or locked stop switches off `all_or_none` for its whole group, and
  a pinned stop that shares a `same_vehicle`, `same_day` or `same_route`
  group with another pinned or locked stop the relation forbids is a `400`.

The rule of thumb: `locked_count` for what the driver has done or has been
told, `pinned` for what the customer has been promised a vehicle for, nothing
for everything else.

## What a warm start reuses, and when a solve is cold

A `plan` on solve seeds the search instead of constructing from scratch, so
the budget goes into improving a plan that is already good. Stops move
wherever the search finds a better place, except the ones you lock or pin.
The first `search_progress` entry is your plan's score, so you can read how
much the repair changed it.

Three consequences for a repair:

* **It ends early.** A warm-started solve stops once it has gone 5% of the
  budget, and at least 150 ms, without finding a better plan, and reports
  `summary.search.termination: "no_improvement"`. The repair in the example
  below was sent with `time_limit_s: 1` and returned after 150 ms.
* **The default budget is enough to start with.** Omitted, `time_limit_s` is
  150 ms plus 4 ms per task. Raise it for the morning solve after measuring
  `summary.score` at a few budgets on your own data, up to `60` on solve and
  `3600` on a run; leave the repair at the default or the same budget, and
  let the early stop return it sooner. A value below `0.1` is raised to the
  100 ms floor, so a smaller number buys nothing.
* **Travel data is reused when it can be.** Without `travel.departure_time`,
  a request on the `osm` or `tomtom` engine with at most 200 distinct locations
  can reuse travel data looked up within the last hour, so a repair that
  keeps the same locations skips most of the lookup. `departure_time` fetches
  fresh data on every call.

A solve is **cold** when it has no `plan`, when the plan is rejected with a
`400` (an id that is not in `problem`, a stop listed twice, a locked or
pinned stop that cannot be driven), and, in effect, when so many stops are
unlocked and infeasible that the search rebuilds most routes. Keep the plan
valid against the edited `problem` and the warm start holds.

`options.seed` defaults to `42`, and the same seed on the same request
usually returns the same plan, though the search is bounded by time rather
than by work, so it is not guaranteed. Keep one seed across a day's repairs
so a repeated call does not reshuffle routes for no reason; vary it only
when you deliberately explore alternatives.

## One insertion: `suggest`

When one order arrives and the routes are already on the road, a full solve
is the wrong tool: it may move every unlocked stop to save a few minutes, and
a dispatcher then has to re-brief every driver. `suggest` leaves the plan as
it is and ranks the feasible positions for each task of `problem` that is not
in `plan`, lowest `delta.cost` first, within `options.time_limit_s` (default
`1`) and up to `options.max_insertions` per task (default `5`, at most `10`).

Use it when the dispatcher chooses, when the customer is on the phone and
needs an arrival time now (`placements[].service_start`), or when a change
must not ripple through other routes. Use `solve` with `plan` when the task
gets an empty `insertions` list with `reason.code: "infeasible"`: no position
in the committed plan works, but the solver may make room by moving unlocked
stops.

To apply a suggestion, swap in its `routes`: for each entry, replace the
`plan.routes[]` entry with the same `vehicle` and `shift`, or add it when the
plan has none, then send the edited plan to `evaluate` for the full timeline
or to `solve` as a warm start. Each task is ranked against the same committed
plan, so after applying one insertion, call `suggest` again for the next task.
See [Applying a suggestion](https://docs.solvice.io/guides/vrp/v3/suggest.md#applying-a-suggestion).

## Call frequency

* **Batch disruptions.** One repair that carries a cancellation, a late
  driver and two new orders is cheaper and more stable than four repairs in a
  row, each moving stops the previous one placed. Collect changes, then send
  one request per plan.
* **One repair in flight per plan.** Two overlapping warm starts from the
  same plan return two plans that disagree. Wait for the response, apply it,
  then send the next batch from the plan it returned.
* **Poll a run's status first.** On an async run, read `GET
  /v3/routing/runs/{id}` until `status` is `succeeded` before fetching
  `/result`; before then `/result` is a `409` with `run_not_finished`. Cancel
  a run that a newer batch has made obsolete with `POST
  /v3/routing/runs/{id}/cancel`.
* **Respect the run limit.** At most 10 runs per account may be `queued` or
  `running`; one more is a `429` with `Retry-After: 30`. A cancelled run keeps
  counting until its worker stops.
* **Do not shrink the time limit to go faster.** Below `0.1` the budget is
  raised to 100 ms anyway, and a warm start already returns early on its own.
  Shorten the wall-clock instead by leaving out `options.polyline` and
  `travel.departure_time` on repairs.
* **Back off on `429` and `503`.** A `429` from the gateway is the rate
  limit or the ten-run cap, and its body is not the error body; a `503` with code `overloaded` means no solver is
  free and carries a `Retry-After` header. Retry both with backoff, and spread
  out bursts such as a top-of-the-hour re-solve of every route.

## Worked example

One van, three jobs in Ghent, straight-line travel so the figures are
reproducible. The shift runs 08:00 to 17:00; `boiler-gent` has a morning
window and `radiator-ledeberg` an afternoon one. Both requests below were sent
to `https://api.solvice.io/v3/routing/solve` and the responses are trimmed to
`summary`, `plan` and `unassigned`.

### Morning solve

```json theme={null}
{
  "problem": {
    "jobs": [
      { "id": "boiler-gent", "location": [3.7250, 51.0500], "service_duration_s": 1800,
        "time_windows": [{ "from": "2026-11-10T09:00:00+01:00", "to": "2026-11-10T12:00:00+01:00" }] },
      { "id": "thermostat-wondelgem", "location": [3.7100, 51.0750], "service_duration_s": 1200,
        "time_windows": [{ "from": "2026-11-10T08:00:00+01:00", "to": "2026-11-10T17:00:00+01:00" }] },
      { "id": "radiator-ledeberg", "location": [3.7400, 51.0300], "service_duration_s": 2700,
        "time_windows": [{ "from": "2026-11-10T13:00:00+01:00", "to": "2026-11-10T17:00:00+01:00" }] }
    ],
    "vehicles": [
      {
        "id": "van-1",
        "shifts": [
          { "id": "tue", "from": "2026-11-10T08:00:00+01:00", "to": "2026-11-10T17:00:00+01:00",
            "start": { "location": [3.7174, 51.0543] }, "end": { "location": [3.7174, 51.0543] } }
        ]
      }
    ],
    "travel": { "engine": "straight_line" }
  },
  "options": { "time_limit_s": 2 }
}
```

The response serves all three, visiting `thermostat-wondelgem` at 08:02,
`boiler-gent` at 08:26 (waiting for its 09:00 window) and `radiator-ledeberg`
at 09:32 (waiting until 13:00), back at base at 13:48:

```json theme={null}
{
  "summary": {
    "score": {
      "violation_count": 0,
      "unassigned_priority": 0,
      "cost": 205.5748,
      "components": {"routes": 0.0, "vehicles": 0.0, "time": 203.391, "distance": 2.1838, "waiting": 0.0, "driving": 0.0, "overtime": 0.0, "lateness": 0.0, "window_choice": 0.0, "target": 0.0, "tag_preference": 0.0, "reloads": 0.0, "grouping": 0.0, "workload_share": 0.0, "drop_costs": 0.0, "preferences": 0.0, "assignment": 0.0, "delay": 0.0}
    },
    "tasks": {"served": 3, "unassigned": 0, "unassigned_required": 0},
    "vehicles_used": 1,
    "shifts_used": 1,
    "distance_m": 10919,
    "time": {"span_s": 20925, "travel_s": 786, "service_s": 5700, "depot_s": 0, "wait_s": 14439, "break_s": 0, "overtime_s": 0},
    "timings": {"search_ms": 500, "total_ms": 500},
    "search": {"termination": "time_limit", "iterations": 285529}
  },
  "plan": {
    "routes": [
      {
        "vehicle": "van-1",
        "shift": "tue",
        "stops": [
          {"type": "job", "id": "thermostat-wondelgem"},
          {"type": "job", "id": "boiler-gent"},
          {"type": "job", "id": "radiator-ledeberg"}
        ]
      }
    ]
  },
  "unassigned": []
}
```

`search_ms` is 500 although the budget was 2 seconds: a one-vehicle solve
with time windows and fewer than 60 visits is capped at 500 ms. See
[When a solve returns before the budget](https://docs.solvice.io/guides/vrp/v3/concepts/performance-guide.md#when-a-solve-returns-before-the-budget).

### Repair at 09:40

The driver has finished the first two stops. `radiator-ledeberg` cancels, and
a leak in Sint-Amandsberg must be fixed between 10:00 and 12:00. The repair
request differs from the morning one in four places:

1. `radiator-ledeberg` is gone from `jobs` **and** from `plan.routes[0].stops`.
2. `leak-sint-amandsberg` is added to `jobs`, with `"priority": 5`, and is
   not in `plan`.
3. The morning response's `plan` is the request `plan`, with
   `"locked_count": 2` so the two completed stops stay where they are.
4. `time_limit_s` drops to `1`; the warm start will return sooner anyway.

```json theme={null}
{
  "problem": {
    "jobs": [
      { "id": "boiler-gent", "location": [3.7250, 51.0500], "service_duration_s": 1800,
        "time_windows": [{ "from": "2026-11-10T09:00:00+01:00", "to": "2026-11-10T12:00:00+01:00" }] },
      { "id": "thermostat-wondelgem", "location": [3.7100, 51.0750], "service_duration_s": 1200,
        "time_windows": [{ "from": "2026-11-10T08:00:00+01:00", "to": "2026-11-10T17:00:00+01:00" }] },
      { "id": "leak-sint-amandsberg", "location": [3.7500, 51.0600], "service_duration_s": 1500, "priority": 5,
        "time_windows": [{ "from": "2026-11-10T10:00:00+01:00", "to": "2026-11-10T12:00:00+01:00" }] }
    ],
    "vehicles": [
      {
        "id": "van-1",
        "shifts": [
          { "id": "tue", "from": "2026-11-10T08:00:00+01:00", "to": "2026-11-10T17:00:00+01:00",
            "start": { "location": [3.7174, 51.0543] }, "end": { "location": [3.7174, 51.0543] } }
        ]
      }
    ],
    "travel": { "engine": "straight_line" }
  },
  "plan": {
    "routes": [
      {
        "vehicle": "van-1",
        "shift": "tue",
        "locked_count": 2,
        "stops": [
          { "type": "job", "id": "thermostat-wondelgem" },
          { "type": "job", "id": "boiler-gent" }
        ]
      }
    ]
  },
  "options": { "time_limit_s": 1 }
}
```

The response keeps the two locked stops at their morning times, appends the
leak (arrival 09:32, service at 10:00 when the window opens) and brings the
van home at 10:27 instead of 13:48:

```json theme={null}
{
  "summary": {
    "score": {
      "violation_count": 0,
      "unassigned_priority": 0,
      "cost": 88.1696,
      "components": {"routes": 0.0, "vehicles": 0.0, "time": 86.2164, "distance": 1.9532, "waiting": 0.0, "driving": 0.0, "overtime": 0.0, "lateness": 0.0, "window_choice": 0.0, "target": 0.0, "tag_preference": 0.0, "reloads": 0.0, "grouping": 0.0, "workload_share": 0.0, "drop_costs": 0.0, "preferences": 0.0, "assignment": 0.0, "delay": 0.0}
    },
    "tasks": {"served": 3, "unassigned": 0, "unassigned_required": 0},
    "vehicles_used": 1,
    "shifts_used": 1,
    "distance_m": 9766,
    "time": {"span_s": 8870, "travel_s": 703, "service_s": 4500, "depot_s": 0, "wait_s": 3667, "break_s": 0, "overtime_s": 0},
    "timings": {"search_ms": 150, "total_ms": 150},
    "search": {"termination": "no_improvement", "iterations": 86591}
  },
  "plan": {
    "routes": [
      {
        "vehicle": "van-1",
        "shift": "tue",
        "stops": [
          {"type": "job", "id": "thermostat-wondelgem"},
          {"type": "job", "id": "boiler-gent"},
          {"type": "job", "id": "leak-sint-amandsberg"}
        ]
      }
    ]
  },
  "unassigned": []
}
```

Three things to read off it. `termination` is `no_improvement` after 150 ms:
the warm start found nothing better than the plan it was given plus the
inserted leak, and stopped. `search_progress` (not shown) opens at
`unassigned_priority: 5`, the leak outside the plan, and reaches `0` in the
first improvement. And the returned `plan` has no `locked_count`: the next
repair sets it again, this time to `3`.

Sent as a `suggest` body instead (same `problem` and `plan`, `options` of
`{ "max_insertions": 3 }`), the leak gets one insertion, after `boiler-gent`,
with `delta.cost` of `33.97688`: the `88.1696` above minus the `54.19272`
the two-stop plan costs on its own. The other two positions are inside the
locked prefix and are not offered.

## Next steps

> [card] [Warm-starting with plan](https://docs.solvice.io/guides/vrp/v3/api-design.md#warm-starting-with-plan)
>
> The `plan` object, `locked_count` and `pinned` in full.

> [card] [Suggest](https://docs.solvice.io/guides/vrp/v3/suggest.md)
>
> Ranked insertions for a new task, and how to apply one.

> [card] [Async runs](https://docs.solvice.io/guides/vrp/v3/runs.md)
>
> Repairs of more than 400 tasks, run limits and polling.

> [card] [Performance guide](https://docs.solvice.io/guides/vrp/v3/concepts/performance-guide.md)
>
> Choosing `time_limit_s`, and why a warm start returns early.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.