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

# Solution Quality Metrics

> What POST /v3/routing/solve actually reports about a solution, and how to read it

# Solution Quality Metrics

Once you have a solved (or partially solved) response, "is this any good?" is a question you answer from the fields that response actually contains — not from a generic quality-scoring framework. This guide walks through every metric you can compute from `SolveResponse`, distinguishes what the API returns directly from what you derive yourself, and calls out the handful of business metrics (profit margin, cost-per-job with real currency, preferred-resource fulfillment) that need data the solver doesn't have.

<Info>
  There is no separate quality-scoring endpoint and no opaque `occupancy`/`workloadFairness`/`onTimeRate` block in the response. Every metric on this page is either a field on `Summary`/`Route`/`StopOutput`/`Unassigned` (see `src/api/v3_types.rs`), or a named arithmetic derivation from those fields that this page states explicitly. If a metric here isn't traceable to one of those two things, it's called out as something you'd have to compute by joining the response with your own data.
</Info>

## What Makes a Good Solution?

The solver itself already answers this question once, internally, via the two-tier objective described in [Objective Function](/guides/vrp/v3/concepts/scoring-explanation): first minimise the drop penalty of the tasks left unserved, then minimise currency cost. This page is about reading the *result* of that comparison back out of the response — the same underlying data, viewed as metrics rather than as a search objective.

```mermaid theme={null}
mindmap
  root((Solution Quality))
    FEASIBILITY
      jobs_assigned / jobs_unassigned
      unassigned reasons
    COST
      estimated_cost.total
      cost components
    ROUTE SHAPE
      total_distance_m / total_duration_s
      per-route distance_m / duration_s
      overtime_s
    TIME BEHAVIOR
      wait_s per stop
      lateness_s per stop
    SEARCH BUDGET
      iterations
      elapsed_ms
```

## Core Quality Metrics

### 1. Feasibility Rate

`summary.jobs_assigned` and `summary.jobs_unassigned` are returned directly. Feasibility rate itself is a customer-side ratio, not a field:

```
Feasibility rate = jobs_assigned / (jobs_assigned + jobs_unassigned)
```

<Tabs>
  <Tab title="Perfect (100%)">
    ```json theme={null}
    {
      "summary": {
        "status": "solved",
        "jobs_assigned": 50,
        "jobs_unassigned": 0
      },
      "unassigned": []
    }
    ```

    `status: "solved"` — every job placed. Feasibility rate = 50 / 50 = 100%.
  </Tab>

  <Tab title="Partial (96%)">
    ```json theme={null}
    {
      "summary": {
        "status": "partial",
        "jobs_assigned": 48,
        "jobs_unassigned": 2
      },
      "unassigned": [
        { "job_id": "job-23", "reasons": [{ "code": "CAPACITY_EXCEEDED", "message": "..." }], "relaxations": [] },
        { "job_id": "job-47", "reasons": [{ "code": "TIME_WINDOW_VIOLATED", "message": "..." }], "relaxations": [] }
      ]
    }
    ```

    Feasibility rate = 48 / 50 = 96%. Every unassigned job carries a structured `reasons` array — read it before assuming this is a capacity or budget problem you can just throw more `time_limit_s` at.
  </Tab>

  <Tab title="Infeasible (0%)">
    ```json theme={null}
    {
      "summary": { "status": "infeasible", "jobs_assigned": 0, "jobs_unassigned": 50 }
    }
    ```

    `status: "infeasible"` — no job could be served at all, usually a fleet-wide blocker (no vehicle has any capacity, or every shift window excludes every job).
  </Tab>
</Tabs>

Source: `Summary.jobs_assigned`, `Summary.jobs_unassigned`, and `SolveStatus` (`Solved`/`Partial`/`Infeasible`) in `src/api/v3_types.rs`.

