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

# Constraints Reference

> Every constraint the VRP solver enforces, grouped by category — what each one means, the request fields that drive it, its hard/medium/soft level, and how it's reported.

This page is the exhaustive, field-by-field reference for every constraint the VRP solver evaluates. For the conceptual model — why hard/medium/soft levels exist, how weights interact, and how to debug conflicts — read the [VRP Constraint System](/guides/vrp/concepts/constraint-system) guide first. This page assumes that background and goes straight to "which field configures which constraint."

<Note>
  Every request field referenced below belongs to the [`OnRouteRequest`](/guides/vrp/schemas/request) schema — `resources[]` ([`Resource`](/guides/vrp/schemas/request#resource)), `jobs[]` ([`Job`](/guides/vrp/schemas/request#job)), `relations[]` ([`Relation`](/guides/vrp/schemas/request#relation)), `depots[]`, `options`, `weights`, and `costs`. Field paths below are written relative to the object they belong to, e.g. `windows[].hard` means `job.windows[].hard`.
</Note>

## Levels and how violations are reported

Every constraint is one of three levels:

* **hard** — a plan that violates it is infeasible. The solver leaves the offending job unassigned (with `options.partialPlanning`, the default) rather than place it illegally.
* **medium** — priced independently from hard and soft score components; used for a handful of "should hold, but a plan isn't automatically invalid if it doesn't" rules (min-side period rules, same-time synchronization).
* **soft** — priced into the objective. Always somewhat "violated" — the goal is minimizing the penalty, not eliminating it.

When you call the explanation endpoint or read `JobAssignment.violations`/`scoreExplanation`, each violated constraint is named using the `OnrouteConstraint` enum — that's the code shown in the **Reported as** column of every table below. A handful of relation-based constraints (noted inline) share a reporting bucket with a sibling constraint rather than having their own enum value.

## Weights vs. costs

Soft constraints are tunable two ways — pick one per request, don't mix mental models:

* **`weights`** — unitless relative importance (e.g. `travelTimeWeight: 1`, `minimizeResourcesWeight: 3600`). This is the original system; all weight fields default to small integers and are compared against each other, not against real money.
* **`costs`** — monetary units (EUR or your currency, treated as unitless by the solver). When `costs` is present, it's converted internally to the equivalent weights and **takes precedence over `weights`** for the fields it covers.

| `costs` field                 | Feeds                                                        | Default          |
| ----------------------------- | ------------------------------------------------------------ | ---------------- |
| `drivingCostPerHour`          | Travel time (`weights.travelTimeWeight` equivalent)          | 25.0             |
| `waitingCostPerHour`          | Wait time (`weights.waitTimeWeight` equivalent)              | 0.0 (disabled)   |
| `distanceCostPerKm`           | Distance component of travel-time scoring                    | 0.0 (disabled)   |
| `overtimeCostPerHour`         | Overtime portion of shift-end scoring                        | 50.0             |
| `priorityCostPerPointPerHour` | Urgency-driven scheduling order                              | 5.0              |
| `rankingViolationCostPerRank` | Ranking preference (`weights.rankingWeight` equivalent)      | 5.0              |
| `workloadImbalanceCostFactor` | Fair-work spread (`weights.workloadSpreadWeight` equivalent) | 0.1              |
| `outOfRegionCostPerHour`      | Region preference                                            | 10.0             |
| `depotCost` / `depotEndCost`  | Depot cost / end-depot                                       | internal default |

<Info>
  `costs.softSkillViolationCost`, `costs.timeWindowViolationCostPerHour`, and `costs.preferredResourceViolationCost` are accepted by the schema but currently have no effect on live solving — soft tag and soft time-window penalties always use each item's own `weight` field regardless. Use the per-item `weight` fields documented below instead.
</Info>

A number of hard/medium constraints below (period rules, overtime, region preference) don't expose a dedicated `weights` field at all — the solver applies a fixed internal weight and the constraint isn't currently tunable except through `costs` where noted.

## Overview

| Constraint                                                  | Level                            | Driven by                                                       | Reported as                            |
| ----------------------------------------------------------- | -------------------------------- | --------------------------------------------------------------- | -------------------------------------- |
| [Hard time window](#time-windows)                           | hard                             | `job.windows[].hard: true` (default)                            | `DATE_TIME_WINDOW_CONFLICT`            |
| [Soft time window](#time-windows)                           | soft                             | `job.windows[].hard: false`, `.weight`                          | `DATE_TIME_WINDOW_CONFLICT_SOFT`       |
| [Planned arrival](#planned-commitments)                     | soft                             | `job.plannedArrival`, `weights.plannedWeight`                   | `PLANNED_ARRIVAL`                      |
| [Planned date](#planned-commitments)                        | hard                             | `job.plannedDate`                                               | `PLANNED_DATE`                         |
| [Wait time](#wait-time-and-urgency)                         | soft                             | `weights.waitTimeWeight`                                        | `WAIT_TIME`                            |
| [Job urgency](#wait-time-and-urgency)                       | soft                             | `job.urgency`, `weights.urgencyWeight`                          | `URGENCY`                              |
| [Shift end](#shift-hours-and-overtime)                      | hard, or soft with `overtimeEnd` | `shift.to`, `shift.overtimeEnd`                                 | `SHIFT_END_CONFLICT`                   |
| [Overtime end](#shift-hours-and-overtime)                   | hard                             | `shift.overtimeEnd`                                             | `OVERTIME_END_CONFLICT`                |
| [Hourly cost](#shift-hours-and-overtime)                    | soft (objective)                 | `resource.hourlyCost`                                           | `HOURLY_COST`                          |
| [Max drive time](#drive-time-distance-and-travel)           | hard                             | `resource.maxDriveTimeInSeconds`                                | `MAX_DRIVE_TIME`                       |
| [Max drive distance](#drive-time-distance-and-travel)       | hard                             | `resource.maxDriveDistance`                                     | `MAX_DRIVE_DISTANCE`                   |
| [Max drive time per leg](#drive-time-distance-and-travel)   | hard                             | `resource.maxDriveTimeJob`                                      | `MAX_DRIVE_TIME_JOB`                   |
| [Travel time](#drive-time-distance-and-travel)              | soft                             | `weights.travelTimeWeight` / `costs.drivingCostPerHour`         | `TRAVEL_TIME`                          |
| [End-location travel time](#drive-time-distance-and-travel) | soft                             | same as travel time                                             | `END_LOCATION_TRAVEL_TIME`             |
| [Drive time (minimize)](#drive-time-distance-and-travel)    | soft                             | `weights.driveTimeWeight` (default 0)                           | `DRIVE_TIME`                           |
| [Region preference](#drive-time-distance-and-travel)        | soft                             | `resource.region` / `costs.outOfRegionCostPerHour`              | `REGION_TIME`                          |
| [Trip capacity](#capacity-and-load-compatibility)           | hard                             | `resource.capacity`, `job.load`                                 | `TRIP_CAPACITY`                        |
| [Load compatibility](#capacity-and-load-compatibility)      | hard                             | `resource.loadCompatibility`                                    | `LOAD_COMPATIBILITY`                   |
| [Hard skill tag](#skills-rankings-and-eligibility)          | hard                             | `job.tags[].hard: true` (default)                               | `TAG_HARD`                             |
| [Soft skill tag](#skills-rankings-and-eligibility)          | soft                             | `job.tags[].hard: false`, `.weight`                             | `TAG_SOFT`                             |
| [Ranking preference](#skills-rankings-and-eligibility)      | soft                             | `job.rankings`, `weights.rankingWeight`                         | `RANKING_SOFT`                         |
| [Allowed resources](#skills-rankings-and-eligibility)       | hard                             | `job.allowedResources` (deprecated)                             | `ALLOWED_RESOURCES`                    |
| [Disallowed resources](#skills-rankings-and-eligibility)    | hard                             | `job.disallowedResources`                                       | `DISALLOWED_RESOURCES`                 |
| [Planned resource](#skills-rankings-and-eligibility)        | hard                             | `job.plannedResource`                                           | `PLANNED_RESOURCE`                     |
| [Resource compatibility](#skills-rankings-and-eligibility)  | hard                             | `relation.enforceCompatibility`, `resource.compatibleResources` | `RESOURCE_COMPATIBILITY`               |
| [Same trip](#job-relations)                                 | hard                             | `relation.type: SAME_TRIP`                                      | `SAME_TRIP`                            |
| [Same resource](#job-relations)                             | hard                             | `relation.type: SAME_RESOURCE`                                  | `SAME_RESOURCE`                        |
| [Same day](#job-relations)                                  | hard                             | `relation.type: SAME_DAY`                                       | reported under `SAME_TRIP`             |
| [Sequence](#job-relations)                                  | hard + soft                      | `relation.type: SEQUENCE`                                       | `SEQUENCE`                             |
| [First job](#job-relations)                                 | hard                             | single-job `SEQUENCE` relation                                  | reported under `SEQUENCE`              |
| [Direct sequence](#job-relations)                           | hard                             | `relation.type: DIRECT_SEQUENCE`                                | `DIRECT_SEQUENCE`                      |
| [Neighbor](#job-relations)                                  | hard                             | `relation.type: NEIGHBOR`                                       | `NEIGHBOR`                             |
| [Same time](#job-relations)                                 | hard + medium                    | `relation.type: SAME_TIME`                                      | `SAME_TIME`                            |
| [Group sequence](#job-relations)                            | hard                             | `relation.type: GROUP_SEQUENCE`                                 | `GROUP_SEQUENCE`                       |
| [Delivery on same vehicle](#pickup-and-delivery)            | hard                             | `relation.type: PICKUP_AND_DELIVERY`                            | `DELIVERY_NOT_ON_SAME_VEHICLE`         |
| [Delivery after pickup](#pickup-and-delivery)               | hard                             | same relation                                                   | `DELIVERY_BEFORE_PICKUP`               |
| [Depot cost](#depots)                                       | soft                             | `depot.cost` / `costs.depotCost`                                | `DEPOT_COST`                           |
| [Depot capacity](#depots)                                   | hard                             | `depot.capacity`                                                | `DEPOT_CAPACITY`                       |
| [Depot time window](#depots)                                | hard                             | `depot.windows`                                                 | `DEPOT_TIME_WINDOW`                    |
| [End depot](#depots)                                        | hard                             | `shift` unload-before-end behavior                              | `END_DEPOT`                            |
| [Max/min service time](#period-rules)                       | hard / medium                    | `rule.maxServiceTime` / `.minServiceTime`                       | `RESOURCE_PERIOD_MAX/MIN_SERVICE_TIME` |
| [Max/min drive time](#period-rules)                         | hard / medium                    | `rule.maxDriveTime` / `.minDriveTime`                           | `RESOURCE_PERIOD_MAX/MIN_DRIVE_TIME`   |
| [Max/min work time](#period-rules)                          | hard / medium                    | `rule.maxWorkTime` / `.minWorkTime`                             | `RESOURCE_PERIOD_MAX/MIN_WORK_TIME`    |
| [Max/min job complexity](#period-rules)                     | hard / medium                    | `rule.maxJobComplexity` / `.minJobComplexity`                   | `RESOURCE_PERIOD_MAX/MIN_COMPLEXITY`   |
| [Job type capacity](#period-rules)                          | hard                             | `shift.jobTypeLimitations` / `rule.jobTypeLimitations`          | `JOBTYPE_VIOLATION`                    |
| [Fair work per trip](#fairness-and-vehicle-usage)           | soft                             | `options.fairWorkloadPerTrip`, `weights.workloadSpreadWeight`   | `FAIR_WORK`                            |
| [Fair work per resource](#fairness-and-vehicle-usage)       | soft                             | `options.fairWorkloadPerResource`                               | `FAIR_TOTAL_WORK`                      |
| [Fair complexity per trip](#fairness-and-vehicle-usage)     | soft                             | `options.fairComplexityPerTrip`, `job.complexity`               | `FAIR_COMPLEXITY_PER_TRIP`             |
| [Fair complexity per resource](#fairness-and-vehicle-usage) | soft                             | `options.fairComplexityPerResource`                             | `FAIR_COMPLEXITY_PER_RESOURCE`         |
| [Minimize resources used](#fairness-and-vehicle-usage)      | soft                             | `options.minimizeResources`, `weights.minimizeResourcesWeight`  | `RESOURCE_USAGE`                       |
| [Unserved jobs](#fairness-and-vehicle-usage)                | medium                           | `job.priority`, `weights.priorityWeight`                        | `UNSERVED_JOBS`                        |
| [Job day index (ASAP)](#fairness-and-vehicle-usage)         | soft                             | `weights.asapWeight`                                            | `JOB_DAY_INDEX`                        |
| [Hard job inclusion](#fairness-and-vehicle-usage)           | hard                             | `job.hard`, `.hardWeight`                                       | `HARD_JOBS`                            |
| [Job proximity](#geographic-optimization)                   | soft                             | `options.jobProximityRadius`, `weights.jobProximityWeight`      | `JOB_PROXIMITY`                        |
| [Route clustering](#geographic-optimization)                | soft                             | `options.enableClustering`, `weights.clusteringWeight`          | not in explanation breakdown           |

## Time windows

A job can carry one or more time windows. Multiple windows mean "any of these is acceptable."

```json theme={null}
{
  "name": "Job-1",
  "windows": [
    { "from": "2024-03-15T09:00:00Z", "to": "2024-03-15T12:00:00Z", "hard": true }
  ]
}
```

<ParamField path="windows[].from" type="string (ISO 8601)" required>
  Start of the window.
</ParamField>

<ParamField path="windows[].to" type="string (ISO 8601)" required>
  End of the window.
</ParamField>

<ParamField path="windows[].hard" type="boolean" default="true">
  When `true`, arriving outside every window makes the job unassignable (`DATE_TIME_WINDOW_CONFLICT`, hard). When `false`, arriving outside a window is allowed but penalized (`DATE_TIME_WINDOW_CONFLICT_SOFT`, soft).
</ParamField>

<ParamField path="windows[].weight" type="integer" default="1">
  Penalty weight for the soft path only — ignored when `hard: true`.
</ParamField>

### Planned commitments

Two fields pin a job to a pre-committed schedule — one hard, one soft:

<ParamField path="job.plannedDate" type="string (date)">
  Fixes the job to a specific calendar date. **Hard** — the solver must honor it (`PLANNED_DATE`).
</ParamField>

<ParamField path="job.plannedArrival" type="string (ISO 8601)">
  Target arrival time. **Soft** — deviations are penalized proportionally to `weights.plannedWeight`, not forbidden (`PLANNED_ARRIVAL`).
</ParamField>

<Tip>
  Use `plannedDate` for commitments that must never move (e.g. already confirmed with a customer) and `plannedArrival` for a preferred time the solver should try to keep but can adjust if it substantially improves the rest of the route.
</Tip>

### Wait time and urgency

<ParamField path="weights.waitTimeWeight" type="integer" default="0 (disabled)">
  Penalizes idle time between arrival and a window opening, summed across every resource (`WAIT_TIME`, soft). Disabled unless set.
</ParamField>

<ParamField path="job.urgency" type="integer">
  Higher values push the job earlier in the day and earlier in the planning period. Affects execution order, not whether the job is selected (`URGENCY`, soft, scaled by `weights.urgencyWeight`).
</ParamField>

<Note>
  `job.padding` (seconds added before/after service), `job.durationSquash` (reduced duration for a job immediately following another at the same location), and `job.proficiency[].durationModifier` (per-resource duration multiplier) aren't constraints themselves, but they change what arrival times and windows are scored against.
</Note>

## Shift hours and overtime

A resource works only within its declared shift windows.

```json theme={null}
{
  "shifts": [
    {
      "from": "2023-01-13T08:00:00Z",
      "to": "2023-01-13T17:00:00Z",
      "overtimeEnd": "2023-01-13T19:00:00Z"
    }
  ],
  "hourlyCost": 25
}
```

<ParamField path="shift.to" type="string (ISO 8601)" required>
  End of the shift. Without `overtimeEnd`, finishing later is a **hard** violation (`SHIFT_END_CONFLICT`).
</ParamField>

<ParamField path="shift.overtimeEnd" type="string (ISO 8601)">
  When set (and later than `shift.from`), finishing after `to` but before `overtimeEnd` becomes a **soft** penalty instead of infeasible, scaled via `costs.overtimeCostPerHour` (no direct `weights` field is currently exposed for this). Finishing after `overtimeEnd` itself is always **hard** (`OVERTIME_END_CONFLICT`), regardless of the cost/weight configuration.
</ParamField>

<ParamField path="resource.hourlyCost" type="integer">
  Cost per hour of active (driving, servicing, or waiting) time. Not a violation — an always-on cost-minimization objective (`HOURLY_COST`, soft).
</ParamField>

<ParamField path="shift.breaks" type="array">
  Windowed (`WINDOWED`), duty/drive-triggered (`DRIVE`), and unavailability (`UNAVAILABILITY`) breaks. Breaks aren't scored directly — they shift the arrival-time shadow variable, which the time-window and shift-end constraints above then score. Set `job.resumable: true` to let a job be split by an unavailability break instead of blocked by it.
</ParamField>

## Drive time, distance, and travel

Per-resource caps on driving, plus the always-on travel-time objective.

```json theme={null}
{
  "maxDriveTimeInSeconds": 28800,
  "maxDriveTimeJob": 7200,
  "maxDriveDistance": 200000,
  "region": { "latitude": 51.05, "longitude": 3.72 }
}
```

<ParamField path="resource.maxDriveTimeInSeconds" type="integer">
  Cumulative driving time cap per shift/trip (excludes service time). **Hard** (`MAX_DRIVE_TIME`).
</ParamField>

<ParamField path="resource.maxDriveDistance" type="integer">
  Cumulative distance cap per shift/trip, meters. **Hard** (`MAX_DRIVE_DISTANCE`).
</ParamField>

<ParamField path="resource.maxDriveTimeJob" type="integer">
  Cap on a single leg's travel time (from the previous stop, or the final leg back to the shift end). Prevents excessive backtracking within one hop, distinct from the cumulative cap above. **Hard** (`MAX_DRIVE_TIME_JOB`).
</ParamField>

<ParamField path="weights.travelTimeWeight" type="integer" default="1">
  Baseline objective weight for total travel time. Also covers the return leg to the shift's end location. Equivalent `costs.drivingCostPerHour` (default 25.0). **Soft** (`TRAVEL_TIME` / `END_LOCATION_TRAVEL_TIME`).
</ParamField>

<ParamField path="weights.driveTimeWeight" type="integer" default="0 (disabled)">
  Separate objective for minimizing total driving time specifically (as opposed to travel time generally). **Soft** (`DRIVE_TIME`).
</ParamField>

<ParamField path="resource.region" type="object (Location)">
  A preferred operating location for the resource. The solver tries to keep the resource's jobs close to it. No dedicated `weights` field is currently exposed — tune via `costs.outOfRegionCostPerHour` (default 10.0 EUR/hour outside the region). **Soft** (`REGION_TIME`).
</ParamField>

## Capacity and load compatibility

Every resource has a multi-dimensional capacity (up to 5 dimensions); every job a load.

```json theme={null}
{
  "resources": [{ "name": "vehicle-1", "capacity": [500, 200] }],
  "jobs": [{ "name": "Job-1", "load": [50, 10] }]
}
```

<ParamField path="resource.capacity" type="array of integers" maxItems="5">
  Multi-dimensional capacity, e.g. `[500, 200]` for weight and volume. **Hard** (`TRIP_CAPACITY`).
</ParamField>

<ParamField path="job.load" type="array of integers">
  Load consumed on each capacity dimension. Must match the dimension count of any resource it could be assigned to.
</ParamField>

<ParamField path="resource.loadCompatibility" type="array of arrays of integers">
  Matrix restricting which load dimensions may coexist non-zero on the vehicle at once: `loadCompatibility[i][j]` is the effective max capacity for dimension `j` when dimension `i` currently carries load. Must match `capacity`'s dimension count. Omit for no restriction. **Hard** (`LOAD_COMPATIBILITY`).
</ParamField>

<Info>
  For pickup-and-delivery requests, load is tracked cumulatively as the vehicle picks up and drops off along the route rather than accumulated per-trip — see [Pickup and delivery](#pickup-and-delivery).
</Info>

## Skills, rankings, and eligibility

Four independent mechanisms answer "which resource may or should serve this job," from strict (hard tags, allow/deny lists) to soft (rankings).

```json theme={null}
{
  "job": {
    "tags": [{ "name": "electrical", "hard": true }, { "name": "certified", "hard": false, "weight": 300 }],
    "rankings": [{ "name": "senior-technician", "ranking": 5 }],
    "disallowedResources": ["vehicle-3"]
  },
  "resource": { "tags": ["electrical", "certified"] }
}
```

<ParamField path="job.tags[].name" type="string" required>
  Skill/capability name matched against `resource.tags` (or the active `shift.tags`, which override the resource's tags for that shift).
</ParamField>

<ParamField path="job.tags[].hard" type="boolean" default="true">
  `true`: only resources with the tag may take the job (`TAG_HARD`, hard). `false`: preferred but not required, penalized by `.weight` if violated (`TAG_SOFT`, soft).
</ParamField>

<ParamField path="job.tags[].weight" type="integer">
  Penalty for the soft path, in travel-time-equivalent units.
</ParamField>

<ParamField path="job.rankings[].name" type="string" required>
  A resource name this job prefers.
</ParamField>

<ParamField path="job.rankings[].ranking" type="integer" default="1-100">
  Lower is more preferred (1 = best). Unranked resources are treated as rank 100. **Soft** (`RANKING_SOFT`), scaled by `weights.rankingWeight` / `costs.rankingViolationCostPerRank`.
</ParamField>

<ParamField path="job.allowedResources" type="array of strings">
  <Warning>Deprecated.</Warning> Whitelist of resource names. **Hard** (`ALLOWED_RESOURCES`), scaled by `weights.allowedResourcesWeight`.
</ParamField>

<ParamField path="job.disallowedResources" type="array of strings">
  Blacklist of resource names. **Hard** (`DISALLOWED_RESOURCES`), same weight field as above.
</ParamField>

<ParamField path="job.plannedResource" type="string">
  Pins the job to exactly this resource — used for warm-starting or committed assignments. **Hard** (`PLANNED_RESOURCE`).
</ParamField>

<ParamField path="resource.compatibleResources" type="array of strings">
  Other resource names this resource may cooperate with on linked jobs. Only enforced when a `SAME_TIME` relation sets `enforceCompatibility: true`. **Hard** (`RESOURCE_COMPATIBILITY`).
</ParamField>

## Job relations

Inter-job rules live in the top-level `relations[]` array, each referencing job names (or, for `GROUP_SEQUENCE`, tags).

```json theme={null}
{
  "relations": [
    { "type": "SEQUENCE", "jobs": ["Job-1", "Job-2"], "minTimeInterval": 0, "maxTimeInterval": 3600 },
    { "type": "SAME_RESOURCE", "jobs": ["Job-3", "Job-4"] }
  ]
}
```

<ParamField path="relation.type" type="enum" required>
  One of `SAME_TRIP`, `SEQUENCE`, `DIRECT_SEQUENCE`, `NEIGHBOR`, `SAME_TIME`, `PICKUP_AND_DELIVERY`, `SAME_RESOURCE`, `SAME_DAY`, `GROUP_SEQUENCE` — see the table below.
</ParamField>

<ParamField path="relation.jobs" type="array of strings" required>
  Job names in the relation. Order matters for `SEQUENCE`/`DIRECT_SEQUENCE`. Ignored for `GROUP_SEQUENCE` (membership comes from `relation.tags` matching `job.tags` instead).
</ParamField>

<ParamField path="relation.resource" type="string">
  Optional — pins every job in the relation to one named resource.
</ParamField>

<ParamField path="relation.partialPlanning" type="boolean" default="false">
  When `true`, allows some (not necessarily all) member jobs to be scheduled if the full relation can't be satisfied.
</ParamField>

| Type                  | Meaning                                                                                  | Level                           | Reported as                                     |
| --------------------- | ---------------------------------------------------------------------------------------- | ------------------------------- | ----------------------------------------------- |
| `SAME_TRIP`           | All jobs on the same vehicle, same day/shift                                             | hard                            | `SAME_TRIP`                                     |
| `SAME_RESOURCE`       | All jobs on the same vehicle, any day                                                    | hard                            | `SAME_RESOURCE`                                 |
| `SAME_DAY`            | All jobs on the same calendar day                                                        | hard                            | reported under `SAME_TRIP`                      |
| `SEQUENCE`            | Jobs executed in the listed order, with optional `minTimeInterval`/`maxTimeInterval` gap | hard (order) + soft (gap)       | `SEQUENCE`                                      |
| `DIRECT_SEQUENCE`     | Jobs must be immediately consecutive — no other job in between                           | hard                            | `DIRECT_SEQUENCE`                               |
| `NEIGHBOR`            | Jobs must be each other's route-neighbor, order-independent                              | hard                            | `NEIGHBOR`                                      |
| `SAME_TIME`           | Jobs scheduled and, within `maxWaitingTime`, synchronized                                | hard (presence) + medium (sync) | `SAME_TIME`                                     |
| `GROUP_SEQUENCE`      | Tag-matched job groups executed group-by-group in order                                  | hard                            | `GROUP_SEQUENCE`                                |
| `PICKUP_AND_DELIVERY` | First job is pickup, second is delivery                                                  | hard                            | see [Pickup and delivery](#pickup-and-delivery) |

<ParamField path="relation.minTimeInterval" type="integer">
  Minimum seconds between consecutive jobs. `SEQUENCE`, `DIRECT_SEQUENCE`, and `SAME_TIME` only. Hard by default; set `relation.hardMinWait: false` to make it soft instead.
</ParamField>

<ParamField path="relation.maxTimeInterval" type="integer">
  Maximum seconds between consecutive jobs. Same relation types as above.
</ParamField>

<ParamField path="relation.maxWaitingTime" type="integer" default="1200">
  For `SAME_TIME`: how far apart member arrivals may be and still count as synchronized.
</ParamField>

<ParamField path="relation.timeInterval" type="enum" default="FROM_ARRIVAL">
  `FROM_ARRIVAL` measures the gap from service start to service start; `FROM_DEPARTURE` from service end to service start.
</ParamField>

<ParamField path="relation.tags" type="array of strings">
  `GROUP_SEQUENCE` only — ordered tag names; jobs carrying each tag form one group.
</ParamField>

<ParamField path="relation.enforceCompatibility" type="boolean">
  `SAME_TIME` only — requires the resources ultimately assigned to member jobs to be mutually listed in each other's `compatibleResources`. See [Resource compatibility](#skills-rankings-and-eligibility).
</ParamField>

<ParamField path="relation.weight" type="integer">
  Weight modifier for this specific relation instance, independent of other relations of the same type.
</ParamField>

<Tip>
  A `SEQUENCE` relation with exactly **one** job is automatically treated as a **First job** constraint: that job must be the very first stop on its trip. It shares the `SEQUENCE` reporting bucket rather than getting its own name.
</Tip>

## Pickup and delivery

Setting any relation to `PICKUP_AND_DELIVERY` switches the two named jobs into a pickup → delivery pair (adding pickup/delivery legs instead of a single visit) and enables cumulative load tracking for the whole request.

```json theme={null}
{
  "relations": [
    { "type": "PICKUP_AND_DELIVERY", "jobs": ["pickup-1", "delivery-1"] }
  ]
}
```

Two hard constraints are added automatically — there's no separate field to configure them beyond declaring the relation:

* **Delivery on same vehicle** (`DELIVERY_NOT_ON_SAME_VEHICLE`) — the pickup and its paired delivery must be on the same vehicle.
* **Delivery after pickup** (`DELIVERY_BEFORE_PICKUP`) — the pickup's arrival must precede the delivery's arrival.

<Info>
  Once any `PICKUP_AND_DELIVERY` relation is present, [trip capacity](#capacity-and-load-compatibility) and [load compatibility](#capacity-and-load-compatibility) switch from a flat per-trip sum to cumulative tracking — load increases at each pickup and decreases at each delivery, so capacity can never be exceeded at any point along the route, not just in total.
</Info>

## Depots

Depots are optional intermediate stops (disposal sites, resupply points) resources can be routed through mid-route.

```json theme={null}
{
  "depots": [
    {
      "name": "Depot-1",
      "location": { "latitude": 51.0543, "longitude": 3.7174 },
      "windows": [{ "from": "2023-01-13T08:00:00Z", "to": "2023-01-13T17:00:00Z" }],
      "cost": 10.0,
      "capacity": [100]
    }
  ]
}
```

<ParamField path="depot.cost" type="number">
  Per-visit cost, used to prefer cheaper depots when a detour is optional. **Soft** (`DEPOT_COST`), also settable via `costs.depotCost`.
</ParamField>

<ParamField path="depot.capacity" type="array of integers" maxItems="5">
  Multi-dimensional cap on cumulative load routed through this depot across every job that selects it. **Hard** (`DEPOT_CAPACITY`).
</ParamField>

<ParamField path="depot.windows" type="array (DateWindow)">
  Opening hours. Arrival outside every window is infeasible. **Hard** (`DEPOT_TIME_WINDOW`).
</ParamField>

<ParamField path="depot.duration" type="integer">
  Service/unloading time at the depot, in seconds. Not a constraint by itself, but extends the route's timeline, which the shift-end and time-window constraints then score against.
</ParamField>

<Info>
  `shift.unloadBeforeEndShift` (End depot, hard, `END_DEPOT`) forces the last job of a route to select a depot if the vehicle would otherwise still be carrying non-zero load at shift end.
</Info>

## Period rules

Resources can carry rules that cap or floor total work over a longer period (a week, a month, the whole planning horizon) rather than a single shift.

```json theme={null}
{
  "rules": [
    {
      "period": { "from": "2024-01-01T08:00:00Z", "to": "2024-01-07T17:00:00Z" },
      "maxWorkTime": 28800,
      "minServiceTime": 7200,
      "jobTypeLimitations": { "Install": 10 },
      "groupTag": "contractor-install-pool"
    }
  ]
}
```

<ParamField path="rule.period" type="object (PeriodDto)">
  The sub-window the rule applies to. Omit to cover the whole planning period.
</ParamField>

<ParamField path="rule.maxServiceTime / minServiceTime" type="integer">
  Cap/floor on total service time (job durations only) across the period, in seconds. Max is **hard** (`RESOURCE_PERIOD_MAX_SERVICE_TIME`); min is **medium** (`RESOURCE_PERIOD_MIN_SERVICE_TIME`).
</ParamField>

<ParamField path="rule.maxDriveTime / minDriveTime" type="integer">
  Same pattern, for driving time. `RESOURCE_PERIOD_MAX_DRIVE_TIME` (hard) / `RESOURCE_PERIOD_MIN_DRIVE_TIME` (medium).
</ParamField>

<ParamField path="rule.maxWorkTime / minWorkTime" type="integer">
  Same pattern, for work time (service + drive/travel combined). `RESOURCE_PERIOD_MAX_WORK_TIME` (hard) / `RESOURCE_PERIOD_MIN_WORK_TIME` (medium).
</ParamField>

<ParamField path="rule.maxJobComplexity / minJobComplexity" type="integer">
  Cap/floor on the summed `job.complexity` of jobs completed in the period. `RESOURCE_PERIOD_MAX_COMPLEXITY` (hard) / `RESOURCE_PERIOD_MIN_COMPLEXITY` (medium).
</ParamField>

<ParamField path="rule.jobTypeLimitations" type="object (map of string → integer)">
  Maximum count of jobs per `job.jobTypes` value, for the period. **Hard** (`JOBTYPE_VIOLATION`). The equivalent `shift.jobTypeLimitations` applies the same cap per shift instead of per period.
</ParamField>

<ParamField path="rule.groupTag" type="string">
  Turns a per-resource rule into a shared pool: every resource declaring an identical rule (same period, same field values) under the same `groupTag` shares one bound across all of them, instead of each resource getting its own. A resource with a mismatched declaration is silently excluded from the pool.
</ParamField>

<Note>
  Period-rule penalties use a fixed internal weight — none of them currently expose a dedicated `weights` field.
</Note>

## Fairness and vehicle usage

Solver-wide objectives that shape how work is spread and how many resources get used, controlled through `options` + `weights` rather than per-job/per-resource fields.

```json theme={null}
{
  "options": {
    "partialPlanning": true,
    "minimizeResources": true,
    "fairWorkloadPerTrip": true,
    "workloadSensitivity": 0.1
  },
  "weights": {
    "priorityWeight": 100,
    "workloadSpreadWeight": 10,
    "minimizeResourcesWeight": 3600,
    "asapWeight": 5
  }
}
```

<ParamField path="options.fairWorkloadPerTrip" type="boolean" default="false">
  Balance service time across all resources and trips within `options.workloadSensitivity` tolerance. **Soft** (`FAIR_WORK`), scaled by `weights.workloadSpreadWeight` / `costs.workloadImbalanceCostFactor`.
</ParamField>

<ParamField path="options.fairWorkloadPerResource" type="boolean" default="false">
  Same balancing, aggregated per resource across all its trips/days instead of globally. **Soft** (`FAIR_TOTAL_WORK`).
</ParamField>

<ParamField path="options.fairComplexityPerTrip / fairComplexityPerResource" type="boolean" default="false">
  Same fairness mechanism, applied to `job.complexity` instead of service time. **Soft** (`FAIR_COMPLEXITY_PER_TRIP` / `FAIR_COMPLEXITY_PER_RESOURCE`). The tolerance is fixed internally, not currently exposed as an option.
</ParamField>

<ParamField path="options.minimizeResources" type="boolean" default="false">
  When `true`, consolidate jobs onto fewer active resources, even at the cost of more travel time. **Soft** (`RESOURCE_USAGE`), scaled by `weights.minimizeResourcesWeight` (units: seconds of travel time an extra resource is "worth" — e.g. `3600` means one resource ≈ 1 hour of travel).
</ParamField>

<ParamField path="job.priority" type="integer" default="1">
  When `options.partialPlanning` leaves jobs unserved, higher-priority (× duration) jobs are penalized more for being left out, biasing the solver to serve them first. **Medium** (`UNSERVED_JOBS`), scaled by `weights.priorityWeight`.
</ParamField>

<ParamField path="weights.asapWeight" type="integer">
  Pushes jobs toward earlier days/shifts in the planning period when possible. **Soft** (`JOB_DAY_INDEX`).
</ParamField>

<ParamField path="job.hard / job.hardWeight" type="boolean / integer">
  In partial-planning mode, marks a job that must be included in the solution rather than left on a dummy trip. **Hard** (`HARD_JOBS`), scaled per-job by `hardWeight`.
</ParamField>

## Geographic optimization

<ParamField path="options.jobProximityRadius" type="integer" default="0 (disabled)">
  Jobs within this radius (meters) of each other are treated as geographic neighbors; the solver penalizes visiting a job whose immediately preceding stop isn't one of its neighbors. **Soft** (`JOB_PROXIMITY`), scaled by `weights.jobProximityWeight`.
</ParamField>

<ParamField path="options.jobProximityDistanceType" type="enum" default="HAVERSINE">
  `HAVERSINE` (straight-line, fast) or `REAL` (routing-engine distance, slower but accurate) for the proximity radius check above.
</ParamField>

<ParamField path="options.enableClustering" type="boolean" default="false">
  Penalizes overlapping bounding boxes between any two routes, encouraging geographically distinct territories per resource. **Soft**, scaled by `weights.clusteringWeight` and buffered by `options.clusteringThresholdMeters` (default 10000m). Not currently surfaced in the score-explanation breakdown.
</ParamField>

## Related

<CardGroup cols={2}>
  <Card title="VRP Constraint System" icon="sitemap" href="/guides/vrp/concepts/constraint-system">
    The conceptual model: hard/medium/soft levels, weight tuning, and debugging conflicts.
  </Card>

  <Card title="Advanced Constraints" icon="cog" href="/guides/vrp/features/advanced-constraints">
    Deeper walkthroughs of drive-time limits, tag matching, and vehicle restrictions.
  </Card>

  <Card title="Request Schema" icon="brackets-curly" href="/guides/vrp/schemas/request">
    Full field reference for `OnRouteRequest`.
  </Card>

  <Card title="Unassigned Reasons" icon="lightbulb" href="/guides/vrp/features/unassigned-reasons">
    Why a job was left unserved — surfaces which of these constraints blocked it.
  </Card>
</CardGroup>
