Skip to main content

VRP Constraint System

The Vehicle Routing Problem solver decides where each job can legally go (constraints) and then which of the legal arrangements is best (the objective). This guide explains both halves for POST /v3/routing/solve: the fixed set of built-in constraint types, which ones are hard versus soft today, and how the two-tier objective ranks feasible solutions once constraints are satisfied.
This solver does not use a Hard/Medium/Soft point-scoring system — there is no three-tier score object with a separate numeric field for each tier, and no per-field weight you tune against an opaque total. It uses two separate mechanisms instead: a fixed list of constraints that make an insertion feasible or infeasible (with exactly one, TimeWindow, also supporting a soft-cost mode), and a two-tier objective that compares feasible solutions using real currency. If you’re looking for point-based constraint weights from another VRP engine’s docs, they don’t apply here — see Objective Function below for what actually ranks solutions.

What Are Constraints?

Constraints determine which insertions of a job into a route are legal. Each one answers a yes/no question (with one exception, described below) about a candidate position in a route: Source: crates/solver-core/src/constraints/mod.rs defines ConstraintType as a closed 16-variant enum and dispatches every check through it with zero runtime overhead (compiler-inlined match arms, no Box<dyn Trait>). A problem only activates the constraint types it actually needs — a plain capacitated VRP with no time windows or skills activates just Capacity.

Hard vs. Soft: There Are Only Two Modes, Not Three

The solver has exactly two constraint modes: There is no “medium” tier, no per-constraint weight object, and no way to make capacity, skills, or any constraint other than time-window lateness soft in the current release. This is a direct code fact, not a simplification for this guide:
Source: crates/solver-core/src/constraints/mod.rs’s InsertionCheck struct (feasible: bool, penalty: i32). The penalty for a soft time-window violation is cost_per_late_minute * max(0, arrival - latest) — strictly proportional to how late the arrival is, not a flat score deduction.
There are no per-window earliness/lateness rates and no soft skill requirements: SkillReq carries only a name, and TimeWindow carries only from, to, and an optional flat cost. That cost does not soften the window — every window stays hard on both sides; it only prices which of several alternative hard windows the task is served in, and requires at least two windows on the task. The only mechanism that makes lateness legal-but-priced is the fleet-wide objective.costs.per_late_hour rate, which softens every window in the request at once; combining it with a nonzero time_windows[].cost anywhere is rejected with HTTP 400. For a per-task soft deadline that never causes infeasibility, use target_arrival ({ "at": ..., "per_late_hour": ... }) instead. Source: TimeWindow, SkillReq, TargetArrival in src/api/v3_types.rs.

The 16 Built-In Constraints

This is the complete, current list — copied from ConstraintType::ALL in crates/solver-core/src/constraints/mod.rs. Only the constraints a given problem actually needs are activated; unused variants cost nothing at runtime.
Every constraint above is reachable from the current V3 request schema. Commitment is activated by locked_vehicle, which hard-pins a job (or a shipment, both legs) to one vehicle. eligible_vehicles.allowed/excluded — on jobs and shipments alike — instead activate VehicleExclusion, a hard allow/exclude list (capped at 64 internal vehicle-shifts; a larger fleet using these lists is rejected with HTTP 400). Sequence, Shipment, and GroupSequence come from relations and shipments[]; ServesTags from a shift’s serves_tags against a task’s tags; TripCapacity/MultiTrip from a shift’s reloads[]. Source: assemble_constraints, src/api/convert/request.rs.

Capacity

Checked as route.total_load + demand(job) <= vehicle.capacity, per named dimension, at every point along the route. For ordinary (non-pickup/delivery) jobs this is an O(1) aggregate check hoisted once per route rather than re-evaluated at every candidate position.
Source: Visit.demand / Vehicle.capacity are both NamedDims — an open map of named dimensions (e.g. weight, volume, count), not a fixed weight-or-volume pair.

TimeWindow

By default this window is hard — an insertion that can’t land inside [from, to] is rejected. To make lateness cost money instead of blocking the solve, set a fleet-wide rate:
Omitting per_late_hour keeps every time window hard. An explicit 0.0 is rejected with HTTP 400 as ambiguous — “free lateness” and “hard windows” read opposite ways, so the schema makes you say which one you mean by omitting the field or giving it a real rate. Source: Costs.per_late_hour in src/api/v3_types.rs and build_cost_config_v3 in src/api/convert/request.rs (cost_per_late_minute = grid_cost(costs.per_late_hour, 60.0, ..., zero_means_set = false)).

Skills

A job’s skills list is a subset test against the assigned vehicle’s skills list: the vehicle must provide every named skill. SkillReq has only a name field — there is no soft-skills cost, so every skill requirement is hard. For an ANY-overlap zone match instead of an ALL-subset capability match, use task tags against a shift’s serves_tags (the ServesTags constraint) or a vehicle’s preferred_tags (soft, priced).

VehicleRange

VehicleLimits has exactly these two fields. max_route_duration_s caps the whole-route span from shift departure to final arrival — travel, service, and wait all count towards it (it was renamed from max_duty_time_s for exactly that reason). Any other key under limits is rejected as an unknown field.

