Skip to main content

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.
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 for how constraints feed into this, and Explainable AI for the full unassigned-job diagnosis mechanism.

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

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 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:
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.
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.
The response’s summary.estimated_cost (an EstimatedCost object) reports back real currency, not an opaque integer:
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.
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.

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

If You Set Nothing: The V3 Defaults

If a request omits objective.costs entirely, V3 seeds a realistic travel baseline — and only travel: 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). 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:
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. 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.
  • mandatorytrue (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.
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.
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.

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:
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; this page won’t duplicate that detail.
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.

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 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.
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 for the levers that affect solve speed rather than solution shape.

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):
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.

Constraint System

The fixed set of sixteen hard constraints, and the soft mechanisms that feed this objective instead

Explainable AI

The full unassigned-job diagnosis mechanism behind unassigned[].reasons and relaxations

Performance Guide

How ALNS search time (not the objective’s cost rates) affects solution quality

Solution Quality

Balancing speed against solution quality in more depth