Documentation
A worked vehicle-routing example that shows list variables, route scoring, retained jobs, maps, and delivery insertion recommendations in one SolverForge app
SolverForge Deliveries Use Case
Table of Contents
- Introduction
- Getting Started
- The Problem We’re Solving
- The Teaching Spine
- Understanding the Data Model
- Prepared Routing Pipeline
- How Optimization Works
- Writing Constraints
- Solver Policy
- API, Routes, and Browser State
- Making Your First Customization
- Testing and Validation
- Quick Reference
Introduction
This guide has two working paths. The first starts from the generic
solverforge-cli Getting Started
flow and shows how the neutral shell becomes one concrete delivery-routing
application. The second points to the downloadable finished app when you want to
run the real example, inspect complete code, or compare your work against the
reference implementation. It is the list-variable counterpart to the
SolverForge Hospital Use Case,
which teaches scalar assignment.
That split is intentional. solverforge-cli gives you the runnable shell,
managed model seams, retained job lifecycle, generic sample data, and neutral
frontend. The deliveries use case still needs manual domain code: the city data
generators, route-preparation pipeline, route endpoints, insertion
recommendations, map rendering, timelines, and complete browser UI live in the
finished example.
Hospital asks SolverForge to choose one employee for each shift. Deliveries asks SolverForge to choose which vehicle owns each delivery and where that delivery appears in the vehicle route.
You will:
- install
solverforge-cliand verify the scaffold targets carried by your binary - scaffold a neutral app with
solverforge new - know when to switch from the learning scaffold to the complete Hugging Face example
- generate the managed fact, entity, list variable, data, and constraint seams
- manually code the delivery-routing domain on top of those seams
- replace the generic scaffold sample data with the three delivery city datasets:
PHILADELPHIA,HARTFORD, andFIRENZE - understand why delivery routing uses a list planning variable
- follow the current
Delivery,Vehicle, andPlanmodel - connect map preparation, route shadows, constraints, and previews
- use the retained
/jobslifecycle plus/jobs/{id}/routes - study
/recommendations/delivery-insertionsas an interactive optimization surface
No routing or optimization background required. The article explains the end-to-end pipeline. The code comments explain the local reason each function, hook, cache, and route exists.
Prerequisites
- Basic Rust knowledge: structs, enums, traits, derive macros, modules
- Familiarity with HTTP APIs
- Comfort with command-line work
- Node.js if you want to run the frontend and browser tests
- Rust
1.95+, matching the current publishedsolverforgecrate line
Getting Started
Start with the Generic CLI Shell
Start from an empty working directory:
cargo install solverforge-cli --force
solverforge --version
solverforge new solverforge-deliveries --quiet
cd solverforge-deliveries
Those commands give you the neutral scaffold. It is runnable, but it is not the delivery app yet. The current delivery app specializes that generated project into list-variable vehicle routing through manual domain, routing, API, and frontend code.
Right after scaffolding, the generated project already contains:
- a neutral
PlanandHardSoftScore - retained
/jobsroutes, status, snapshot, analysis, pause, resume, cancel, delete, and SSE - published SolverForge core, UI, and maps dependencies
- a neutral frontend in
static/app.js - compiler-owned sample data in
src/data/data_seed.rs
Use this fresh scaffold as the learning workspace for the generator commands and the ownership boundaries below. Use the finished example when you want the full city data generators, map-backed routes, complete frontend, and tested application.
Download the Finished Example
If you want the complete reference implementation instead of rebuilding it step by step, download the Hugging Face Space repository:
git clone https://huggingface.co/spaces/SolverForge/solverforge-deliveries
cd solverforge-deliveries
The Space source is the finished app: it includes the Philadelphia, Hartford, and Firenze data generators, route-preparation pipeline, map/timeline frontend, recommendation endpoint, tests, and deployment files. The tutorial below explains how that app is assembled from the CLI scaffold plus manual delivery-routing code.
Keep the Published Dependency Shape
The CLI emits the current public crate line. Keep those published dependencies and add the delivery app’s normal web/runtime dependencies:
[dependencies]
solverforge = { version = "0.9.1", features = [
"serde",
"console",
"verbose-logging",
] }
solverforge-ui = "0.6.3"
solverforge-maps = "2.1.3"
Fresh scaffolds also start with generic demo sample data:
SMALL, STANDARD, and LARGE. Those sizes are useful for proving the shell,
but they are not the delivery-routing datasets. The finished tutorial app
replaces the generated seed flow with app-owned city visits and records that
delivery-specific catalog in solverforge.app.toml:
[app]
name = "solverforge-deliveries"
starter = "neutral-shell"
cli_version = "2.0.1"
[runtime]
target = "solverforge 0.9.1"
runtime_source = "crates.io: solverforge 0.9.1"
ui_source = "crates.io: solverforge-ui 0.6.3"
maps_source = "crates.io: solverforge-maps 2.1.3"
[demo]
default_size = "PHILADELPHIA"
available_sizes = [
"PHILADELPHIA",
"HARTFORD",
"FIRENZE",
]
[solution]
name = "Plan"
score = "HardSoftScore"
That metadata matters because this example teaches the current public integration: SolverForge core, SolverForge UI, and SolverForge Maps working together in one released-crate app.
Generate the Managed Seams
Use the CLI to create the parts SolverForge can safely own:
solverforge generate fact delivery \
--field label:String \
--field kind:DeliveryKind \
--field lat:CoordValue \
--field lng:CoordValue \
--field demand:i32 \
--field min_start_time:i64 \
--field max_end_time:i64 \
--field service_duration:i64
solverforge generate entity vehicle \
--field name:String \
--field capacity:i32 \
--field home_lat:CoordValue \
--field home_lng:CoordValue \
--field departure_time:i64
solverforge generate variable delivery_order \
--entity Vehicle \
--kind list \
--elements deliveries
solverforge generate constraint all_deliveries_assigned --unary --hard
solverforge generate constraint vehicle_capacity --unary --hard
solverforge generate constraint delivery_time_windows --unary --hard
solverforge generate constraint total_travel_time --unary --soft
solverforge generate data --mode stub
Those commands are not the final app. They create the managed anchors. They do not generate the city data, map-backed route pipeline, insertion endpoint, or finished frontend. The app code then supplies the routing meaning:
- keep the scaffolded
Planas the solution root - replace the generated
Deliveryfact with stop data, coordinates, demand, and time windows - replace the generated
Vehicleentity with depot data, thedelivery_orderlist variable, CVRP hook attributes, and route shadow fields - expand
Planwithrouting_mode,view_state, route preparation caches, and the list-variable shadow update hook - add
CoordValue, preview types, andsrc/domain/route_metrics/ - replace the generated constraint skeletons with the four route rules
- replace the generated
SMALL/STANDARD/LARGEsample-data seed with the Philadelphia, Hartford, and Firenze demo seeds - split the neutral frontend into the finished map, timeline, route-list, raw data, analysis, and recommendation modules
That is the teaching boundary: the CLI owns repeatable project seams, while the manual code owns delivery-routing semantics. When you want to see every manual line in context, keep the Space checkout open next to this article.
Run the Finished App
After the manual code is in place, validate the project and start the server:
solverforge check
solverforge routes
cargo run --release --bin solverforge_deliveries
Then open:
http://localhost:7860
The browser loads the default Philadelphia plan and renders retained solve controls, route summaries, a map, timelines, raw data, analysis, and insertion recommendations.
Inspect Demo Data
List the available demos:
curl http://localhost:7860/demo-data
Load a specific plan:
curl http://localhost:7860/demo-data/PHILADELPHIA
curl http://localhost:7860/demo-data/HARTFORD
curl http://localhost:7860/demo-data/FIRENZE
Current dataset sizes:
| Dataset | Vehicles | Deliveries | Purpose |
|---|---|---|---|
PHILADELPHIA |
10 | 82 | Default road-network baseline |
HARTFORD |
10 | 50 | Smaller city demo |
FIRENZE |
10 | 80 | European street-network demo |
The Problem We’re Solving
Delivery routing asks:
Given depots, vehicles, delivery stops, capacities, and time windows, which vehicle should visit each delivery and in what order?
That second part is what makes routing different from simple assignment. A route
A -> B -> C can have a very different travel time from C -> A -> B.
The solver therefore chooses both:
- the vehicle that owns each delivery
- the position of each delivery inside that vehicle’s route
This is why the app uses a list planning variable. Each vehicle owns an ordered list of delivery IDs.
The Teaching Spine
The delivery app is the list-variable and map-backed routing tutorial. Its core path is:
Deliveryis immutable problem data.Vehicleis the planning entity.Vehicle.delivery_orderis the list planning variable.PlanDto::to_domain()normalizes transport data back into dense route IDs.prepare_plan()builds travel matrices and per-vehicle routing caches.- CVRP hooks let SolverForge construction and k-opt read those caches.
- Route shadows convert ordered visits into demand, travel, lateness, and unreachable-leg totals.
- Constraints score assignment coverage, capacity, time windows, and travel time.
/jobs/{id}/routesturns a retained snapshot into map geometry./recommendations/delivery-insertionsranks route edits without a full solve.
The comments in src/domain/route_metrics/ teach the local mechanics. This
article connects those mechanics across the API, solver, and browser.
Understanding the Data Model
Open src/domain/.
The domain splits responsibility this way:
delivery.rsstop data, demand, time windows, service duration, and coordinatesvehicle.rsdepot, capacity, route list, and route shadow valuesplan.rsplanning solution, routing mode, list shadow refresh, and CVRP solution hookspreview.rsbrowser-facing preview stateroute_metrics/preparation, CVRP hooks, metrics, scoring previews, route geometry, and insertion rankingmod.rstheplanning_model!manifest and exports
Delivery as Problem Fact
Delivery is input data:
#[problem_fact]
pub struct Delivery {
#[planning_id]
pub id: usize,
pub label: String,
pub demand: i32,
pub min_start_time: i64,
pub max_end_time: i64,
pub service_duration: i64,
}
The solver reads deliveries but does not mutate delivery records. It mutates the vehicle route lists that contain delivery IDs.
Vehicle as Planning Entity
Vehicle.delivery_order is the central list variable:
#[planning_entity]
pub struct Vehicle {
#[planning_id]
pub id: usize,
pub capacity: i32,
#[planning_list_variable(
element_collection = "deliveries",
solution_trait = "crate::domain::DeliveryRoutingSolution",
distance_meter = "solverforge::cvrp::MatrixDistanceMeter",
intra_distance_meter = "solverforge::cvrp::MatrixIntraDistanceMeter"
)]
pub delivery_order: Vec<usize>,
}
The real attribute in the repo names the full set of Clarke-Wright and k-opt hooks. Those hook comments explain the local contract. The article-level point is simpler: the route is an ordered list of delivery IDs, not a copied list of delivery structs.
Plan as Routing Solution
Plan carries the facts, entities, score, routing mode, and prepared data:
#[planning_solution(
constraints = "crate::constraints::create_constraints",
solver_toml = "../../solver.toml"
)]
#[shadow_variable_updates(
list_owner = "vehicles",
post_update_listener = "refresh_vehicle_route_shadows"
)]
pub struct Plan {
pub name: String,
pub routing_mode: RoutingMode,
#[problem_fact_collection]
pub deliveries: Vec<Delivery>,
#[planning_entity_collection]
pub vehicles: Vec<Vehicle>,
#[planning_score]
pub score: Option<HardSoftScore>,
}
shadow_variable_updates(...) is the list-variable handoff. When SolverForge
changes a route list, the app refreshes the derived route totals before
constraints read them.
Codegen Markers
You will see @solverforge:begin and @solverforge:end markers in the domain
and constraint modules. They are scaffold/codegen boundaries. You can read past
them while learning the domain logic.
Prepared Routing Pipeline
This is the heart of the delivery example.
The browser submits a PlanDto to POST /jobs. The backend does:
PlanDto::to_domain()
-> Plan::normalize()
-> prepare_plan(&mut plan).await
-> SolverService::start_job(plan)
Plan::normalize() makes route IDs dense again after transport. If public data
arrived with older delivery IDs, the route lists are remapped onto the current
delivery positions before scoring.
prepare_plan() is the boundary before solving:
- normalize route IDs
- collect delivery coordinates, demand, time windows, and service durations
- build delivery-to-delivery travel data
- build per-vehicle depot-to-delivery and delivery-to-depot legs
- attach
ProblemDatafor SolverForge CVRP hooks - attach
PreparedVehicleRoutingfor app route shadows and previews
In road_network mode, preparation uses solverforge-maps to load or fetch a
road graph and compute matrix data. In straight_line mode, it uses fast draft
geometry with the same app shape.
Preparation creates the travel-time data the solver scores against. Route
drawing is a separate snapshot read: /jobs/{id}/routes turns the selected
solution revision into encoded geometry for the browser.
How Optimization Works
The delivery app uses HardSoftScore.
Read a score as:
{hard}hard/{soft}soft
Hard score records feasibility problems:
- missing deliveries
- capacity overage
- time-window violations
- unreachable route legs
Soft score records route quality:
- total travel seconds
A plan with 0hard/-14000soft is feasible on the hard rules and better than a
plan with 0hard/-18000soft, because both are feasible and the first spends
less time traveling.
Route shadow values keep scoring incremental. Vehicle::refresh_route_shadows()
walks the current delivery_order and updates total demand, capacity overage,
travel seconds, lateness, and unreachable legs. Constraints then read one
derived value per route instead of recalculating the route from scratch.
Writing Constraints
Open src/constraints/.
create_constraints() assembles four rules:
pub fn create_constraints() -> impl ConstraintSet<Plan, HardSoftScore> {
(
all_deliveries_assigned::constraint(),
vehicle_capacity::constraint(),
delivery_time_windows::constraint(),
total_travel_time::constraint(),
)
}
Read them in this order:
all_deliveries_assigned.rsFlatten everyVehicle.delivery_orderand hard-penalize deliveries that do not appear in any route.vehicle_capacity.rsHard-penalize positive capacity overage from the route shadows.delivery_time_windows.rsHard-penalize route lateness and unreachable-leg pressure from the shadows.total_travel_time.rsSoft-penalize travel seconds so feasible route plans can be compared.
This mirrors the beginner routing pattern:
assign every stop -> keep each route feasible -> minimize travel
Solver Policy
solver.toml is embedded by Plan and drives the solve.
The policy starts with two construction phases:
list_clarke_wrightbuilds initial delivery routes from depot, delivery, distance, load, and capacity hookslist_k_optimproves route edge structure before local search starts
Then local search combines list-aware route edits:
nearby_list_change_move_selectormove one delivery to another route or positionnearby_list_swap_move_selectorexchange deliveries between route positionslist_reverse_move_selectorreverse a contiguous route segmentk_opt_move_selectorreconnect route edgeslist_ruin_move_selectorremove a small group of visits and reinsert them elsewhere- limited
sublist_change_move_selectormove a short contiguous run while keeping neighborhood size bounded
For beginners, this is the difference between writing one greedy dispatcher and building a search space. The solver starts from a route plan and repeatedly asks which route edit improves the score.
API, Routes, and Browser State
Deliveries uses the same retained lifecycle shape as the other SolverForge UI examples:
POST /jobs
GET /jobs/{id}
GET /jobs/{id}/status
GET /jobs/{id}/snapshot
GET /jobs/{id}/analysis
POST /jobs/{id}/pause
POST /jobs/{id}/resume
POST /jobs/{id}/cancel
DELETE /jobs/{id}
GET /jobs/{id}/events
The delivery-specific additions are:
GET /jobs/{id}/routes
POST /recommendations/delivery-insertions
Snapshot-Bound Routes
/jobs/{id}/routes accepts the same optional snapshot_revision={n} query used
by snapshots and analysis. That makes score, route table, and map geometry
describe the same retained solution revision.
The route endpoint rebuilds display geometry for the snapshot. It is not the solver’s scoring matrix. That distinction keeps the solver hot path and browser map path separate.
Insertion Recommendations
/recommendations/delivery-insertions answers a dispatcher-style question:
If I need to insert this delivery into the current plan, where are the best candidate positions?
The endpoint:
- removes the selected delivery from the current plan
- prepares the same route data used by solving
- tries every vehicle and insertion index
- evaluates the resulting preview plan
- returns the best candidates by feasibility first and travel quality second
This is not a separate solver run. It is a fast recommendation pass that reuses the same model and route metrics.
Browser State Guard
static/app/main.mjs keeps route geometry truthful by tracking the active route
identity:
job id + snapshot revision + routing mode
When the user changes datasets, routing mode, or receives a newer snapshot, the
browser invalidates old currentRoutes. A route response is rendered only if it
still matches the active job, snapshot revision, and routing mode.
That is the frontend counterpart to snapshot-bound /jobs/{id}/routes: maps
must never silently draw geometry for a different solution than the table and
score are showing.
Routing Modes
The selected routingMode travels with the submitted plan.
road_networkdefault map-backed mode usingsolverforge-mapsstraight_linedraft/testing mode that keeps the same API and UI shape with faster geometry
In straight_line, both preview scoring and solve submission use draft
geometry. In road_network, both use map-backed travel data.
Making Your First Customization
Change Capacity
Open src/data/data_seed/entrypoints.rs for the capacity ranges. The
city-specific depot and visit coordinates live under:
src/data/data_seed/philadelphia/
src/data/data_seed/hartford/
src/data/data_seed/firenze/
Lowering capacity makes the hard capacity rule more difficult. Raising capacity lets the solver focus more quickly on route travel time.
This teaches the relationship between:
- domain data
- route preparation
- route shadows
- hard feasibility
- list construction
- local search
- score analysis
Change a Time Window
Changing a delivery time window in a city visit file changes route feasibility. Narrower windows create hard pressure unless the route order supports them. Wider windows let the solver prioritize travel quality.
Try Straight-Line Routing
For quick model experiments, submit a plan with routingMode = "straight_line".
It is not a replacement for road-network validation, but it is useful when you
are checking domain, API, and UI behavior before paying full road-preparation
cost.
Testing and Validation
After you have cloned the finished example, or after your manual build-out matches it, run the foundational checks from the app root:
solverforge check
solverforge routes
cargo fmt --check
cargo test
solverforge check validates the app metadata and model wiring. solverforge
routes confirms that the retained lifecycle and delivery-specific endpoints are
visible from the generated Axum route surface.
In the finished example repository, the convenience target is:
make test
That target adds frontend module syntax checks, browserless frontend model tests, and Playwright browser tests.
Run the full example gate before publishing or updating the hosted demo:
make ci-local
make ci-local runs format check, clippy, release build, the standard test
surface, and the Docker/Space image build.
Run the live road-network smoke when you need to prove the map-backed route path:
make test-live-road
Quick Reference
| Need | File or directory |
|---|---|
| App metadata | solverforge.app.toml |
| Solver policy | solver.toml |
| Planning model manifest | src/domain/mod.rs |
| Planning solution | src/domain/plan.rs |
| Delivery fact | src/domain/delivery.rs |
| Vehicle entity and list variable | src/domain/vehicle.rs |
| Route preparation and CVRP hooks | src/domain/route_metrics/ |
| Constraint assembly | src/constraints/mod.rs |
| City demo IDs | src/data/data_seed/entrypoints.rs |
| API routes | src/api/routes.rs |
| DTO contract | src/api/dto.rs |
| SSE endpoint | src/api/sse.rs |
| Solver service | src/solver/service.rs |
| Browser controller | static/app/main.mjs |
| Visible API guide | static/app/ui/api-guide.mjs |
Common Gotchas
- The CLI scaffold is a starting shell, not a generator for the complete deliveries app.
- The full city data generators, route previews, insertion recommendations, and complete frontend live in the Hugging Face Space repository.
- A route is an ordered list of delivery IDs, not delivery structs.
Plan::normalize()keeps route IDs dense after transport.- Road-network preparation builds scoring data;
/jobs/{id}/routesbuilds display geometry for a snapshot. - Route shadows are what constraints read.
- Snapshot, analysis, and routes should use the same
snapshot_revision. - The selected
routingModemust match the routes the browser is drawing. deleteremoves a terminal retained job;cancelstops a live or paused one.- The finished app intentionally uses published crates.io dependencies.