VehicleExclusion (hard allow/exclude lists)

allowed and excluded are mutually exclusive whitelist/blacklist forms of the same hard constraint. There is no soft “preferred vehicle” cost on this field — that is JobCommon.preferences, a separate list of { "vehicle": ..., "violation_cost": ... } entries. Each entry is priced independently: a task pays a listed entry’s violation_cost whenever that entry’s own vehicle does not serve it — even if a different listed vehicle does. A task naming several preferred vehicles and getting none of them pays every entry’s cost, but so does a task that gets one of several: only the matched entry is free, the rest still charge. The charge is a soft money cost, so the hard eligible_vehicles list, locked_vehicle, skills, and time windows all outrank it.

Sequence, Shipment, and GroupSequence (relation-driven)

Sequence is activated by an Ordered relation:
This says job-1 must appear before job-2 on whatever route serves them, with gaps allowed (other jobs may be visited in between). Ordered accepts either job_ids or a groups list (mutually exclusive), and nothing else — there are no interval or soft-cost fields on it. Relations are hard-only. Shipment is activated by a bound pickup→delivery pair, not a relation:
GroupSequence is the group form of the same relation: { "type": "ordered", "groups": ["unload", "install"] } declares that on every route, every task tagged unload precedes every task tagged install. Membership comes from each task’s tags; ordering within one group is unconstrained, and untagged tasks may appear anywhere. Needs at least two group labels, and a label that selects a shipment is rejected with HTTP 400.
Relation has exactly four variants today, and all four are wired: ordered, same_resource, same_day, and synchronized. same_resource and same_day select their members with either job_ids or a single group tag; synchronized takes a tasks[] list plus an optional max_wait_s. The never-wired consecutive/same_route variants and every relation’s soft violation_cost were removed from the schema rather than left as 400s — relations are hard-only, and an unknown relation type is now an unknown-field rejection.

The Objective: Two Tiers, Not a Score

Once a solution is feasible (every inserted job satisfies every active hard constraint), the solver has to decide which of many feasible arrangements is better. That comparison is a strict two-tier lexicographic order — not a weighted sum of hard/medium/soft points:
  1. penalty_sum (tier 1, hard) — the summed drop penalty of the unassigned tasks. Every required task (the default) carries the same large internal penalty, so on a request where nothing is droppable this tier is exactly “number of unassigned tasks” and always outranks cost.
  2. cost (tier 2) — the weighted currency sum of vehicle activation, travel distance, travel time, waiting, overtime, soft lateness, preference violations, and the drop_fee of every priced task the plan chose not to serve. This is what objective.costs and vehicles[].cost tune.
Because a priced drop sits in tier 2 rather than in a tier of its own, drop_fee genuinely trades against travel cost: a task is served exactly while serving it costs less than its fee. Source: crates/solver-core/src/objective.rs (Objective::key).

What you actually configure

There is no per-field weight object anywhere in this model. The three request-level knobs are:
  • objective.minimize_vehicles — a bool (default true), not an ordered token list; it replaced the old objective.priorities array, which could never actually be reordered. Since there is no default per-vehicle cost for it to toggle, it only has an effect in combination with a priced fleet — see Objective Function.
  • objective.costs — fleet-wide rates: per_travel_km, per_travel_hour, per_late_hour, per_wait_hour, per_overtime_hour, per_vehicle, plus an informational currency label.
  • vehicles[].cost — per-vehicle overrides: fixed, 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 is a distance-rate override and combines freely with whichever one you picked (map_vehicle_cost, src/api/convert_v3/vehicles.rs).
If you omit objective.costs entirely, V3 seeds a realistic travel baseline — €0.20/km and €35/hour — and nothing else. In particular there is no default per-vehicle cost: the fleet-wide per_vehicle rate stays at 0 until you set it (ADR-0012), so a request that prices nothing has no cost-side pressure to use fewer vehicles at all. See Objective Function for the full defaulting behavior, the vehicle-count tradeoff, and how drop_fee interacts with mandatory.
objective.costs and vehicles[].cost are the primary levers for solution shape. There is nothing to “weight” beyond them — if you’re trying to replicate a hard/medium/soft weight table from another VRP engine’s config, translate each entry into either a hard constraint (it must always hold — use the matching ConstraintType above), a per-task drop_fee (leave this task unserved for a fixed price if serving it costs more), or a fleet-wide objective.costs rate. per_late_hour, per_wait_hour, and per_overtime_hour are all live per-unit soft rates today; per-task soft levers are drop_fee, preferences[].violation_cost, target_arrival.per_late_hour, and preferred_date (priced by objective.non_preferred_date_cost).

Constraint Evaluation Order

assemble_constraints (src/api/convert/request.rs) pushes only the constraint types a given problem actually needs onto Problem::constraints, in a fixed order, capacity first: check_all_constraints short-circuits on the first infeasible constraint, so capacity — the cheapest and most frequent rejection reason — is checked first and the rest are skipped once a rejection is found. A plain CVRP with no time windows, skills, or relations evaluates exactly one constraint per candidate position. Note that a multi-trip problem replaces Capacity with TripCapacity rather than adding to it.

