Documentation
A long-form worked example that starts with solverforge-cli and carries one concrete hospital scheduling app through retained jobs, constraints, and browser updates
SolverForge Hospital Use Case
Table of Contents
- Introduction
- Getting Started
- The Problem We’re Solving
- The Teaching Spine
- Understanding the Data Model
- The Demo Dataset
- How Optimization Works
- Writing Constraints
- Solver Policy
- Runtime and Browser Behavior
- 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 a concrete hospital scheduling
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.
That split is intentional. solverforge-cli gives you the runnable shell,
managed model seams, retained job lifecycle, sample data, and neutral frontend.
The hospital use case still needs manual domain code: the deterministic
benchmark generator, scheduling constraints, API DTOs, and complete browser UI
live in the finished example.
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
- replace the neutral
HardSoftScoreshell with the currentHardSoftDecimalScorehospital contract - grow the domain into the current hospital model with
Employee,CareHub,Shift, andPlan - follow how
employee_idxmoves through constraints, solver policy, retained jobs, snapshots, and the browser - keep the stock retained
/jobslifecycle while landing on the current hospital UI and data contract
No optimization background required. The article explains the end-to-end path. The code comments in the example repo explain the local intent at the exact place where a concept is implemented.
Prerequisites
- Basic Rust knowledge: structs, enums, traits, closures, modules, derive macros
- Familiarity with HTTP APIs
- Comfort with command-line work
- Node.js if you want to run the browserless frontend tests
- Rust
1.95+, matching the checked-in SolverForge use-case runtime line
Getting Started
Start with the Generic CLI Shell
Start with the public CLI flow:
cargo install solverforge-cli --force
solverforge --version
solverforge new solverforge-hospital --quiet
cd solverforge-hospital
Those commands give you the neutral scaffold. It is runnable, but it is not the hospital app yet. The current hospital app specializes that generated project into scalar employee scheduling through manual domain, data, 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 - 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 data generator, 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-hospital
cd solverforge-hospital
The Space source is the finished app: it includes the deterministic hospital benchmark generator, retained-job API, modular schedule frontend, tests, and deployment files. The tutorial below explains how that app is assembled from the CLI scaffold plus manual hospital scheduling code.
Keep the Published Dependency Shape
Start from the CLI’s current published scaffold line, which now targets the
solverforge 0.15.0 crate used by the checked-in hospital use-case source.
Keep the published solverforge-ui 0.6.5 crate for the UI patch line, then add
the hospital app’s normal scheduling and web/runtime dependencies:
[dependencies]
solverforge = { version = "0.15.0", features = [
"serde",
"console",
"verbose-logging",
] }
solverforge-ui = "0.6.5"
rand = "0.10.1"
axum = "0.8.9"
tokio = { version = "1.52.3", features = ["full"] }
tokio-stream = { version = "0.1.18", features = ["sync"] }
tower-http = { version = "0.6.10", features = ["fs", "cors"] }
tower = "0.5.3"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
chrono = { version = "0.4.44", features = ["serde"] }
parking_lot = "0.12.5"
solverforge-cli 2.2.0 scaffolds solverforge 0.15.0 and
solverforge-ui 0.6.5, so the generated baseline already matches the current
checked-in use-case runtime before the tutorial adds hospital-specific code.
Align App Metadata
The current hospital app metadata is intentionally explicit:
[app]
name = "SolverForge Hospital"
starter = "neutral-shell"
shell = "web"
cli_version = "2.2.0"
[runtime]
target = "SolverForge crates.io target"
runtime_crate = "solverforge"
runtime_version = "0.15.0"
ui_crate = "solverforge-ui"
ui_version = "0.6.5"
[demo]
default_size = "large"
available_sizes = ["large"]
[solution]
name = "Plan"
score = "HardSoftDecimalScore"
This records the scaffold lineage and the public crates used by the running app. It is not a local-path development recipe.
Generate the Managed Seams
The scaffold gives you a neutral app. Use the CLI to create the managed seams:
solverforge generate score HardSoftDecimalScore
solverforge generate fact employee \
--field id:String \
--field name:String \
--field home_hub:CareHub \
--field "skills:BTreeSet<String>" \
--field "unavailable_dates:BTreeSet<NaiveDate>" \
--field "undesired_dates:BTreeSet<NaiveDate>" \
--field "desired_dates:BTreeSet<NaiveDate>"
solverforge generate entity shift \
--field "start:NaiveDateTime" \
--field "end:NaiveDateTime" \
--field location:String \
--field care_hub:CareHub \
--field required_skill:String
solverforge generate variable employee_idx \
--entity Shift \
--kind scalar \
--range employees \
--allows-unassigned
solverforge generate constraint assigned_shift --unary --hard
solverforge generate constraint required_skill --join --hard
solverforge generate constraint overlapping_shift --pair --hard
solverforge generate constraint minimum_rest --pair --hard
solverforge generate constraint one_shift_per_day --pair --hard
solverforge generate constraint unavailable_employee --join --hard
solverforge generate constraint undesired_day --join --soft
solverforge generate constraint desired_day --join --soft
solverforge generate constraint balance_assignments --balance --soft
solverforge generate data --mode stub
Those commands are not the final app. They create the managed anchors. They do not generate the hospital benchmark data or the finished frontend. The app code then supplies the scheduling meaning:
- keep the scaffolded
Planas the solution root and switch its score type - add
CareHuband the nearby-search helper functions - replace generated
EmployeeandShiftfields with transport-safe IDs, derived indexes, calendar helpers, skills, availability, and preferences - add
Plan::rebuild_derived_fields()so decoded JSON becomes solver-ready - replace the generated constraint skeletons with the nine scheduling rules
- replace stub data with the deterministic
LARGEbenchmark generator - split the neutral frontend into schedule, lifecycle, analysis, and view-state modules
That is the teaching boundary: the CLI owns repeatable project seams, while the manual code owns hospital-scheduling semantics. When you want to see every manual line in context, keep the Space checkout open next to this article.
Project Shape
The current hospital example is organized as:
solverforge-hospital/
├── Cargo.toml
├── solver.toml
├── solverforge.app.toml
├── src/
│ ├── api/
│ ├── constraints/
│ ├── data/
│ ├── domain/
│ ├── solver/
│ ├── lib.rs
│ └── main.rs
└── static/
├── app/
├── generated/ui-model.json
├── index.html
└── sf-config.json
Read the article for the cross-layer story, then read the comments in each file for local intent.
The Problem We’re Solving
Hospital scheduling asks:
Given a hospital workforce and a month of shifts, which employee should cover each shift?
The current public demo ships one serious deterministic instance:
- 50 employees
- 688 shifts
- one public dataset id:
LARGE - two browser views:
By locationandBy employee - retained solve lifecycle with status, snapshot, analysis, pause, resume, cancel, delete, and SSE
The score model separates feasibility from quality:
| Rule | Kind | Meaning |
|---|---|---|
| Assigned shift | Hard | Every shift should be assigned to someone |
| Required skill | Hard | The assigned employee must have the required skill |
| Overlapping shift | Hard | One employee cannot cover overlapping shifts |
| Minimum rest | Hard | At least 10 hours between two shifts |
| One shift per day | Hard | One employee should not work two shifts on the same touched day |
| Unavailable employee | Hard | Unavailable dates are hard violations |
| Undesired day | Soft | Softly penalize assignments on undesired dates |
| Desired day | Soft | Reward assignments on desired dates |
| Balance assignments | Soft | Discourage concentrating too many shifts on one employee |
Hard constraints define feasibility. Soft constraints define quality among feasible schedules.
The Teaching Spine
The hospital app is the scalar-assignment tutorial in the SolverForge examples. Its core path is:
Employeeis immutable problem data.Shiftis the planning entity.Shift.employee_idxis the scalar planning variable SolverForge mutates.- Constraints score each proposed assignment.
solver.tomldefines how the solver constructs and improves schedules.- The retained job API keeps snapshots, analysis, pause, resume, cancel, and delete available to the browser.
static/app/main.mjsrenders schedule views from the latest retained plan.
The comments in the repo do not repeat this whole story. They explain the local reasoning at the point of implementation: why a field is skipped in JSON, why a constraint is proportional to minutes, why nearby meters are hints rather than rules, or why a generator pass exists.
Understanding the Data Model
Open src/domain/.
The domain is deliberately small:
care_hub.rssearch-facing service-line proximity signalemployee.rsproblem fact, transport identity, skills, availability, preferences, and derived runtime helpersplan.rsShift,Plan, scalar planning variable, normalization, and nearby metersmod.rstheplanning_model!manifest and public exports
Employee as Problem Fact
Employee is input data. The solver reads employees but does not move them.
The transport-visible identity is Employee.id; the solver-facing join key is
the dense Employee.index rebuilt during normalization.
This split matters because APIs need stable human-readable IDs, while hot solver paths need cheap index comparisons.
Shift as Planning Entity
Shift is the movable thing:
#[planning_entity]
pub struct Shift {
#[planning_id]
pub id: String,
#[serde(skip)]
pub index: usize,
pub start: NaiveDateTime,
pub end: NaiveDateTime,
pub location: String,
pub required_skill: String,
#[planning_variable(
value_range_provider = "employees",
allows_unassigned = true,
nearby_value_distance_meter = "shift_to_employee_nearby_distance",
nearby_entity_distance_meter = "shift_to_shift_nearby_distance"
)]
pub employee_idx: Option<usize>,
}
employee_idx points into Plan.employees, not to Employee.id. That is the
central scalar-assignment idea in this example.
Plan as Planning Solution
Plan carries the two collections and the score:
#[planning_solution(
constraints = "crate::constraints::create_constraints",
solver_toml = "../../solver.toml"
)]
pub struct Plan {
#[problem_fact_collection]
pub employees: Vec<Employee>,
#[planning_entity_collection]
pub shifts: Vec<Shift>,
#[planning_score]
pub score: Option<HardSoftDecimalScore>,
}
Plan::rebuild_derived_fields() is the normalization boundary. It restores
employee indexes, inferred CareHub values, touched calendar dates, and
range-safe assignments after generation or HTTP decoding.
Domain Manifest
src/domain/mod.rs is not just a list of modules. It is the SolverForge model
manifest:
solverforge::planning_model! {
root = "src/domain";
// @solverforge:begin domain-exports
mod care_hub;
mod employee;
mod plan;
pub use care_hub::CareHub;
pub use employee::Employee;
pub use plan::{Plan, Shift};
// @solverforge:end domain-exports
}
The @solverforge markers are scaffold/codegen boundary markers. You can read
past them while learning the domain; they exist so generated edits know where
managed exports begin and end.
The Demo Dataset
The current app exposes one public dataset:
["LARGE"]
The route handler reads that list from data::list_demo_data(), so the public
API and the generator stay aligned.
The dataset is not random filler. It is a deterministic benchmark designed to teach real search:
- Workforce blueprints define skill mix and service-line identity.
- Demand templates generate the public shifts.
- A hidden feasible witness roster proves the generator can shape a hard feasible problem.
- Availability and preference passes add real pressure without destroying that feasibility margin.
- Validation checks the exact public plan before it is served.
The hidden witness is never shown to the solver. It exists only to shape a public problem that has both hard-feasible assignments and useful soft-score movement.
How Optimization Works
Traditional programming says: “do this, then do that.”
Constraint-based optimization says: “here is the domain, here are the rules, and here is what better means.”
The hospital example uses HardSoftDecimalScore.
Hard score records feasibility problems. Soft score records quality once the plan is feasible enough to compare. The current constraint modules use one fixed-point scale:
const SCORE_SCALE: i64 = 100_000;
Using the same scale across rules lets the app express very different priorities without changing the shape of the score model. A wrong-skilled assignment can be much worse than an unassigned shift. Preferences can remain small enough to matter only after hard problems are under control.
Writing Constraints
Open src/constraints/.
Each sibling file defines one incremental rule. create_constraints() assembles
those rules into the scoring model:
pub fn create_constraints() -> impl ConstraintSet<Plan, HardSoftDecimalScore> {
(
assigned_shift::constraint(),
required_skill::constraint(),
overlapping_shift::constraint(),
minimum_rest::constraint(),
one_shift_per_day::constraint(),
unavailable_employee::constraint(),
undesired_day::constraint(),
desired_day::constraint(),
balance_assignments::constraint(),
)
}
Read the constraint files in this order:
assigned_shift.rsA unary hard rule over unassignedShiftentities.required_skill.rsA fact join fromShift.employee_idxtoEmployee.index.overlapping_shift.rs,minimum_rest.rs, andone_shift_per_day.rsSelf-joins that compare two shifts for the same employee.unavailable_employee.rsA fact join with a minute-proportional hard penalty.desired_day.rsandundesired_day.rsSoft preference signals from touched calendar dates.balance_assignments.rsA grouped balance rule overemployee_idx.
The teaching pattern is the same in every file:
stream shape -> join/filter -> score impact -> why the weight matters
Constraints do not assign employees. They score the assignments the solver is considering.
Required Skill
The required-skill rule is the best first join to read. It matches a shift to
its employee by comparing shift.employee_idx with Some(employee.index), then
hard-penalizes missing skills.
That is the scalar index pattern used throughout the app.
Availability and Preferences
unavailable_employee is hard and minute-proportional. If a shift overlaps an
unavailable date by more minutes, the penalty is larger.
desired_day and undesired_day are soft and date-count based. They shape which
feasible schedule is better, but they do not decide feasibility.
Balance
balance_assignments groups shifts by employee_idx and softly penalizes
uneven assignment counts. This is the scheduling meaning behind the compact
.balance(...) stream in the code.
Solver Policy
solver.toml is embedded by Plan and is the runtime source of truth:
random_seed = 1
[termination]
seconds_spent_limit = 30
unimproved_seconds_spent_limit = 5
[[phases]]
type = "construction_heuristic"
construction_heuristic_type = "cheapest_insertion"
entity_class = "Shift"
variable_name = "employee_idx"
[[phases]]
type = "local_search"
[phases.acceptor]
type = "late_acceptance"
late_acceptance_size = 400
[phases.forager]
type = "accepted_count"
limit = 4
[phases.move_selector]
type = "union_move_selector"
[[phases.move_selector.selectors]]
type = "nearby_change_move_selector"
entity_class = "Shift"
variable_name = "employee_idx"
max_nearby = 10
[[phases.move_selector.selectors]]
type = "nearby_swap_move_selector"
entity_class = "Shift"
variable_name = "employee_idx"
max_nearby = 10
Construction assigns shifts one at a time by trying candidate employee values and choosing the least damaging option. Local search then improves that plan by changing assignments and swapping assignments.
Nearby search is a hint layer, not a rule layer. The CareHub meters rank
promising employees and shift pairs before exact scoring. The constraints still
decide feasibility and quality.
Runtime and Browser Behavior
The hospital app keeps the stock retained lifecycle and adapts it to its domain.
The public job API is:
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
src/solver/service.rs wraps SolverManager<Plan>, stores per-job SSE state,
and translates runtime events into the JSON payloads consumed by the browser.
src/api/dto.rs is the transport boundary. PlanDto::to_domain() rebuilds the
domain Plan from flattened JSON and calls normalization before the solver sees
the schedule.
static/app/main.mjs is the browser entrypoint. It loads sf-config.json,
loads static/generated/ui-model.json, creates the shared solverforge-ui
shell, fetches /demo-data/LARGE, and renders the current schedule in both
views.
The neutral scaffold has static/app.js; the current hospital app uses focused
modules under static/app/.
Making Your First Customization
A good first scheduling expansion is a new constraint that touches only the domain rule layer.
For example, a home-hub preference would be a soft fact join:
solverforge generate constraint home_hub_match --join --soft
Then implement the rule by matching Shift.employee_idx to Employee.index,
filtering for employee.home_hub == shift.care_hub, and rewarding the match.
This is the normal SolverForge growth path:
- generate the managed seam
- write the domain rule
- register it in
src/constraints/mod.rs - run the tests and inspect score analysis
The retained backend and browser contract do not have to change for a pure constraint addition.
Testing and Validation
After you have cloned the finished example, or after your manual build-out matches it, run the baseline 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 endpoints are visible in the
generated Axum router.
In the finished example repository, the convenience target is:
make test
That target adds browserless frontend tests and Playwright browser tests.
Run the full example gate before publishing or updating the hosted demo:
make ci-local
The slow acceptance solve is available when you need to prove the public
LARGE dataset reaches a hard-feasible terminal state:
make test-slow
Quick Reference
| Need | File or directory |
|---|---|
| App metadata | solverforge.app.toml |
| Solver policy | solver.toml |
| Planning model manifest | src/domain/mod.rs |
| Employee fact | src/domain/employee.rs |
| Shift entity and scalar variable | src/domain/plan.rs |
| Constraint assembly | src/constraints/mod.rs |
| One scheduling rule | src/constraints/*.rs |
| Public demo-data entrypoints | src/data/data_seed/entrypoints.rs |
| Published benchmark generator | src/data/data_seed/large.rs and siblings |
| 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 |
| Schedule views | static/app/schedule/*.mjs |
Common Gotchas
- The CLI scaffold is a starting shell, not a generator for the complete hospital app.
- The full deterministic data generator and complete frontend live in the Hugging Face Space repository.
employee_idxis an index intoPlan.employees;Employee.idis API/UI identity.- Nearby meters rank candidates before exact scoring; they do not replace constraints.
Plan::rebuild_derived_fields()must run after JSON decoding./demo-datamust agree withdata::list_demo_data().- The current public dataset is
LARGEonly. - The current browser app is module-based and starts from
static/app/main.mjs.