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

# Objective Function

> How the solver ranks feasible solutions: the two-tier lexicographic objective and the real-currency cost model

# Objective Function

Once a solution satisfies every active hard constraint, the solver still has to decide which of many *feasible* arrangements is best. This page explains that comparison for `POST /v3/routing/solve`: a strict two-tier lexicographic order, backed by a real, currency-denominated cost model — not a Hard/Medium/Soft point score.

<Info>
  This solver does **not** have `hardScore`/`mediumScore`/`softScore` fields, and there is no `unservedReasons` map of constraint-code strings per job. Those come from a different VRP engine's scoring model and don't exist in this API. The real mechanism is a **two-tier objective** (drop penalty → real-currency cost) plus a structured **`unassigned[].reasons`** array per job. See [Constraint System](/guides/vrp/v3/concepts/constraint-system#the-objective-two-tiers-not-a-score) for how constraints feed into this, and [Explainable AI](/guides/platform/explainable-ai) for the full unassigned-job diagnosis mechanism.
</Info>

## Lexicographic Priority: The Drop Penalty Outranks Everything

Before any money is compared, the solver compares the total penalty of the tasks each candidate left unserved:

```mermaid theme={null}
graph TD
    CMP["Compare two solutions"] --> T1{"Tier 1: penalty_sum<br/>differs?"}
    T1 -->|"Yes"| W1["Lower drop penalty wins outright,<br/>regardless of cost"]
    T1 -->|"Tied"| T2{"Tier 2: cost<br/>differs?"}
    T2 -->|"Yes"| W2["Lower currency cost wins"]
    T2 -->|"Tied"| TIE["Solutions are equal"]

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

1. **`penalty_sum`** (tier 1, hard) — the summed drop penalty of the unassigned tasks. Every *required* task — which is the default, and everything that doesn't set `drop_fee` — carries the same large, uniform internal penalty, so on an unpriced request this tier is exactly "number of unassigned tasks" and can never be outranked by cost.
2. **`cost`** (tier 2) — the weighted currency sum of vehicle activation, travel distance, travel time, waiting, overtime, soft lateness, preference violations, non-preferred-day charges, and the `drop_fee` of every priced task the plan chose not to serve. This is what `objective.costs` and `vehicles[].cost` tune, and it's most of what the rest of this page is about.

Source: `Objective` and `Objective::key` in `crates/solver-core/src/objective.rs`. The struct still carries an `unassigned` count, but it is reporting/fingerprinting only — it does **not** participate in the ranking.

<Note>
  The two tiers used to be three: an `unassigned` *count* tier sat above `penalty_sum`, and a request priced drops with a separate `unassigned_cost` field that ranked between the count and the money. Both are gone. A drop is now either impossible (the default) or a real payment in tier 2, which is what makes a `drop_fee` genuinely trade against travel cost instead of always winning.
</Note>

## What You Actually Set: `objective.minimize_vehicles`, `objective.costs`, `vehicles[].cost`

A V3 caller expresses business priorities with three request fields:

* **`objective.minimize_vehicles`** — a bool, default `true`. It is *not* an ordered token list; it replaced an `objective.priorities` array that could never actually be reordered. See [Vehicle Count Is Free Until You Price It](#vehicle-count-is-free-until-you-price-it) for what it does and doesn't do.
* **`objective.costs`** — fleet-wide default rates that apply to every vehicle that doesn't override them: `per_travel_km`, `per_travel_hour`, `per_wait_hour` (idle time between stops), `per_overtime_hour` (time past a shift's soft end), `per_late_hour` (soft time-window lateness), `per_vehicle` (flat charge per non-empty route), plus an informational `currency` label.
* **`vehicles[].cost`** — per-vehicle overrides: `fixed` (one-time cost when the vehicle is used), `per_travel_hour`, `per_stop`, and `per_travel_km`. At most **one** of the three billing terms (`fixed`/`per_travel_hour`/`per_stop`) may be set per vehicle; `per_travel_km` overrides the fleet distance rate for that vehicle and combines freely with whichever billing term you chose.

A worked example — a courier fleet with a €150/day fixed cost per van, €25 per travel-hour, and a €0.20/km fuel rate:

```json theme={null}
{
  "vehicles": [
    { "id": "van-1", "cost": { "fixed": 150.0 }, "shifts": [ /* ... */ ] }
  ],
  "jobs": [ /* ... */ ],
  "objective": {
    "costs": { "per_travel_km": 0.20, "per_travel_hour": 25.0, "currency": "EUR" }
  }
}
```

Source: `Objective`, `Costs`, and `Cost` (per-vehicle) structs in `src/api/v3_types.rs`; `build_cost_config_v3` in `src/api/convert/request.rs` and `map_vehicle_cost` in `src/api/convert_v3/vehicles.rs`.

<Warning>
  Three combinations are rejected with HTTP 400 rather than silently resolved:

  * **`objective.costs.per_vehicle` together with `minimize_vehicles: true`** — count-first minimization ignores a money trade-off, so the pair is contradictory. Set `minimize_vehicles: false`, or omit `per_vehicle`.
  * **`objective.costs.per_late_hour: 0.0`** — "free lateness" and "hard windows" read opposite ways, so an explicit zero is ambiguous. Omit the field for hard windows. (Every other rate treats `0.0` as "priced at zero".)
  * **More than one of `fixed` / `per_travel_hour` / `per_stop` on the same vehicle** — `"vehicle '{id}': multi-term cost models not supported (Phase 1)"`.

  The schema uses strict field validation, so anything that isn't in the field lists above — an old `per_service_hour`, `standby`, `per_shift`, or `objective.balance` — is an unknown-field rejection, not a silently ignored option.
</Warning>

The response's `summary.estimated_cost` (an `EstimatedCost` object) reports back real currency, not an opaque integer:

```json theme={null}
{
  "summary": {
    "status": "solved",
    "estimated_cost": {
      "total": 16.5,
      "currency": "EUR",
      "components": { "travel": 16.5, "waiting": 0.0 }
    }
  }
}
```

`total` is the sum of the components actually reported — `travel + waiting + overtime + drop_fees` — so it stays comparable across plans that serve different task sets. `overtime` and `drop_fees` are **absent** rather than `0.0` when nothing priced them, which is how you tell "not priced" from "priced at zero".

Source: `Summary.estimated_cost`, `EstimatedCost`/`CostComponents` in `src/api/v3_types.rs`, populated by `build_v3_response()` in `src/api/response_v3.rs`. `EstimatedCost` is nested inside `SolveResponse.summary`, not under a separate `score` object.

<Note>
  `CostComponents` also declares `lateness`, `vehicles_fixed`, `preferences`, and `imbalance`, all typed `Option<f64>` and documented "Absent until wired." `build_v3_response()` sets those four to `None` unconditionally, so they never appear today. Because they are excluded from `total` as well as from the breakdown, a plan whose objective includes real lateness or preference cost reports a `total` lower than the number the search actually minimised. Don't chart these four as zero; they're absent because they aren't computed yet, not because their value is zero.
</Note>

## `per_wait_hour` Is Live — and Now Reported

`objective.costs.per_wait_hour` converts into `cost_per_wait`, an internal per-second idle-time weight, and is wired all the way into the solver's cost function: `Route::travel_cost` adds `cost_per_wait × total_wait` on top of the base distance/duration terms, and `route_cost` sums the same term into the objective. A nonzero `per_wait_hour` genuinely changes which solutions the ALNS search prefers — routes with less idle time between stops score better.

Unlike earlier releases, its euro impact is also visible in the response: `summary.estimated_cost.components.waiting` is computed from the fleet's total wait seconds at the configured rate, and is included in `total`. It reads `0.0` (not absent) when no rate was set. Source: `wait_cost_currency` in `src/api/response_v3.rs`, `CostConfig::cost_per_wait` in `crates/solver-core/src/objective.rs`.

<Note>
  `per_wait_hour` is fleet-wide only. There is no per-vehicle wait rate — `vehicles[].cost` has no such field, so setting one is an unknown-field rejection.
</Note>

## If You Set Nothing: The V3 Defaults

If a request omits `objective.costs` entirely, V3 seeds a realistic travel baseline — and *only* travel:

| Rate              | Default        | What it represents                                              |
| ----------------- | -------------- | --------------------------------------------------------------- |
| `per_travel_km`   | **€0.20 / km** | Fuel/wear cost per kilometre driven                             |
| `per_travel_hour` | **€35 / hour** | Driver wage cost per hour on the road                           |
| `per_vehicle`     | **0**          | No fleet-wide charge for using a vehicle                        |
| everything else   | **0 / unset**  | Waiting, overtime, and lateness are unpriced; windows stay hard |

Override them per-fleet via `objective.costs` / `vehicles[].cost`. If you set only one rate (say `per_travel_km`), the other keeps its baseline rather than dropping to zero.

Source: `CostConfig::v3_default()` in `crates/solver-core/src/objective.rs` — it sets `cost_per_distance` (20 cents/km) and `cost_per_duration` (€35/hour) and deliberately leaves `cost_per_vehicle` at `0`. `build_cost_config_v3` (`src/api/convert/request.rs`) seeds from `v3_default()` and only overwrites the axis the caller actually set.

## Vehicle Count Is Free Until You Price It

There is **no default per-vehicle cost** ([ADR-0012](https://github.com/solvice/solvice/blob/main/docs/adr/0012-no-global-vehicle-cost-default.md)). A request that prices nothing per vehicle has zero cost-side bias toward fewer vehicles: the solver will happily open another route for any travel saving at all, however small. The only consolidation you get for free is whatever `per_travel_km`/`per_travel_hour` happen to produce.

That has a direct consequence for `objective.minimize_vehicles`: with nothing to toggle, `true` (the default) and `false` behave identically. `false` is meaningful only as an explicit statement of intent — and as the way to unlock `costs.per_vehicle`, which is rejected alongside `minimize_vehicles: true`.

To make fleet size matter, price it — either fleet-wide or per vehicle:

```json theme={null}
{
  "objective": {
    "minimize_vehicles": false,
    "costs": { "per_vehicle": 150.0, "per_travel_km": 0.20, "per_travel_hour": 35.0, "currency": "EUR" }
  }
}
```

Once a price exists, the trade-off is honest and losable. At €150/vehicle with those travel rates, a split that saves more than **\~750 km** (750 × €0.20 = €150) or **\~4.3 hours** (4.3 × €35 ≈ €150) pays for the extra vehicle; a smaller saving does not, and the solver keeps the consolidated route.

```mermaid theme={null}
graph LR
    subgraph "Small saving: extra vehicle NOT worth it"
        A["1 vehicle: EUR150 fixed + travel<br/>2 vehicles: EUR300 fixed + less travel<br/>Saving < EUR150 -> 1 vehicle wins"]
    end
    subgraph "Big saving: extra vehicle IS worth it"
        B["1 vehicle: EUR150 fixed + long detour<br/>2 vehicles: EUR300 fixed + short routes<br/>Saving > EUR150 -> 2 vehicles win"]
    end

    style A fill:#4a90d9,color:#fff
    style B fill:#27ae60,color:#fff
```

A per-vehicle `cost.fixed` does the same thing for one vehicle and overrides the fleet-wide `per_vehicle` for it — use that when different vans really cost different amounts.

## Droppable Tasks: `drop_fee` and `mandatory`

Every task is must-serve by default. The only way to make one optional is to say what leaving it unserved is worth to you:

* **`drop_fee`** — the price, in the same currency as `objective.costs`, of not serving this task. It enters tier 2, so the task is served exactly while serving it costs less than the fee. Fees the plan chose to pay come back in `estimated_cost.components.drop_fees` and are included in `total`. On a shipment the fee applies to the pair and is charged once.
* **`mandatory`** — `true` (or omitted) means must-serve. `false` is only meaningful together with `drop_fee` and is **rejected with HTTP 400 without it**: an optional task with no price would be one the solver could abandon for free. `mandatory: true` alongside a `drop_fee` is rejected too — a task that cannot drop has no drop price.

```json theme={null}
{
  "jobs": [
    { "id": "job-1", "location": { "coordinate": [3.72, 51.05] } },
    { "id": "job-2", "location": { "coordinate": [3.71, 51.06] }, "drop_fee": 1000.0 }
  ]
}
```

`job-1` is must-serve (no `mandatory` needed). `job-2` may be left unserved for €1000 if routing to it would cost more than that. There is no fleet-wide drop price — pricing is per task.

Source: `JobCommon.mandatory` / `JobCommon.drop_fee` in `src/api/v3_types.rs`.

<Warning>
  The old `unassigned_cost` field — per-job and fleet-wide — was **removed**, not deprecated. A request still sending it gets an `unknown field` rejection. It was a lexicographic drop-*ordering* tier that never traded against travel cost, so no value a caller set there could actually price a drop; `drop_fee` replaces it with real money in tier 2.
</Warning>

## Diagnosing Unassigned Jobs: `unassigned[].reasons`

There is no `unservedReasons` map of bare constraint-code strings. Every job the solver couldn't place comes back in `SolveResponse.unassigned` with a structured, typed explanation:

```json theme={null}
{
  "job_id": "job-9",
  "reasons": [
    {
      "code": "CAPACITY_EXCEEDED",
      "message": "No vehicle has enough remaining capacity to serve this job"
    }
  ],
  "relaxations": [
    {
      "field": "/jobs/job-9/demand",
      "op": "reduce_demand",
      "amount": 5,
      "unit": "load",
      "suggestion": "Reduce this task's demand by 5, or add 5 capacity to an eligible vehicle."
    }
  ]
}
```

`reasons[].code` is a stable machine-readable string from a closed set of twelve (`SOLVER_LIMIT`, `CAPACITY_EXCEEDED`, `TIME_WINDOW_VIOLATED`, `CAPACITY_AND_TIME_WINDOW`, `SKILLS_MISMATCH`, `TAG_RESTRICTED`, `COMMITMENT_BLOCKED`, `VEHICLE_EXCLUDED`, `VEHICLE_RANGE_EXCEEDED`, `UNREACHABLE_LOCATION`, `NO_FEASIBLE_SHIFT`, `ALL_ROUTES_BLOCKED`), each with a human-readable `message`. `relaxations[]` is a best-effort, actionable suggestion for what to change in the request — empty when no numeric fix exists (e.g. a skills mismatch).

Source: `Unassigned`, `UnassignedReason`, and `Relaxation` structs in `src/api/v3_types.rs`. This is the exact same mechanism documented in full — including the two-pass classification algorithm and worked examples — in [Explainable AI](/guides/platform/explainable-ai); this page won't duplicate that detail.

<Tip>
  If `summary.jobs_unassigned` is non-zero, read `unassigned[].reasons` before reaching for `objective.costs`. A job blocked by a hard constraint (capacity, a time window, a skill mismatch) won't be rescued by cost tuning — it needs either a schema-level relaxation (see `relaxations[]`) or to be priced as droppable via `drop_fee`.
</Tip>

## Tuning for Business Goals

### "I want the fewest vehicles possible, and I know roughly what a vehicle costs me"

Set `vehicles[].cost.fixed` to your real daily cost per vehicle, or `objective.costs.per_vehicle` (with `minimize_vehicles: false`) for a fleet-wide figure. The higher this number relative to `per_travel_km`/`per_travel_hour`, the more the solver favors consolidating onto fewer vehicles. Without one of these, there is no vehicle-count pressure at all.

### "I want shortest/cheapest routes, don't care about vehicle count"

Set nothing per-vehicle. That is already the default: with no `costs.per_vehicle` and no `vehicles[].cost.fixed`, the solver freely uses extra vehicles whenever doing so lowers travel cost.

### "I want to minimize driving time cost, not distance cost"

Set `per_travel_km` to `0.0` and keep `per_travel_hour` at your real hourly rate (or vice versa). Useful when driver wages dominate your cost structure more than fuel, or the other way around.

### "I have soft time windows and want to control the lateness penalty"

Set `objective.costs.per_late_hour` to a real hourly cost of lateness (e.g. a customer-goodwill or SLA-penalty figure). `0` or absent keeps the time window hard (a violation is infeasible, not just penalized) — see [Constraint System](/guides/vrp/v3/concepts/constraint-system#timewindow) for how this interacts with the `TimeWindow` constraint itself.

### "Some jobs are nice-to-have, not must-serve"

Give the task a `drop_fee` — the price you'd rather pay than force it onto an expensive route. That alone makes it droppable; `mandatory: false` is optional alongside it and rejected without it. There is no fleet-wide fallback price, so every optional task names its own.

<Tip>
  `objective.costs`, `vehicles[].cost`, and `drop_fee` are the primary tuning knobs for solution quality. Before adjusting operator parameters or the time limit, make sure these reflect your actual business economics — see [Performance Guide](/guides/vrp/v3/concepts/performance-guide) for the levers that affect solve *speed* rather than solution *shape*.
</Tip>

## Implementation Detail: How Euros Become Integers

*This section is for readers who want to know how the solver represents money internally — you do not need it to use the API.*

Internally, the ALNS search never touches floating-point currency values — see ADR-0003 (integer arithmetic in hot paths) and ADR-0009 (the currency-denominated cost model this page describes). Every request is converted at the API boundary into an integer-weighted cost configuration (`CostConfig` in `crates/solver-core/src/objective.rs`):

```
cost = Σ (cost_per_vehicle × has_jobs(v))
     + Σ (cost_per_distance × route_distance(v))
     + Σ (cost_per_duration × route_duration(v))
     + Σ (cost_per_wait × route_wait(v))
     + penalties
```

Every rate on `objective.costs` and `vehicles[].cost` is converted into its internal `cost_per_*` counterpart via a fixed scale factor, `CURRENCY_SCALE = 100_000` (1 internal unit = 1/100,000th of a currency unit — a hundredth of a cent). This resolution is fine enough that realistic per-km/per-hour rates survive rounding without collapsing to zero — and a nonzero rate too small to represent is rejected with HTTP 400 rather than silently rounded to zero. The reported cost in the response is divided back by `CURRENCY_SCALE` so it reads as real currency again. None of this internal machinery — `CostConfig`, `CURRENCY_SCALE`, `v3_default()` — is exposed in the API; it exists purely so the integer-only core solver can search over what is, from the caller's perspective, an ordinary currency-denominated cost.

The solver's full comparison is the two-tier order described at the top of this page: `penalty_sum` (tier 1) → `cost` (tier 2, the weighted sum above).

## V2 Is Unaffected

**Everything on this page describes `/v3/routing/solve` only.** `/v2/vrp/sync/solve` has no `objective.costs` equivalent: a V2 request expresses fleet-size pressure through its own `options.cost_per_vehicle`, which the compatibility layer maps onto the same internal `cost_per_vehicle` (paired with `minimize_vehicles: false`, since an explicit price and count-first minimization are mutually exclusive). V2 request and response shapes are unchanged.

## Related Topics

<CardGroup cols={2}>
  <Card title="Constraint System" icon="cog" href="/guides/vrp/v3/concepts/constraint-system">
    The fixed set of sixteen hard constraints, and the soft mechanisms that feed this objective instead
  </Card>

  <Card title="Explainable AI" icon="lightbulb" href="/guides/platform/explainable-ai">
    The full unassigned-job diagnosis mechanism behind `unassigned[].reasons` and `relaxations`
  </Card>

  <Card title="Performance Guide" icon="gauge" href="/guides/vrp/v3/concepts/performance-guide">
    How ALNS search time (not the objective's cost rates) affects solution quality
  </Card>

  <Card title="Solution Quality" icon="chart-line" href="/guides/vrp/v3/concepts/solution-quality">
    Balancing speed against solution quality in more depth
  </Card>
</CardGroup>