Debugging: Why Didn’t My Job Get Placed?

There is no separate “explain” endpoint or opt-in flag to enable diagnostics. Every POST /v3/routing/solve response that leaves at least one job unassigned already carries a structured reason for each one, in SolveResponse.unassigned:
The code values map directly onto the constraint that blocked every candidate vehicle: CAPACITY_EXCEEDED (Capacity/TripCapacity), TIME_WINDOW_VIOLATED (TimeWindow), SKILLS_MISMATCH (Skills), TAG_RESTRICTED (ServesTags), COMMITMENT_BLOCKED (Commitment), VEHICLE_EXCLUDED (VehicleExclusion), VEHICLE_RANGE_EXCEEDED (VehicleRange), UNREACHABLE_LOCATION (Reachable), plus rolled-up outcomes (CAPACITY_AND_TIME_WINDOW, NO_FEASIBLE_SHIFT, ALL_ROUTES_BLOCKED) and SOLVER_LIMIT for the rare case where a feasible slot existed but the time budget ran out first. When the blocker is capacity or a time window, relaxations also names the exact field and the smallest change (in load units or seconds) that would have let the job fit somewhere. This diagnosis inspects the one solution the solver already produced — it does not re-solve or explore alternative orderings, and it only fires for jobs that ended up unassigned. Jobs that were successfully routed carry no explanation payload. See Explainable AI for the full mechanism, response shape, and worked examples for each code.
1

Check the unassigned array first

If SolveResponse.unassigned is non-empty, each entry already has reasons and relaxations — no follow-up request needed.
2

Branch on the code

Use reasons[].code to distinguish a capacity problem from a time-window problem programmatically; use reasons[].message for anything shown to a person.
3

Apply the relaxation or escalate

When relaxations is non-empty, either apply the suggested change and re-solve, or surface it to whoever is planning the route. An empty relaxations array (e.g. for SKILLS_MISMATCH) means the fix is structural — no single number closes the gap.

Performance Impact

Constraint cost varies by how much work each check does per candidate position: O(1), cheapCapacity, Skills, VehicleExclusion, TaskLimit, and TimeWindow (via precomputed Savelsbergh forward-slack arrays) are all constant-time per check. O(n), scans the routeSequence and Shipment may walk route.nodes to verify chain or pickup-before-delivery ordering. These are only pushed onto the active constraint list when a problem actually uses relations or shipments, so a problem without them never pays this cost. Hoisted, not per-position — Because repair scans every position of a route for each unassigned job, the constraint set is split into position-independent and position-dependent halves. Skills, ServesTags, Commitment, VehicleExclusion, TaskLimit, and Capacity on monotonic-load problems are checked once per route rather than once per candidate position. The remaining position-dependent checks are additionally ordered cheapest-first, so the O(n) driver-break check runs last.
The active constraint list only ever contains what a problem actually needs — the solver doesn’t pay for constraint types your request doesn’t use. Adding time windows, skills, or relations you don’t need adds real per-position or per-route checking cost; leaving them out keeps the fast path fast. See Performance Guide for the broader set of levers (matrix size, time limits, vehicle count) beyond just constraint choice.

Best Practices

Constraint design guidelines:
  1. Reach for a hard constraint first. If a rule must always hold (a van can never exceed its capacity, a job can never go to a vehicle without the right skill), express it as a hard constraint field — capacity, skills, eligible_vehicles, limits — not as a heavily-weighted cost.
  2. Use per_late_hour deliberately, fleet-wide. It is currently the only working soft mechanism, and it applies to every time window in the request at once. If you need some jobs’ windows hard and others soft, that per-job distinction isn’t available yet — plan around the fleet-wide behavior.
  3. Use drop_fee for droppable jobs, not a workaround built out of a very loose time window or a fake low-priority vehicle. A drop_fee is the only way to make a task optional, and mandatory: false without one is rejected with HTTP 400.
  4. Check for HTTP 400 before assuming a feature works. The schema uses strict field validation, so a field that was cut (or that you misspelled) is an immediate unknown-field rejection rather than a silent no-op. A handful of live fields also 400 on contradictory combinations — per_late_hour: 0.0, mandatory: false without drop_fee, minimize_vehicles: true with costs.per_vehicle, two billing terms on one vehicle — so read the message; it names the field.
  5. Read the unassigned array before adjusting cost rates. If jobs are dropping, the reasons/relaxations payload usually tells you exactly which hard constraint is blocking them and by how much — cheaper than guessing at cost tuning.

Objective Function

Full defaulting behavior, the vehicle-count tradeoff, and how currency becomes an integer internally

Explainable AI

The complete unassigned-job diagnosis mechanism, response shape, and codes

Performance Guide

How constraint choice and problem size affect solve time

Distance Matrices

How travel time and distance feed the VehicleRange constraint and the cost objective