<Tip>
  `summary.status` already tells you which of the three feasibility bands you're in without computing the ratio yourself — `solved` means the ratio is 100%, `infeasible` means it's 0%, and `partial` means somewhere in between. Compute the exact percentage when you need to track it as a KPI over time (see [Monitoring Quality Over Time](#monitoring-quality-over-time) below).
</Tip>

### 2. Cost

`summary.estimated_cost` is a real-currency figure, not an opaque score — see [Objective Function](/guides/vrp/v3/concepts/scoring-explanation#what-you-actually-set-objectiveminimize_vehicles-objectivecosts-vehiclescost) for the full cost model. For solution-quality purposes, the two numbers worth watching are the total and the share of it you paid to *not* serve tasks:

```json theme={null}
{
  "estimated_cost": {
    "total": 141.5,
    "currency": "EUR",
    "components": { "travel": 116.5, "waiting": 0.0, "drop_fees": 25.0 }
  }
}
```

```
Drop-fee share = components.drop_fees / total
```

A rising `components.drop_fees` share across otherwise-similar requests means the solver is choosing to pay more in drop fees rather than route to those tasks — worth checking against your `drop_fee` values (see [Improvement Strategies](#improvement-strategies) below) before assuming it's a routing-quality regression. `drop_fees` is **absent**, not `0.0`, when no task priced a drop; the same absent-means-unpriced rule applies to `overtime`.

<Note>
  `CostComponents` also declares `lateness`, `vehicles_fixed`, `preferences`, and `imbalance` — all `Option<f64>`, all set to `None` unconditionally by `build_v3_response()` in `src/api/response_v3.rs`, and all excluded from `total`. Don't chart those four as zero; they're absent because they aren't computed yet, not because their true value is zero. `travel`, `waiting`, `overtime`, and `drop_fees` are the four that are really populated, and `total` is exactly their sum — so a plan carrying real lateness or preference cost reports a `total` below the number the search actually minimised.
</Note>

Source: `EstimatedCost`, `CostComponents` in `src/api/v3_types.rs`.

### 3. Route Shape: Distance, Duration, and Overtime

`summary.total_distance_m` and `summary.total_duration_s` are fleet-wide sums; each `routes[]` entry carries the same two figures per vehicle, plus `overtime_s`:

```json theme={null}
{
  "summary": { "total_distance_m": 8450, "total_duration_s": 1980 },
  "routes": [
    { "vehicle": "v1", "distance_m": 8450, "duration_s": 1980, "overtime_s": 0, "load_peak": { "weight": 55 } }
  ]
}
```

Two useful customer-side derivations:

```
Distance per job = summary.total_distance_m / summary.jobs_assigned
```

Lower is better clustering/routing for a given job set — but only meaningful when comparing runs over the *same* job set, since job density and geography dominate this number more than routing quality does.

```
Fleet-wide overtime = sum(routes[].overtime_s)
```

`overtime_s` is "seconds of overtime past the soft shift end" per route (`0` when the route finishes within its shift) — summing it across `routes[]` gives you a single fleet-wide overtime figure not returned as a top-level field.

<Warning>
  There is no `circuity factor` (actual distance ÷ straight-line distance) anywhere in the response, and the solver has no straight-line-distance concept to compute one against — every distance in this API comes from a real Solvice Maps road-network matrix (see [Distance Matrix Integration](/guides/vrp/v3/concepts/distance-matrices)). If you want a circuity-style efficiency check, you'd need to compute great-circle distance between each stop pair yourself from `stops[].location` and divide `travel_distance_m` by that — a metric you'd build entirely client-side, not something this page can point you at a field for.
</Warning>

Source: `Summary.total_distance_m`, `Summary.total_duration_s`, `Route.distance_m`, `Route.duration_s`, `Route.overtime_s`, `Route.load_peak` in `src/api/v3_types.rs`.

### 4. Fleet Utilization

`summary.vehicles_used` ("number of routes with at least one task stop") is returned directly. There is no separate "total fleet size" field in the response — you know your requested fleet size because you sent `vehicles[]` in the request:

```
Utilization rate = vehicles_used / count(vehicles[] in the request)
```

This is a customer-side ratio computed by joining the response against your own request, not a returned field. Careful with multi-shift fleets: one `vehicles[]` entry with several `shifts` becomes one internal route *per shift*, so `vehicles_used` counts vehicle-shifts, not physical vehicles. There is no `vehicles[].count` multiplier field — every vehicle you want is its own array entry.

Source: `Summary.vehicles_used` in `src/api/v3_types.rs`.

### 5. Time-Based Metrics: Wait and Lateness

Per-stop timing detail lives on `StopOutput`, not as fleet-wide aggregates:

```json theme={null}
{
  "type": "job",
  "id": "job-2",
  "arrival": "2026-07-01T09:20:00+02:00",
  "departure": "2026-07-01T09:30:00+02:00",
  "wait_s": 120,
  "service_s": 600,
  "travel_time_s": 900,
  "travel_distance_m": 4200,
  "lateness_s": 0
}
```

* **`wait_s`** — seconds spent waiting for the time window to open at that stop.
* **`lateness_s`** — seconds late beyond the latest allowed arrival (`0` when on time). Because fleet-wide `objective.costs.per_late_hour` is the only mechanism that makes a time window soft (see [Constraint System](/guides/vrp/v3/concepts/constraint-system#timewindow)), a nonzero `lateness_s` only ever shows up when that rate is set — a hard time window makes lateness infeasible rather than penalized, so it never appears as a nonzero value in the response. A soft `target_arrival` is different: it prices late arrival but is not a window, so it never produces `lateness_s` either.
* **`travel_time_s`** / **`travel_distance_m`** — the leg immediately before this stop.

Two customer-side rollups you can build from these, summed across every stop in every route:

```
Total wait = sum(stops[].wait_s)
On-time rate = count(stops where lateness_s == 0 or absent) / count(stops with a time window)
```

`wait_s`, `service_s`, `travel_time_s`, `travel_distance_m`, and `lateness_s` are all `Option<i32>` — they're `None`/omitted for stops where the concept doesn't apply (e.g. a depot `start` stop has no `lateness_s`), not defaulted to `0`. Treat an absent field as "not applicable here," not as "zero."

<Warning>
  There is no `onTimeDeliveries`/`onTimeRate` field anywhere in the response, and no fleet-wide wait-time summary. Both of the rollups above are entirely client-side arithmetic over `stops[]` — useful, but not something you can read off a single field.
</Warning>

Source: `StopOutput.wait_s`, `StopOutput.service_s`, `StopOutput.travel_time_s`, `StopOutput.travel_distance_m`, `StopOutput.lateness_s` in `src/api/v3_types.rs`.

## Advanced Quality Indicators

### Workload Distribution

There is **no balance reporting in the response at all**. `Summary` has exactly nine fields — `status`, `vehicles_used`, `jobs_assigned`, `jobs_unassigned`, `total_distance_m`, `total_duration_s`, `estimated_cost`, `elapsed_ms`, `iterations` — and no `balance` object among them; the `BalanceReport` placeholder that used to sit there was removed rather than left unpopulated. On the request side there is no `objective.balance` either, so it is an unknown-field rejection, not a "not supported" 400.

Compute the spread yourself over `routes[].duration_s` (or `.distance_m`):

```
Workload spread = max(routes[].duration_s) - min(routes[].duration_s)
```

There is no `workloadFairness` score, no `resourceWorkloads` breakdown, and no way to ask the solver to optimise for balance today. Source: `Summary` in `src/api/v3_types.rs`.

### Skills and Constraint Compliance

There is no `priorityLevels`/`weightedFulfillment` breakdown and no job-level `priority` field in the schema at all — `JobCommon` has `mandatory`, `drop_fee`, `skills`, `eligible_vehicles`, `locked_vehicle`, `preferences`, `tags`, `target_arrival`, and `preferred_date`, but nothing that ranks jobs into abstract priority tiers. The closest thing to a priority is `drop_fee`, and it is money rather than a rank. What you get instead is per-job pass/fail via `unassigned[].reasons`:

```json theme={null}
{
  "job_id": "job-9",
  "reasons": [{ "code": "SKILLS_MISMATCH", "message": "No eligible vehicle has the required skill(s)." }],
  "relaxations": []
}
```

A "skills compliance rate" is something you'd compute yourself by counting how many `unassigned[]` entries carry `SKILLS_MISMATCH` versus your total skill-tagged job count — there's no returned aggregate for it.

<Warning>
  There is no `preferredResourceMatches`/`preferenceRate`/`tagMatchRate` metric. `JobCommon.preferences` (soft vehicle affinity) is accepted and priced into the objective, but the response carries no per-task "was this preference honoured?" flag and `estimated_cost.components.preferences` is still omitted — to measure preference satisfaction you compare each `preferences[].vehicle` against the `vehicle` on the route that served the task. `tags` exist for grouping/relation labels only, explicitly **not** capability matching, so there's no tag-affinity score to derive from them either.
</Warning>

Source: `JobCommon` fields, `Unassigned`/`UnassignedReason` in `src/api/v3_types.rs`.

### Cost Effectiveness Beyond `estimated_cost`

`estimated_cost.total` is real currency, but a few cost-adjacent metrics from the source workflow require data the solver never sees:

<CodeGroup>
  ```text What the API gives you theme={null}
  summary.estimated_cost.total       // EUR, or whatever currency was set
  summary.estimated_cost.components  // travel + waiting, plus overtime/drop_fees when priced
  summary.total_distance_m
  summary.total_duration_s
  ```

  ```text What you'd compute yourself, and why theme={null}
  cost_per_job = estimated_cost.total / jobs_assigned   // simple division, fine to derive
  cost_per_km  = estimated_cost.total / (total_distance_m / 1000)  // same, fine to derive
  revenue_per_route   // needs YOUR revenue data — solver has no price/revenue concept
  profit_margin       // needs revenue_per_route above — not derivable from the response alone
  ```
</CodeGroup>

`cost_per_job` and `cost_per_km` are legitimate one-line derivations from fields the API actually returns. `revenuePerRoute` and `profitMargin`, by contrast, are business metrics that require joining the response with your own pricing/revenue data — the solver has no concept of what you charge a customer, so there is no request field or response field that could ever produce them directly.

## Comparing Solutions

When you run the same problem more than once (different `seed`, different `time_limit_s`, or after a request edit), compare runs on the real fields in order of what the objective actually prioritizes:

```mermaid theme={null}
graph TD
    CMP["Compare run A vs run B"] --> T1{"jobs_unassigned differs?"}
    T1 -->|"Yes"| W1["Fewer unassigned wins,<br/>regardless of cost"]
    T1 -->|"Tied"| T2{"estimated_cost.total differs?"}
    T2 -->|"Yes"| W2["Lower cost wins"]
    T2 -->|"Tied"| T3["Compare route shape:<br/>total_duration_s, overtime_s, spread"]

    style W1 fill:#27ae60,color:#fff
    style W2 fill:#4a90d9,color:#fff
```

This mirrors the solver's own two-tier objective (see [Objective Function](/guides/vrp/v3/concepts/scoring-explanation#lexicographic-priority-the-drop-penalty-outranks-everything)): don't average `jobs_unassigned` and `estimated_cost.total` into a single blended score unless you've deliberately decided your business cares about that tradeoff. The solver never trades a *required* task away for a better cost. It will trade a `drop_fee`-priced one — that is the whole point of the fee — but in that case the fee is already inside `estimated_cost.total`, so comparing totals is the fair comparison.

<Tip>
  If you do want a single composite score across multiple runs (e.g. to rank several `seed` values), build it from fields you actually have — `jobs_unassigned`, `estimated_cost.total`, `summary.total_duration_s`, and your own workload-spread calculation — and weight `jobs_unassigned` heavily enough that it dominates the other terms, matching how the solver itself never lets cost override feasibility.
</Tip>

## Improvement Strategies

### For Low Feasibility (High `jobs_unassigned`)

<Steps>
  <Step title="Read unassigned[].reasons first">
    Don't reach for `time_limit_s` or cost tuning before checking why each job failed — `reasons[].code` (`CAPACITY_EXCEEDED`, `TIME_WINDOW_VIOLATED`, `SKILLS_MISMATCH`, etc.) tells you whether it's a hard-constraint block that no amount of search time can fix. See [Constraint System](/guides/vrp/v3/concepts/constraint-system#debugging-why-didnt-my-job-get-placed) for the full code list.
  </Step>

  <Step title="Price the truly optional jobs">
    Give the task a `drop_fee` — what leaving it unserved is worth to you — instead of leaving every task at the implicit must-serve default. There is no fleet-wide fallback price, and `mandatory: false` without a `drop_fee` is rejected with HTTP 400. See [Objective Function](/guides/vrp/v3/concepts/scoring-explanation#droppable-tasks-drop_fee-and-mandatory).

    ```json theme={null}
    { "id": "job-2", "location": { "coordinate": [3.71, 51.06] }, "drop_fee": 1000.0 }
    ```
  </Step>

  <Step title="Add capacity or relax constraints">
    Apply the `relaxations[]` suggestion directly (it names the exact field and the smallest change), or add real vehicle capacity/shift time if the relaxation isn't practical.
  </Step>
</Steps>

<Warning>
  There is no `partialPlanning` boolean anywhere in the schema. The real mechanism for "let some jobs go unassigned on purpose" is a per-task `drop_fee` — a priced decision the solver makes per task, not a single global switch.
</Warning>

### For Poor Route Efficiency (High Cost or Distance)

<Tabs>
  <Tab title="Tune cost rates to your real economics">
    ```json theme={null}
    {
      "objective": {
        "costs": { "per_travel_km": 0.20, "per_travel_hour": 35.0, "currency": "EUR" }
      }
    }
    ```

    `per_travel_km` / `per_travel_hour` are the actual dials — see [Objective Function](/guides/vrp/v3/concepts/scoring-explanation#if-you-set-nothing-the-v3-defaults) for the real defaults (€0.20/km and €35/hour, and **no** default per-vehicle cost) they replace.
  </Tab>

  <Tab title="Price lateness instead of leaving windows hard">
    ```json theme={null}
    {
      "objective": { "costs": { "per_late_hour": 40.0 } }
    }
    ```

    `per_late_hour` is the only mechanism that softens a time window; omitting it keeps windows hard (infeasible, not penalized), and an explicit `0.0` is rejected as ambiguous. There is no per-request `snapUnit`/time-slot-rounding option — arrival and departure times are exact ISO-8601 instants, not rounded to a slot grid.
  </Tab>

  <Tab title="Give the search more time">
    ```json theme={null}
    {
      "options": { "runtime": { "time_limit_s": 5, "seed": 42 } }
    }
    ```

    See [Performance Guide](/guides/vrp/v3/concepts/performance-guide#the-real-tuning-knob-optionsruntimetime_limit_s) for how `time_limit_s` and `seed` actually affect solution quality. There is no separate "optimize routes" toggle. If you are re-optimising an existing plan, pass it as `initial_routes` so the search warm-starts from it rather than rebuilding from scratch.
  </Tab>
</Tabs>

### For Imbalanced Workload

There is no `fairWorkloadPerResource` boolean, no `workloadSpreadWeight`/`workloadSensitivity` option, and no `objective.balance` field — nor any balance figure in the response (both covered above). The only lever available today is indirect: `vehicles[].cost.fixed` / `objective.costs.per_vehicle` and the fleet-wide travel rates shape how many vehicles the solver uses and how it spreads work across them, but there is no dedicated fairness objective to turn on.

<Note>
  If workload balance is a hard business requirement today, you'll need to post-process: solve, compute the spread over `routes[].duration_s` yourself, and adjust vehicle count or shift windows in a follow-up request if the spread is unacceptable. This is a real gap, not a naming difference — don't build against a `fairWorkloadPerResource` field that doesn't exist.
</Note>

## Monitoring Quality Over Time

There is no built-in metrics-history endpoint — `GET /v3/metrics` (Prometheus format) covers server-level operational metrics (request counts, solver duration, queue depth), not per-solve business KPIs. To track solution quality over time, log the fields you care about from each `SolveResponse` into your own store:

```json theme={null}
{
  "date": "2026-07-01",
  "summary": {
    "status": "solved",
    "jobs_assigned": 48,
    "jobs_unassigned": 2,
    "vehicles_used": 5,
    "total_distance_m": 245000,
    "total_duration_s": 28800,
    "estimated_cost": { "total": 612.40, "currency": "EUR" },
    "iterations": 41200,
    "elapsed_ms": 1340
  }
}
```

Everything in that example is a real field from `Summary` (see `src/api/v3_types.rs`) — there is no separate "daily metrics"/"weekly trends" response shape; you build that aggregation layer yourself from repeated `SolveResponse` bodies.

## Quality Benchmarks

There are no published per-sector benchmark numbers (feasibility %, occupancy %, on-time %) for this API — those would require occupancy and on-time fields the response doesn't return today, and industry benchmarks depend heavily on your own fleet economics and SLAs. The one number worth carrying around is throughput, not solution quality: roughly 5 ALNS iterations per millisecond for a \~100-job instance, as an order of magnitude only (see [Performance Guide](/guides/vrp/v3/concepts/performance-guide#how-more-time-becomes-a-better-solution)). Use `summary.iterations` against that figure to sanity-check whether a given `time_limit_s` was enough for your problem size — that's a search-budget check, not a solution-quality benchmark.

<Note>
  If you need sector-specific quality targets (feasibility rate, on-time rate, workload fairness), define them from your own historical data and SLAs, then track them using the derivations in this page — the API itself doesn't ship a benchmark table.
</Note>

## Best Practices

<Check>
  **Quality management guidelines:**

  1. **Read `summary.status` first.** `solved`/`partial`/`infeasible` already tells you the feasibility band before you compute anything.
  2. **Diagnose before tuning.** `unassigned[].reasons` explains *why* a job dropped; don't reach for cost rates or `time_limit_s` until you've read it.
  3. **Distinguish returned fields from your own derivations.** `jobs_assigned`, `estimated_cost.total`, `distance_m` are real; feasibility rate, cost-per-job, and workload spread are arithmetic you do on top of them — know which is which when you build dashboards.
  4. **Respect the solver's own priority order when comparing runs.** Fewer unassigned jobs beats lower cost, always — don't build a blended score that could rank a cheaper-but-less-complete solution above a more-complete one, unless that's a deliberate business choice.
  5. **Don't build against unshipped fields.** `CostComponents.lateness`, `vehicles_fixed`, `preferences`, and `imbalance` are declared but never populated, and they are excluded from `total` as well. Anything else you remember from an older schema — `summary.balance`, `objective.balance`, `unassigned_cost`, `options.runtime.matrix` — has been removed outright and now returns an unknown-field rejection.
  6. **Track your own KPIs over time.** There's no built-in history; log the fields above into your own store per solve.
</Check>

## Related Topics

<CardGroup cols={2}>
  <Card title="Objective Function" icon="star" href="/guides/vrp/v3/concepts/scoring-explanation">
    The two-tier objective that determines which solution the solver picks in the first place
  </Card>

  <Card title="Constraint System" icon="cog" href="/guides/vrp/v3/concepts/constraint-system">
    The sixteen hard constraints, the soft mechanisms, and the `unassigned[].reasons` diagnosis
  </Card>

  <Card title="Performance Guide" icon="gauge" href="/guides/vrp/v3/concepts/performance-guide">
    Balancing search time against solution quality
  </Card>

  <Card title="Distance Matrices" icon="map" href="/guides/vrp/v3/concepts/distance-matrices">
    Where `distance_m`/`duration_s` actually come from
  </Card>
</CardGroup>
