0.8.0
Overtime is reworked. max_overtime — an allowance bolted onto shift_duration — is replaced by an
explicit max_duration hard cap, and a new overtime_start adds clock-based overtime for drivers
who are contracted until a time of day rather than for a number of hours.
Changed
Breaking:
max_overtimeis replaced bymax_duration. The old field was an allowance added toshift_durationto derive the hard cap;max_durationnow is the hard cap. To migrate, add the two numbers together:# before Model.add_vehicle_type(model, shift_duration: 480, max_overtime: 60, unit_overtime_cost: 10) # after Model.add_vehicle_type(model, shift_duration: 480, max_duration: 540, unit_overtime_cost: 10)max_durationdefaults toshift_duration, which matches the oldmax_overtime: 0default: a route may not run past its nominal shift. Vehicle types that never setmax_overtimeneed no change. Note that this default makesunit_overtime_costinert on its own — duration-based overtime is only reachable oncemax_durationis raised aboveshift_duration.usage-rules.mdhas a section on the three fields and how they interact.Multi-trip construction now respects
max_durationrather thanshift_duration. Both places that decide whether another trip fits —LocalSearch's trip-insertion heuristic and the initial solution builder — capped the route atshift_duration, so raisingmax_durationbought a single-trip route more room but never a multi-trip one. Behaviour is unchanged for vehicle types that leavemax_durationat its default, since that default isshift_duration.Breaking:
ExVrp.Native.search_route_max_overtime_nifis removed. Usesearch_route_max_duration_nif/1for the hard cap or the newsearch_route_overtime_start_nif/1for the contracted end of shift.search_route_max_duration_nif/1is unchanged in name but now reports the value the caller passed rather thanshift_duration + max_overtime.ExVrp.solve/2andsolve!/2are specced as returningExVrp.IteratedLocalSearch.Result, which is what they have always returned — the specs saidExVrp.Solutionand were silenced with@dialyzer {:nowarn_function, ...}. The specs are now correct and the suppressions are gone; dialyzer passes without them.
Documented
shift_durationandmax_durationboth measure elapsed time, not time worked. Route duration runs from route start to route end with idle time included, so neither field expresses "no more than N hours worked per day": a day of two short shifts separated by a long gap breaches an elapsed cap the driver's actual hours would clear. Concretely, a route with 300 units of driving spread across a 900-unit day is infeasible undermax_duration: 500.usage-rules.mdand theProblemDatadocs now say so, and point atRoute.duration/1 - Route.wait_duration/1for measuring worked time after the fact. No behaviour change — this was always true and undocumented.
Added
overtime_startonExVrp.VehicleType— the contracted end of shift, on the same axis as:time_windows. When set, overtime becomesmax(0, route_end - overtime_start): a driver contracted until 16:00 who runs 09:00–17:00 has worked an hour of overtime even though the route lasted only the nominal eight. Defaults to:infinity, in which case overtime staysmax(0, duration - shift_duration)as before.ExVrp.Native.search_route_overtime_start_nif/1, completing the search-route accessor set alongsidesearch_route_shift_duration_nif/1andsearch_route_max_duration_nif/1.Doctests for
ExVrpitself, so the moduledoc's end-to-end example is executed rather than asserted. The example's stated result was wrong ([[1, 2], [3]]at distance 8944 for an instance whose optimum is a single route at 68); it now shows real output, and notes that route entries are location indices.
Fixed
Local search was blind to overtime cost whenever the hard cap was unbounded.
Route::hasDurationCostgates whetherCostEvaluator::deltaCostprices the duration and time-warp terms of a move at all. It tested the oldmax_overtime != 0, whose natural translation (overtime_startbeing set) drops the duration-based case: withshift_duration: 480, max_duration: :infinity, unit_overtime_cost: 10it returnedfalse, so every move on that route was evaluated as if overtime were free. It now also accounts for a finiteshift_duration.Search-side overtime was wrong when forbidden windows were in play. The search route derived its end time as
start + duration - time_warp, but under forbidden windowstime_warpcarries violation penalties that are not shifts along the timeline. A route withovertime_start: 250and a forbidden window it had to idle through reported 0 overtime in the search where the final route reported 250. The search now uses the end time from the schedule walk it already performs, and the two agree.
0.7.1
Added
usage-rules.md, consumed by usage_rules, so agents working in a consuming project get ExVrp's semantics at request time instead of inferring them. It documents the traps that are invisible from the type specs: location indices being offset by the depot count, capacity dimensions having to match the first vehicle type,:time_windowsbeing the only accepted way to give a vehicle its hours,required: falsemeaning nothing without a prize, andIteratedLocalSearch.Result.cost/1being an objective rather than a distance.CHANGELOG.mdandusage-rules.mdare now shipped in the package and rendered in the docs — the package included neither before.usage-rules.mdalso covers the cost model (unit_distance_cost,unit_duration_cost,fixed_cost, and the fact that duration is free by default and dwarfs distance once service times are counted), warm-starting via:initial_routes, multi-trip viareload_depots, and:log_label.- Doctests. The project had none, so every
iex>example in the moduledocs was unverified — which is how two fabricated result values survived in the docs.ExVrp.Client,ExVrp.ClientGroup,ExVrp.Depot,ExVrp.NeighbourhoodParams,ExVrp.PerturbationManagerandExVrp.VehicleTypeare now executed as doctests (13 in total). Examples that used elided...output were reshaped into runnable form rather than deleted.
Fixed
Solver.solve/2's:max_runtimewas documented as seconds. It has always been milliseconds —resolve_max_runtime_ms/1passes the value straight through — so the docstring'smax_runtime: 60.0example asked for a 60 millisecond solve.ExVrp.solve/2already documented it correctly; the two now agree, and both point atStoppingCriteria.max_runtime/1being the one that takes seconds.README quick start printed results the code does not produce. It claimed
routes #=> [[1, 2], [3]]anddistance #=> 8944; the actual output is[[2, 1, 3]]and68. The install snippet still pinned~> 0.4.0, and the prerequisites said Elixir 1.15+ againstelixir: "~> 1.18"inmix.exs. The development section now also documentsEX_VRP_FORCE_BUILD=1, without whichc_src/edits are silently ignored in favour of a precompiled artifact.Flaky timing tests made load-independent.
ExVrp.TimeoutTest,ExVrp.SolveTest,ExVrp.OscillationPreventionTestandExVrp.PrizeCollectingEdgeCasesTestall asserted on elapsed time, somix checkfailed intermittently: running ex_unit alongside dialyzer, credo and reach starves the schedulers, and a 250ms budget reported 892ms while a 1.5s oscillation guard took 3734ms.TimeoutTesthad also assumedresult.runtimewas not a wall-clock measurement; it is, just taken inside the ILS loop.The oscillation tests were measuring the wrong thing entirely. Those searches complete their full iteration budget in 10-29ms; oscillation manifests as a search stalling inside an iteration and being cut off by
max_runtimeafter a handful, not as a slow wall clock. They now assert that the iteration budget was completed, which is immune to machine load. The timeout tests assert that the stopping criterion fired — the run ended well short of the default 10_000 iterations — and keep only a loose runtime ceiling to catch a criterion that is ignored outright. All timing-sensitive solves also pinnum_starts: 1so one chain's budget is what is being measured.SolveTestadditionally passedmax_runtime: 0.001, a float left over from the seconds assumption, wheresolve_optsdeclarespos_integer().
Changed
- The
ExVrp.ABBenchmark.*modules live underdev/and are not shipped in the package, but were still published to HexDocs. They are now filtered out, which also clears themix docswarnings about their hidden types.
Known issues
ExVrp.Route'stripsfield is declared on the struct and in its typespec, butExVrp.Solution.routes/1never populates it — it is always[], even on a route that made three trips. UseExVrp.Route.num_trips/1. Documented inusage-rules.md; populating the field is a behavioural change left for a later release.
0.7.0
Fixed
solve/2withnum_starts > 1picked the winning start on the wrong metric.IteratedLocalSearch.Result.cost/1returnedbest.distance, while every ILS chain minimisesunit_distance_cost × distance + unit_duration_cost × duration + fixed_cost × vehicles + uncollected prizes.Solver.finalize_best/3ranks starts with that function, so the cross-start winner was chosen on one term of an objective no start had optimised. It now returns the full objective (stats.final_cost), still:infinitywhen infeasible.This is a behavioural change for callers reading
Result.cost/1as a distance — it now equals distance only whenunit_duration_costis 0, no vehicle type sets afixed_cost, and every client is visited. Measured on a 153-order instance with a fixed vehicle cost: the old rule returned a 7-vehicle plan, the new rule a 6-vehicle plan that was also shorter in duration.
Changed
IteratedLocalSearch.Paramsdefaultmax_no_improvementlowered from50_000to800. Upstream PyVRP's150_000assumes runs of millions of iterations; a two-minute solve of ~150 locations runs about 10_000, so any threshold in that range leftmaybe_restart/1unreachable and a stalled chain simply burned its remaining budget. On the same instance800fires 3-6 restarts per start and raises total iterations ~29%. Passils_paramsto override.Per-iteration and per-start setup logging dropped from
:infoto:debug(ILS iteration,LocalSearch created,Initial solution generated,Total setup time before ILS,Total solve time). An 8-start two-minute solve emitted over a thousand:infolines, burying everything else in the host's log.
Added
Parallel solves log one
[exvrp start N] candidate:line per finished start (cost, distance, duration, routes, clients, iterations), andParallel solve completenames the winning start.ILS completedis prefixed with[exvrp start N]so interleaved chains can be told apart. Indices are the original spawn indices and stay stable when a start fails.New
:log_labeloption onsolve/2. Start indices only tell chains apart within one solve, so a host running several solves concurrently would see the same[exvrp start 0..3]labels from all of them.log_label: "relaxed_15"namespaces every line of that solve:[exvrp relaxed_15 start 2] ILS completed in 4210ms (3180 iterations) [exvrp relaxed_15 start 2] candidate: cost 977440, distance 252435, ... [exvrp relaxed_15] Parallel solve complete: 4 starts, ... best from start 2: ...Defaults to
nil, which keeps the unlabelled format.
0.6.0
Added
Exhaustive-on-best polishing in Iterated Local Search (PyVRP #988). When a candidate becomes a new global best, it is now polished with a non-perturbing (exhaustive) local-search pass before being recorded as the best. Controlled by the new
exhaustive_on_bestfield onIteratedLocalSearch.Params(defaulttrue). The polished result replaces the candidate only when it is feasible; an infeasible polish falls back to the original candidate.New
exhaustiveargument onNative.local_search_run/5(defaultfalse), backing the polishing pass. Passingtrueskips perturbation and runs a pure intensification search, matching PyVRP'sexhaustiveflag.
Changed
IteratedLocalSearch.Paramsdefaultmax_no_improvementrestored from an accidental5_000to50_000(upstream PyVRP uses150_000).NIF
local_search_run_nifarity changed from 4 to 5. This bumps the minor version because the precompiled artifact is version-pinned: consumers must pull thev0.6.0release binary (or force a local build withEX_VRP_FORCE_BUILD=1) — thev0.5.xartifact exposes the old arity-4 NIF and will fail to load against this release.
0.5.3
Added
Warm-start solver via
:initial_routesoption onExVrp.solve/2. The outer list position maps to the vehicle type index; each inner list is the sequence of client IDs visited by that vehicle type. Empty inner lists are skipped. Example:ExVrp.solve(model, initial_routes: [[1, 2, 3], [], [4, 5]]) # vehicle type 0 → clients 1, 2, 3; vehicle type 2 → clients 4, 5Use this when you already have a known-good (or even partially-known) assignment to seed the solver — e.g. inserting new orders into existing routes — instead of cold-starting from an empty solution.
New
Native.create_solution_from_routes_with_types/2NIF backing the warm-start path. Takes[{vehicle_type, [client_id, ...]}, ...], unlike the existingcreate_solution_from_routes/2which hardcodes vehicle type 0 and is only suitable for homogeneous fleets.
Robustness
- Warm-start inputs are bounds-checked in the NIF before constructing the
C++
Solution: vehicle type indices outside[0, numVehicleTypes())and client IDs outside[numDepots, numLocations)now raiseArgumentErrorwith a descriptive message instead of segfaulting. ExVrp.solve/2rescues anyArgumentError/RuntimeErrorfrom the warm-start NIF and falls back to an empty-solution start with a warning log. Structurally invalid:initial_routes(duplicate clients, malformed tuples, too many routes fornum_available) no longer crash the solve.- Capacity-overloaded or time-window-violating warm-starts are passed through to the solver unchanged — these are valid infeasible starting points that the solver can repair via penalties.
0.5.2
Internal
- Static analysis baseline: zero findings across credo, sobelow, ex_dna, and
reach (arch/dead-code/smells/candidates). Wired into
mix checkand the pre-commit hook; PRs also runmix reach.check --changed. - Performance: hot validators in
ModelandReadswitched fromEnum.at-in-loop and length checks toStream.with_index,Enum.sum_by,List.to_tuple+elem/2, andEnum.zip_with. - Architecture:
ExVrp.Nativeis now a true PDG leaf (type-erasedModel.t()in the @spec) with explicit reach forbidden rules. - Safety: replaced
String.to_atom/1in TSPLIB parsing withString.to_existing_atom/1+ rescue.
0.5.1
Added
- Forbidden time windows in route planning: support for multiple disjunctive
feasibility windows on vehicle time, used by the local search to evaluate
insertions against reload-time constraints (see
test/forbidden_window_test.exs).
Fixed
- AddressSanitizer / Valgrind setup stabilised across the C++ search code
(
LocalSearch,Route,Solution,CostEvaluator).
Internal
- Removed the in-tree
credo/append_in_loop.excustom check (and its test); superseded by upstream tooling.
0.5.0
Breaking Changes
VehicleType: replaced
tw_early/tw_late/forbidden_windowswithtime_windowsThe
VehicleTypeAPI now uses a single:time_windowsoption (list of{start, end}tuples) instead of separate:tw_early,:tw_late, and:forbidden_windowsoptions.# Before Model.add_vehicle_type(model, num_available: 1, capacity: [100], tw_early: 0, tw_late: 28_800 ) # After Model.add_vehicle_type(model, num_available: 1, capacity: [100], time_windows: [{0, 28_800}] )Multiple disjunctive time windows are now first-class:
Model.add_vehicle_type(model, num_available: 1, capacity: [100], time_windows: [{0, 500}, {600, 1000}] )When
:time_windowsis omitted, it defaults to[{0, :infinity}](no time constraint).Passing
:tw_early,:tw_late, or:forbidden_windowsdirectly now raises anArgumentErrorwith a migration hint.Invalid time windows are silently filtered instead of raising. Windows where
start >= endare dropped. If all windows are invalid, the vehicle getstw_early: 0, tw_late: 0(effectively unusable).