Solution Quality Metrics
Once you have a solved (or partially solved) response, “is this any good?” is a question you answer from the fields that response actually contains — not from a generic quality-scoring framework. This guide walks through every metric you can compute fromSolveResponse, distinguishes what the API returns directly from what you derive yourself, and calls out the handful of business metrics (profit margin, cost-per-job with real currency, preferred-resource fulfillment) that need data the solver doesn’t have.
There is no separate quality-scoring endpoint and no opaque
occupancy/workloadFairness/onTimeRate block in the response. Every metric on this page is either a field on Summary/Route/StopOutput/Unassigned (see src/api/v3_types.rs), or a named arithmetic derivation from those fields that this page states explicitly. If a metric here isn’t traceable to one of those two things, it’s called out as something you’d have to compute by joining the response with your own data.What Makes a Good Solution?
The solver itself already answers this question once, internally, via the two-tier objective described in Objective Function: first minimise the drop penalty of the tasks left unserved, then minimise currency cost. This page is about reading the result of that comparison back out of the response — the same underlying data, viewed as metrics rather than as a search objective.Core Quality Metrics
1. Feasibility Rate
summary.jobs_assigned and summary.jobs_unassigned are returned directly. Feasibility rate itself is a customer-side ratio, not a field:
- Perfect (100%)
- Partial (96%)
- Infeasible (0%)
status: "solved" — every job placed. Feasibility rate = 50 / 50 = 100%.Summary.jobs_assigned, Summary.jobs_unassigned, and SolveStatus (Solved/Partial/Infeasible) in src/api/v3_types.rs.
2. Cost
summary.estimated_cost is a real-currency figure, not an opaque score — see Objective Function for the full cost model. For solution-quality purposes, the two numbers worth watching are the total and the share of it you paid to not serve tasks:
components.drop_fees share across otherwise-similar requests means the solver is choosing to pay more in drop fees rather than route to those tasks — worth checking against your drop_fee values (see Improvement Strategies below) before assuming it’s a routing-quality regression. drop_fees is absent, not 0.0, when no task priced a drop; the same absent-means-unpriced rule applies to overtime.
CostComponents also declares lateness, vehicles_fixed, preferences, and imbalance — all Option<f64>, all set to None unconditionally by build_v3_response() in src/api/response_v3.rs, and all excluded from total. Don’t chart those four as zero; they’re absent because they aren’t computed yet, not because their true value is zero. travel, waiting, overtime, and drop_fees are the four that are really populated, and total is exactly their sum — so a plan carrying real lateness or preference cost reports a total below the number the search actually minimised.EstimatedCost, CostComponents in src/api/v3_types.rs.
3. Route Shape: Distance, Duration, and Overtime
summary.total_distance_m and summary.total_duration_s are fleet-wide sums; each routes[] entry carries the same two figures per vehicle, plus overtime_s:
overtime_s is “seconds of overtime past the soft shift end” per route (0 when the route finishes within its shift) — summing it across routes[] gives you a single fleet-wide overtime figure not returned as a top-level field.
Source: Summary.total_distance_m, Summary.total_duration_s, Route.distance_m, Route.duration_s, Route.overtime_s, Route.load_peak in src/api/v3_types.rs.
4. Fleet Utilization
summary.vehicles_used (“number of routes with at least one task stop”) is returned directly. There is no separate “total fleet size” field in the response — you know your requested fleet size because you sent vehicles[] in the request:
vehicles[] entry with several shifts becomes one internal route per shift, so vehicles_used counts vehicle-shifts, not physical vehicles. There is no vehicles[].count multiplier field — every vehicle you want is its own array entry.
Source: Summary.vehicles_used in src/api/v3_types.rs.
5. Time-Based Metrics: Wait and Lateness
Per-stop timing detail lives onStopOutput, not as fleet-wide aggregates:
wait_s— seconds spent waiting for the time window to open at that stop.lateness_s— seconds late beyond the latest allowed arrival (0when on time). Because fleet-wideobjective.costs.per_late_houris the only mechanism that makes a time window soft (see Constraint System), a nonzerolateness_sonly ever shows up when that rate is set — a hard time window makes lateness infeasible rather than penalized, so it never appears as a nonzero value in the response. A softtarget_arrivalis different: it prices late arrival but is not a window, so it never produceslateness_seither.travel_time_s/travel_distance_m— the leg immediately before this stop.
wait_s, service_s, travel_time_s, travel_distance_m, and lateness_s are all Option<i32> — they’re None/omitted for stops where the concept doesn’t apply (e.g. a depot start stop has no lateness_s), not defaulted to 0. Treat an absent field as “not applicable here,” not as “zero.”
Source: StopOutput.wait_s, StopOutput.service_s, StopOutput.travel_time_s, StopOutput.travel_distance_m, StopOutput.lateness_s in src/api/v3_types.rs.
Advanced Quality Indicators
Workload Distribution
There is no balance reporting in the response at all.Summary has exactly nine fields — status, vehicles_used, jobs_assigned, jobs_unassigned, total_distance_m, total_duration_s, estimated_cost, elapsed_ms, iterations — and no balance object among them; the BalanceReport placeholder that used to sit there was removed rather than left unpopulated. On the request side there is no objective.balance either, so it is an unknown-field rejection, not a “not supported” 400.
Compute the spread yourself over routes[].duration_s (or .distance_m):
workloadFairness score, no resourceWorkloads breakdown, and no way to ask the solver to optimise for balance today. Source: Summary in src/api/v3_types.rs.
Skills and Constraint Compliance
There is nopriorityLevels/weightedFulfillment breakdown and no job-level priority field in the schema at all — JobCommon has mandatory, drop_fee, skills, eligible_vehicles, locked_vehicle, preferences, tags, target_arrival, and preferred_date, but nothing that ranks jobs into abstract priority tiers. The closest thing to a priority is drop_fee, and it is money rather than a rank. What you get instead is per-job pass/fail via unassigned[].reasons:
unassigned[] entries carry SKILLS_MISMATCH versus your total skill-tagged job count — there’s no returned aggregate for it.
Source: JobCommon fields, Unassigned/UnassignedReason in src/api/v3_types.rs.
Cost Effectiveness Beyond estimated_cost
estimated_cost.total is real currency, but a few cost-adjacent metrics from the source workflow require data the solver never sees:
cost_per_job and cost_per_km are legitimate one-line derivations from fields the API actually returns. revenuePerRoute and profitMargin, by contrast, are business metrics that require joining the response with your own pricing/revenue data — the solver has no concept of what you charge a customer, so there is no request field or response field that could ever produce them directly.
Comparing Solutions
When you run the same problem more than once (differentseed, different time_limit_s, or after a request edit), compare runs on the real fields in order of what the objective actually prioritizes:
This mirrors the solver’s own two-tier objective (see Objective Function): don’t average jobs_unassigned and estimated_cost.total into a single blended score unless you’ve deliberately decided your business cares about that tradeoff. The solver never trades a required task away for a better cost. It will trade a drop_fee-priced one — that is the whole point of the fee — but in that case the fee is already inside estimated_cost.total, so comparing totals is the fair comparison.
Improvement Strategies
For Low Feasibility (High jobs_unassigned)
1
Read unassigned[].reasons first
Don’t reach for
time_limit_s or cost tuning before checking why each job failed — reasons[].code (CAPACITY_EXCEEDED, TIME_WINDOW_VIOLATED, SKILLS_MISMATCH, etc.) tells you whether it’s a hard-constraint block that no amount of search time can fix. See Constraint System for the full code list.2
Price the truly optional jobs
Give the task a
drop_fee — what leaving it unserved is worth to you — instead of leaving every task at the implicit must-serve default. There is no fleet-wide fallback price, and mandatory: false without a drop_fee is rejected with HTTP 400. See Objective Function.3
Add capacity or relax constraints
Apply the
relaxations[] suggestion directly (it names the exact field and the smallest change), or add real vehicle capacity/shift time if the relaxation isn’t practical.For Poor Route Efficiency (High Cost or Distance)
- Tune cost rates to your real economics
- Price lateness instead of leaving windows hard
- Give the search more time
per_travel_km / per_travel_hour are the actual dials — see Objective Function for the real defaults (€0.20/km and €35/hour, and no default per-vehicle cost) they replace.For Imbalanced Workload
There is nofairWorkloadPerResource boolean, no workloadSpreadWeight/workloadSensitivity option, and no objective.balance field — nor any balance figure in the response (both covered above). The only lever available today is indirect: vehicles[].cost.fixed / objective.costs.per_vehicle and the fleet-wide travel rates shape how many vehicles the solver uses and how it spreads work across them, but there is no dedicated fairness objective to turn on.
If workload balance is a hard business requirement today, you’ll need to post-process: solve, compute the spread over
routes[].duration_s yourself, and adjust vehicle count or shift windows in a follow-up request if the spread is unacceptable. This is a real gap, not a naming difference — don’t build against a fairWorkloadPerResource field that doesn’t exist.Monitoring Quality Over Time
There is no built-in metrics-history endpoint —GET /v3/metrics (Prometheus format) covers server-level operational metrics (request counts, solver duration, queue depth), not per-solve business KPIs. To track solution quality over time, log the fields you care about from each SolveResponse into your own store:
Summary (see src/api/v3_types.rs) — there is no separate “daily metrics”/“weekly trends” response shape; you build that aggregation layer yourself from repeated SolveResponse bodies.
Quality Benchmarks
There are no published per-sector benchmark numbers (feasibility %, occupancy %, on-time %) for this API — those would require occupancy and on-time fields the response doesn’t return today, and industry benchmarks depend heavily on your own fleet economics and SLAs. The one number worth carrying around is throughput, not solution quality: roughly 5 ALNS iterations per millisecond for a ~100-job instance, as an order of magnitude only (see Performance Guide). Usesummary.iterations against that figure to sanity-check whether a given time_limit_s was enough for your problem size — that’s a search-budget check, not a solution-quality benchmark.
If you need sector-specific quality targets (feasibility rate, on-time rate, workload fairness), define them from your own historical data and SLAs, then track them using the derivations in this page — the API itself doesn’t ship a benchmark table.
Best Practices
Quality management guidelines:
- Read
summary.statusfirst.solved/partial/infeasiblealready tells you the feasibility band before you compute anything. - Diagnose before tuning.
unassigned[].reasonsexplains why a job dropped; don’t reach for cost rates ortime_limit_suntil you’ve read it. - Distinguish returned fields from your own derivations.
jobs_assigned,estimated_cost.total,distance_mare real; feasibility rate, cost-per-job, and workload spread are arithmetic you do on top of them — know which is which when you build dashboards. - Respect the solver’s own priority order when comparing runs. Fewer unassigned jobs beats lower cost, always — don’t build a blended score that could rank a cheaper-but-less-complete solution above a more-complete one, unless that’s a deliberate business choice.
- Don’t build against unshipped fields.
CostComponents.lateness,vehicles_fixed,preferences, andimbalanceare declared but never populated, and they are excluded fromtotalas well. Anything else you remember from an older schema —summary.balance,objective.balance,unassigned_cost,options.runtime.matrix— has been removed outright and now returns an unknown-field rejection. - Track your own KPIs over time. There’s no built-in history; log the fields above into your own store per solve.
Related Topics
Objective Function
The two-tier objective that determines which solution the solver picks in the first place
Constraint System
The sixteen hard constraints, the soft mechanisms, and the
unassigned[].reasons diagnosisPerformance Guide
Balancing search time against solution quality
Distance Matrices
Where
distance_m/duration_s actually come from