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