Skip to main content

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 from SolveResponse, 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:
status: "solved" — every job placed. Feasibility rate = 50 / 50 = 100%.
Source: Summary.jobs_assigned, Summary.jobs_unassigned, and SolveStatus (Solved/Partial/Infeasible) in src/api/v3_types.rs.
summary.status already tells you which of the three feasibility bands you’re in without computing the ratio yourself — solved means the ratio is 100%, infeasible means it’s 0%, and partial means somewhere in between. Compute the exact percentage when you need to track it as a KPI over time (see Monitoring Quality Over Time below).

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:
A rising 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.
Source: 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:
Two useful customer-side derivations:
Lower is better clustering/routing for a given job set — but only meaningful when comparing runs over the same job set, since job density and geography dominate this number more than routing quality does.
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.
There is no circuity factor (actual distance ÷ straight-line distance) anywhere in the response, and the solver has no straight-line-distance concept to compute one against — every distance in this API comes from a real Solvice Maps road-network matrix (see Distance Matrix Integration). If you want a circuity-style efficiency check, you’d need to compute great-circle distance between each stop pair yourself from stops[].location and divide travel_distance_m by that — a metric you’d build entirely client-side, not something this page can point you at a field for.
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:
This is a customer-side ratio computed by joining the response against your own request, not a returned field. Careful with multi-shift fleets: one 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 on StopOutput, 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 (0 when on time). Because fleet-wide objective.costs.per_late_hour is the only mechanism that makes a time window soft (see Constraint System), a nonzero lateness_s only 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 soft target_arrival is different: it prices late arrival but is not a window, so it never produces lateness_s either.
  • travel_time_s / travel_distance_m — the leg immediately before this stop.
Two customer-side rollups you can build from these, summed across every stop in every route:
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.”
There is no onTimeDeliveries/onTimeRate field anywhere in the response, and no fleet-wide wait-time summary. Both of the rollups above are entirely client-side arithmetic over stops[] — useful, but not something you can read off a single field.
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):
There is no 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 no priorityLevels/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:
A “skills compliance rate” is something you’d compute yourself by counting how many unassigned[] entries carry SKILLS_MISMATCH versus your total skill-tagged job count — there’s no returned aggregate for it.
There is no preferredResourceMatches/preferenceRate/tagMatchRate metric. JobCommon.preferences (soft vehicle affinity) is accepted and priced into the objective, but the response carries no per-task “was this preference honoured?” flag and estimated_cost.components.preferences is still omitted — to measure preference satisfaction you compare each preferences[].vehicle against the vehicle on the route that served the task. tags exist for grouping/relation labels only, explicitly not capability matching, so there’s no tag-affinity score to derive from them either.
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 (different seed, 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.
If you do want a single composite score across multiple runs (e.g. to rank several seed values), build it from fields you actually have — jobs_unassigned, estimated_cost.total, summary.total_duration_s, and your own workload-spread calculation — and weight jobs_unassigned heavily enough that it dominates the other terms, matching how the solver itself never lets cost override feasibility.

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.
There is no partialPlanning boolean anywhere in the schema. The real mechanism for “let some jobs go unassigned on purpose” is a per-task drop_fee — a priced decision the solver makes per task, not a single global switch.

For Poor Route Efficiency (High Cost or Distance)

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 no fairWorkloadPerResource 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:
Everything in that example is a real field from 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). Use summary.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:
  1. Read summary.status first. solved/partial/infeasible already tells you the feasibility band before you compute anything.
  2. Diagnose before tuning. unassigned[].reasons explains why a job dropped; don’t reach for cost rates or time_limit_s until you’ve read it.
  3. Distinguish returned fields from your own derivations. jobs_assigned, estimated_cost.total, distance_m are 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.
  4. 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.
  5. Don’t build against unshipped fields. CostComponents.lateness, vehicles_fixed, preferences, and imbalance are declared but never populated, and they are excluded from total as 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.
  6. Track your own KPIs over time. There’s no built-in history; log the fields above into your own store per solve.

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 diagnosis

Performance Guide

Balancing search time against solution quality

Distance Matrices

Where distance_m/duration_s actually come from