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

# VRP Constraint System

> How hard constraints, soft time windows, and the cost objective combine to produce a routing solution

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

<Info>
  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](#the-objective-two-tiers-not-a-score) below for what actually ranks solutions.
</Info>

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

```mermaid theme={null}
flowchart LR
    subgraph "Business Rules"
        A["'Van can't carry more than 1000kg'"]
        B["'Deliver between 9am and noon'"]
        C["'Only certified techs for fridge repairs'"]
    end

    subgraph "Constraints"
        D["Capacity"]
        E["TimeWindow"]
        F["Skills"]
    end

    subgraph "Effect on search"
        G["Insertion rejected if load exceeds capacity"]
        H["Insertion rejected (or penalized) if outside window"]
        I["Insertion rejected if vehicle lacks skill"]
    end

    A --> D --> G
    B --> E --> H
    C --> F --> I

    style A fill:#e1f5fe
    style B fill:#e1f5fe
    style C fill:#e1f5fe
    style D fill:#fff3e0
    style E fill:#fff3e0
    style F fill:#fff3e0
    style G fill:#e8f5e9
    style H fill:#e8f5e9
    style I fill:#e8f5e9
```

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:

| Mode     | Behavior                                                                                            | Which constraints support it                                                                           |
| -------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| **Hard** | Insertion is rejected outright if violated (`feasible = false`)                                     | All 16 constraint types, always                                                                        |
| **Soft** | Insertion is accepted with a cost penalty added to the objective (`feasible = true`, `penalty > 0`) | Only `TimeWindow`, and only when the fleet-wide `objective.costs.per_late_hour` rate is set above zero |

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:

```
Mode      | Behavior                              | When
----------|----------------------------------------|----------------------------------------
Hard      | feasible = false, insertion rejected   | Default for all constraints
Soft      | feasible = true, penalty > 0            | Only TimeWindow, when per_late_hour > 0
```

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.

<Warning>
  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`.
</Warning>

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

| Constraint         | What it checks                                                                                                                                                   | Hard/soft                                 |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `Capacity`         | Vehicle load stays within `vehicle.capacity` at every point on the route, across every named dimension                                                           | Hard                                      |
| `TimeWindow`       | Visit falls within `[from, to]`, checked in O(1) via Savelsbergh forward-slack arrays                                                                            | Hard, or soft when `per_late_hour` is set |
| `Sequence`         | Chain members (`Ordered` relations) appear in relative order within one route; gaps are tolerated                                                                | Hard                                      |
| `FirstJob`         | A job flagged to go first occupies position 1, immediately after the route's depot-start                                                                         | Hard                                      |
| `Skills`           | Job's required skills are a subset of the assigned vehicle's skills (bitmask test; ALL must match)                                                               | Hard                                      |
| `ServesTags`       | A tagged job may only go to a shift whose `serves_tags` overlaps its `tags` (ANY-overlap, unlike `Skills`' subset rule). Untagged jobs are always eligible       | Hard                                      |
| `VehicleRange`     | Route's total distance/duration after insertion stays within `vehicle.limits.max_distance_m` / `max_route_duration_s`                                            | Hard                                      |
| `Commitment`       | A job pinned to one specific vehicle (`locked_vehicle`) can only go on that vehicle's route — any shift of it                                                    | Hard                                      |
| `Shipment`         | A pickup→delivery pair's delivery leg is only inserted after its pickup leg, in the same route                                                                   | Hard                                      |
| `TaskLimit`        | Number of job stops on a route doesn't exceed the vehicle's max task count                                                                                       | Hard                                      |
| `VehicleExclusion` | Job only goes to vehicles on its allow-list, never to vehicles on its exclude-list                                                                               | Hard                                      |
| `DriverBreak`      | Marker constraint: break feasibility (windowed / drive-accumulation / duty-accumulation / unavailability) must be re-verified on every insertion                 | Hard                                      |
| `GroupSequence`    | Jobs with a lower `group_rank` precede jobs with a higher rank on the same route (job-grouping / passing-waypoint feature)                                       | Hard                                      |
| `TripCapacity`     | Peak load *within each trip* (a route segment between reload nodes) stays within capacity — replaces `Capacity` for multi-trip problems                          | Hard                                      |
| `MultiTrip`        | Per-route trip count stays within the shift's `max_trips`, plus reload-node ownership bookkeeping                                                                | Hard (structural)                         |
| `Reachable`        | No leg of the route uses a pair the routing provider reported as having no road connection. Activated only when the fetched matrix actually contains such a cell | Hard                                      |

<Note>
  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`.
</Note>

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

```json theme={null}
{
  "jobs": [
    { "id": "job-1", "location": { "coordinate": [3.7250, 51.0500] }, "demand": { "weight": 20 } }
  ],
  "vehicles": [
    { "id": "van-1", "capacity": { "weight": 1000 }, "shifts": [ /* ... */ ] }
  ]
}
```

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

```json theme={null}
{
  "jobs": [
    {
      "id": "job-1",
      "location": { "coordinate": [3.7250, 51.0500] },
      "time_windows": [
        { "from": "2026-07-01T09:00:00+02:00", "to": "2026-07-01T12:00:00+02:00" }
      ]
    }
  ]
}
```

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:

```json theme={null}
{
  "objective": {
    "costs": { "per_late_hour": 60.0, "currency": "EUR" }
  }
}
```

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

```json theme={null}
{
  "jobs": [
    { "id": "job-1", "location": { "coordinate": [3.72, 51.05] }, "skills": [{ "name": "fridge" }] }
  ],
  "vehicles": [
    { "id": "v1", "skills": ["fridge", "electrical"], "shifts": [ /* ... */ ] }
  ]
}
```

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

```json theme={null}
{
  "vehicles": [
    {
      "id": "v1",
      "limits": { "max_distance_m": 200000, "max_route_duration_s": 32400 },
      "shifts": [ /* ... */ ]
    }
  ]
}
```

`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)

```json theme={null}
{
  "jobs": [
    {
      "id": "job-1",
      "location": { "coordinate": [3.72, 51.05] },
      "eligible_vehicles": { "allowed": ["v1", "v2"] }
    }
  ]
}
```

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

```json theme={null}
{
  "relations": [
    { "type": "ordered", "job_ids": ["job-1", "job-2"] }
  ]
}
```

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:

```json theme={null}
{
  "shipments": [
    {
      "id": "ship-1",
      "pickup": { "location": { "coordinate": [3.725, 51.05] }, "service_duration_s": 300 },
      "delivery": { "location": { "coordinate": [3.71, 51.06] }, "service_duration_s": 300 },
      "demand": { "weight": 20 }
    }
  ]
}
```

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

<Note>
  `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.
</Note>

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

```mermaid theme={null}
graph TD
    CMP["Compare two candidate solutions"] --> T1{"Tier 1: lower<br/>penalty_sum?"}
    T1 -->|"Differs"| W1["Lower drop penalty wins outright,<br/>regardless of cost"]
    T1 -->|"Tied"| T2{"Tier 2: lower<br/>cost?"}
    T2 -->|"Differs"| 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 (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](/guides/vrp/v3/concepts/scoring-explanation#vehicle-count-is-free-until-you-price-it).
* **`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`).

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

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](/guides/vrp/v3/concepts/scoring-explanation) for the full defaulting behavior, the vehicle-count tradeoff, and how `drop_fee` interacts with `mandatory`.

<Tip>
  `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`).
</Tip>

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

```mermaid theme={null}
graph LR
    C1["Capacity, or TripCapacity + MultiTrip<br/>for multi-trip problems"]
    C2["TimeWindow, Sequence, FirstJob, Skills,<br/>ServesTags, VehicleRange, Commitment"]
    C3["Shipment, TaskLimit, Reachable,<br/>VehicleExclusion, DriverBreak, GroupSequence"]

    C1 --> C2 --> C3
```

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

```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."
    }
  ]
}
```

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](/guides/platform/explainable-ai) for the full mechanism, response shape, and worked examples for each code.

<Steps>
  <Step title="Check the unassigned array first">
    If `SolveResponse.unassigned` is non-empty, each entry already has `reasons` and `relaxations` — no follow-up request needed.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

## Performance Impact

Constraint cost varies by how much work each check does per candidate position:

**O(1), cheap** — `Capacity`, `Skills`, `VehicleExclusion`, `TaskLimit`, and `TimeWindow` (via precomputed Savelsbergh forward-slack arrays) are all constant-time per check.

**O(n), scans the route** — `Sequence` 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.

<Tip>
  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](/guides/vrp/v3/concepts/performance-guide) for the broader set of levers (matrix size, time limits, vehicle count) beyond just constraint choice.
</Tip>

## Best Practices

<Check>
  **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.
</Check>

## Related Topics

<CardGroup cols={2}>
  <Card title="Objective Function" icon="star" href="/guides/vrp/v3/concepts/scoring-explanation">
    Full defaulting behavior, the vehicle-count tradeoff, and how currency becomes an integer internally
  </Card>

  <Card title="Explainable AI" icon="lightbulb" href="/guides/platform/explainable-ai">
    The complete unassigned-job diagnosis mechanism, response shape, and codes
  </Card>

  <Card title="Performance Guide" icon="gauge" href="/guides/vrp/v3/concepts/performance-guide">
    How constraint choice and problem size affect solve time
  </Card>

  <Card title="Distance Matrices" icon="map" href="/guides/vrp/v3/concepts/distance-matrices">
    How travel time and distance feed the `VehicleRange` constraint and the cost objective
  </Card>
</CardGroup>
