Documentation
A field-service routing quickstart that shows technicians, service visits, skills, parts, shifts, road-network travel, retained jobs, and route geometry in one SolverForge app
SolverForge FSR Use Case
Table of Contents
- Introduction
- Getting Started
- The Problem We’re Solving
- The Teaching Spine
- Understanding the Data Model
- Bergamo Routing Data
- How Optimization Works
- Writing Constraints
- Solver Policy
- Runtime and Browser Behavior
- Making Your First Customization
- Testing and Validation
- Quick Reference
Introduction
This guide shows how the generic
solverforge-cli Getting Started
shell becomes solverforge-fsr, a field-service routing app for technicians in
Bergamo. It is the field-service counterpart to the
SolverForge Deliveries Use Case:
both use list variables and road-network data, but FSR adds technician skills,
parts, shifts, territories, route reachability, and snapshot-bound route
geometry for service dispatch.
The app answers one concrete question:
Given technicians, service visits, skills, parts, shifts, territories, and road-network travel, which technician should serve each visit and in what order?
You will:
- install
solverforge-cliand scaffold a neutral SolverForge app - know when to switch from the learning scaffold to the complete FSR Space repository
- keep the published SolverForge 0.11.1 dependency shape
- understand why field-service routing uses a list planning variable
- follow the current
Location,ServiceVisit,TravelLeg,TechnicianRoute, andFieldServicePlanmodel - see where Bergamo OSM data becomes the travel-leg matrix used by constraints
- read the ten route constraints in their intended order
- use retained jobs, snapshots, analysis, SSE, and
/jobs/{id}/routes - validate the app locally before publishing the Space
No dispatch or route-optimization background required. The article keeps the domain concepts explicit and points to the exact source files that carry the full implementation.
Prerequisites
- Rust
1.95+, matching the current published SolverForge app line cargoand a stable Rust toolchain- Basic Rust knowledge: structs, enums, traits, modules, derive macros
- Familiarity with HTTP APIs
- Node.js if you want frontend syntax validation
- Docker if you want to run the Hugging Face Space image locally
Getting Started
Start with the Generic CLI Shell
Start from an empty working directory:
cargo install solverforge-cli --force
solverforge --version
solverforge new solverforge-fsr --quiet
cd solverforge-fsr
Those commands give you the neutral scaffold. It is runnable, but it is not the field-service app yet. The finished FSR app specializes that scaffold into a road-network technician-routing application through manual domain, routing, constraint, API, and browser code.
Right after scaffolding, the generated project already gives you:
- a runnable Axum server
- retained
/jobsroutes and solver lifecycle controls - a planning solution, score field, solver config, and generated seams
solverforge-uiintegration- a neutral frontend shell
- compiler-owned sample-data and codegen markers
Use the fresh scaffold as the learning workspace. Use the finished Space repository when you want the complete Bergamo dataset, routing preparation, route geometry, constraints, frontend, Dockerfile, and validation pipeline.
Download the Finished Example
Clone the finished app from the Hugging Face Space repository:
git clone https://huggingface.co/spaces/SolverForge/solverforge-fsr
cd solverforge-fsr
The Space source is the reference implementation. It includes the Bergamo technicians, service locations, OSM routing setup, retained API, map workspace, score analysis surface, route tables, Docker build, and tests.
Keep the Published Dependency Shape
The current finished app targets the published SolverForge 0.11.1 line:
[dependencies]
solverforge = { version = "0.11.1", features = [
"serde",
"console",
"verbose-logging",
] }
solverforge-core = "0.11.1"
solverforge-ui = "0.6.5"
solverforge-maps = "2.1.4"
solverforge-core is a direct dependency because this app writes custom
incremental constraints that hold ConstraintRef. Most generated applications
only need the top-level solverforge facade.
The app contract in solverforge.app.toml names the current scaffold target:
[app]
name = "solverforge-fsr"
starter = "neutral-shell"
cli_version = "2.0.4"
[runtime]
target = "solverforge 0.11.1"
runtime_source = "crates.io: solverforge 0.11.1"
ui_source = "crates.io: solverforge-ui 0.6.5"
maps_source = "crates.io: solverforge-maps 2.1.4"
[solution]
name = "FieldServicePlan"
score = "HardSoftScore"
Generate the Managed Seams
The CLI can create the repeatable app seams:
solverforge generate fact location
solverforge generate fact service_visit
solverforge generate fact travel_leg
solverforge generate entity technician_route
solverforge generate variable visits \
--entity TechnicianRoute \
--kind list \
--elements service_visits
solverforge generate constraint assigned_visits --unary --hard
solverforge generate constraint reachable_legs --unary --hard
solverforge generate constraint required_skills --unary --hard
solverforge generate constraint required_parts --unary --hard
solverforge generate constraint shift_capacity --unary --hard
solverforge generate constraint time_windows --unary --hard
solverforge generate constraint minimize_travel --unary --soft
solverforge generate constraint balance_workload --unary --soft
solverforge generate constraint territory_affinity --unary --soft
solverforge generate constraint priority_slack --unary --soft
Those commands are the learning skeleton, not the full finished app. The FSR repository then supplies the field-service-specific work:
- Bergamo depots, customer locations, technician profiles, visit profiles, and catalog masks
- the road-network fetch and matrix build using
solverforge-maps TravelLegfacts derived from matrix duration, distance, and reachability- custom incremental constraints over whole technician routes
/jobs/{id}/routesfor snapshot-bound map geometry- a browser workspace with map, route list, timeline, raw data, and score analysis views
- Docker/Space validation commands
Run the Finished App
From the finished repository:
make run-release
Open:
http://localhost:7860
The first run may need to fetch and cache Bergamo OSM data. The app cache path is:
.osm_cache/field-service-routing/bergamo
To inspect the command surface:
make help
To run the Hugging Face Space image locally:
make space-build
make space-run
Inspect Demo Data
List available demos:
curl http://localhost:7860/demo-data
Load the standard Bergamo plan:
curl http://localhost:7860/demo-data/STANDARD
Current dataset shape:
| Dataset | Technicians | Service Visits | Locations | Purpose |
|---|---|---|---|---|
STANDARD |
6 | 48 | 26 | Bergamo road-network field-service demo |
The Problem We’re Solving
Field-service routing asks the solver to make two linked decisions:
- which technician owns each service visit
- where each visit appears inside that technician’s route
That differs from a simple assignment problem. A technician route is ordered:
depot -> visit 03 -> visit 17 -> visit 08 -> depot
Changing that order changes travel time, shift feasibility, reachability, and time-window slack. The app therefore models each technician as one planning entity with one list variable. The solver mutates the ordered visit list, and the constraints score the resulting route.
The domain also includes field-service details that plain delivery routing does not cover:
- service visits require skills and parts
- technicians carry skills, inventory, territory, shift bounds, and route caps
- road-network legs can be unreachable
- high-priority visits are better when served with deadline slack
- balanced workload matters after feasibility is satisfied
The Teaching Spine
The finished app’s core path is:
Locationstores depots and service-location coordinates.ServiceVisitstores job duration, time window, skills, parts, priority, and territory.TechnicianRouteis the planning entity.TechnicianRoute.visitsis the list planning variable.TravelLegstores matrix duration, distance, and reachability.FieldServicePlanis the planning solution.generate(STANDARD)builds the initial Bergamo plan.prepare_routing(&mut plan)loads or fetches the OSM road graph and computes the travel matrix.SolverService::start_job(plan)starts a retained solve./jobs/{id}/snapshot,/jobs/{id}/analysis, and/jobs/{id}/routesexpose the selected retained solution revision to the browser.
The important boundary is that scoring and display geometry are related but not
the same operation. The solver scores against prepared TravelLeg facts. The
route endpoint rebuilds display geometry for a particular retained snapshot so
the map, route list, score, and analysis stay aligned.
Understanding the Data Model
Open src/domain/ in the finished repository.
| File | Role |
|---|---|
location.rs |
Problem fact for depots and customer locations, stored as integer microdegrees with lat() and lng() helpers |
service_visit.rs |
Problem fact for service job identity, customer, location index, duration, time window, required skill mask, required parts mask, priority, and territory |
travel_leg.rs |
Problem fact for from-location, to-location, duration, distance, and reachability |
technician_route.rs |
Planning entity for technician identity, depot indexes, shift bounds, maximum route minutes, skill mask, inventory mask, territory, and visits |
field_service_plan.rs |
Planning solution with locations, service_visits, travel_legs, technician_routes, and score |
mod.rs |
Domain exports |
Service Visits Are Problem Facts
ServiceVisit records the work that needs to be scheduled. The solver reads
those records but does not mutate them.
The fields that matter most for routing are:
location_idxduration_minutesearliest_minutelatest_minuterequired_skill_maskrequired_parts_maskpriorityterritory
Technician Routes Are Planning Entities
TechnicianRoute owns the mutable route:
TechnicianRoute.visits: Vec<usize>
Each item in visits is an index into FieldServicePlan.service_visits.
Keeping the route as indexes avoids copying service records into the mutable
planning entity and keeps route edits small.
Travel Legs Are Prepared Facts
TravelLeg is prepared before solving. For each location pair, it records:
- duration in seconds
- distance in meters
- whether the road-network route is reachable
Constraints use those facts to decide whether a route is feasible and how much travel it carries.
Bergamo Routing Data
Open src/data/.
| File | Role |
|---|---|
bergamo_locations.rs |
Two depots and the service-location catalog |
bergamo_technicians.rs |
Six technician profiles, colors, depot indexes, skill masks, inventory masks, and territories |
bergamo_profiles.rs |
Six recurring service profiles with duration, time window, skills, parts, and priority |
bergamo_catalog.rs |
Skill, parts, location, technician, and visit-profile seed types |
data_seed.rs |
Demo catalog, plan generation, road-network loading, matrix computation, and TravelLeg construction |
DemoData::Standard builds 48 service visits by cycling the visit profiles
across the Bergamo service locations. prepare_routing() then:
- converts every
Locationinto asolverforge_maps::Coord - loads or fetches the Bergamo road network
- computes a travel-time matrix
- writes one
TravelLegfor each location pair
The road-network bounding box is scoped to Bergamo, and the OSM cache lives
inside .osm_cache/field-service-routing/bergamo. This keeps the finished Space
self-contained after the cache has been populated in the running environment.
How Optimization Works
FSR uses HardSoftScore.
Hard score records feasibility:
- every service visit appears exactly once
- every route leg is reachable
- assigned visits match technician skills
- assigned visits match technician inventory
- routes fit inside shift and route-cap limits
- visits start before their latest service minute
Soft score records route quality and dispatcher preference:
- lower road travel time and distance
- balanced workload
- familiar territory
- slack for high-priority visits
A plan with 0hard/-420soft is feasible on the hard rules and better than
0hard/-600soft, because both satisfy hard constraints and the first loses
less soft score.
Writing Constraints
Open src/constraints/.
create_constraints() assembles ten rules:
| Constraint | Type | Purpose |
|---|---|---|
assigned_visits |
Hard | Penalizes unassigned, duplicate, or invalid visit indexes |
reachable_legs |
Hard | Penalizes depot-to-visit, visit-to-visit, and visit-to-depot legs that cannot be routed |
required_skills |
Hard | Penalizes visits assigned to technicians without the required skill mask |
required_parts |
Hard | Penalizes visits assigned to technicians without the required parts mask |
shift_capacity |
Hard | Penalizes routes that exceed the technician shift or max route minutes |
time_windows |
Hard | Penalizes late service starts |
minimize_travel |
Soft | Penalizes route travel minutes and travel kilometers |
balance_workload |
Soft | Penalizes concentrated service and travel load |
territory_affinity |
Soft | Rewards visits inside the technician’s territory |
priority_slack |
Soft | Rewards high-priority visits served with slack before the deadline |
Most route constraints share RouteConstraint, a custom incremental constraint
adapter that evaluates one technician route at a time and reports standard
SolverForge score-analysis metadata. assigned_visits is separate because it
must reason about coverage across all routes.
Read the hard rules first. They define whether the dispatch plan is usable. Then read the soft rules. They define which usable route plan is preferred.
Solver Policy
solver.toml drives the solve:
[[phases]]
type = "construction_heuristic"
construction_heuristic_type = "list_round_robin"
[[phases]]
type = "local_search"
[termination]
seconds_spent_limit = 60
Local search uses a round-robin union of list moves:
list_change_move_selectorlist_swap_move_selectorsublist_change_move_selectorsublist_swap_move_selectorlist_reverse_move_selector
That policy is intentionally easy to inspect. It lets beginners see the route edit vocabulary directly: move a visit, swap visits, move a contiguous run, swap contiguous runs, or reverse a route segment.
Runtime and Browser Behavior
The app keeps the standard retained SolverForge lifecycle:
GET /health
GET /info
GET /demo-data
GET /demo-data/{id}
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 FSR-specific map endpoint is:
GET /jobs/{id}/routes
API, Routes, and Browser State
/jobs/{id}/routes accepts the same optional
snapshot_revision={n} query used by snapshots and analysis. The response
contains one route geometry object per technician route, with segment-level
duration, distance, reachability, encoded polyline, and geometry status:
ROUTEDUNREACHABLE_LEGSNAP_FAILEDNO_PATH
That segment status is deliberate. One failed or unreachable leg should not erase the rest of the snapshot’s route geometry.
The browser is split into small modules under static/:
| File | Role |
|---|---|
app.js |
Lifecycle bootstrap |
app-dataset.js |
Demo-data loading |
app-route-state.js |
Snapshot-bound route fetching |
app-layout.js |
Map and routes workspace layout |
app-render-map.js |
Leaflet/map rendering |
app-render-routes.js |
Route list and timeline rendering |
app-render.js |
Summary, raw data, and score-analysis rendering |
generated/ui-model.json |
Generated SolverForge UI model metadata |
Making Your First Customization
Start with a source change that is easy to verify.
Add a Service Profile
Edit src/data/bergamo_profiles.rs and add a new recurring service profile.
Then run:
make test
The next STANDARD generated plan will cycle through the expanded profile set.
Add a Technician
Edit src/data/bergamo_technicians.rs. Give the technician:
- a stable
id - display
name - route
color - start and end depot indexes
- skill and inventory masks
- territory
Then adjust DemoData::technician_count() in src/data/data_seed.rs if you
want the new technician included in the standard demo.
Tune the Search Policy
Edit solver.toml. For example, increase seconds_spent_limit when you want
the local-search phase to spend more time improving the route plan.
Add a Constraint
Use the generator for the module seam:
solverforge generate constraint emergency_response --unary --hard
Then implement the rule in src/constraints/emergency_response.rs and add it
to the assembled constraint set. Keep hard constraints for requirements and soft
constraints for preferences.
Testing and Validation
Use the finished repository’s Makefile:
make test
That runs Rust tests and frontend syntax checks.
For linting:
make lint
For local Space readiness:
make ci-local
make ci-local runs formatting, Clippy, release build, tests, and Docker image
build. It requires Docker.
For a quick HTTP smoke check after starting the server:
curl http://localhost:7860/health
curl http://localhost:7860/info
curl http://localhost:7860/demo-data
Quick Reference
| Topic | File |
|---|---|
| App contract | solverforge.app.toml |
| Solver policy | solver.toml |
| Solution root | src/domain/field_service_plan.rs |
| List planning variable | src/domain/technician_route.rs |
| Service jobs | src/domain/service_visit.rs |
| Road-network matrix facts | src/domain/travel_leg.rs |
| Demo data and route preparation | src/data/data_seed.rs |
| Constraints | src/constraints/ |
| Retained API | src/api/routes.rs |
| Route geometry DTO | src/api/route_dto.rs |
| Route geometry builder | src/api/route_geometry.rs |
| Solver manager wrapper | src/solver/service.rs |
| Browser workspace | static/ |
| Space build | Dockerfile |
Related docs: