Skip to content

Clinic Appointment SaaS Part 5: Translating Clinic Rules into Timefold Constraints

Small robotic workers connect red mandatory-rule cards and purple preference cards to a Timefold scoring machine on a dark clinic scheduling workbench
To give clinic policy to a solver, separate “must not happen” from “prefer when possible,” then turn each statement into data and a calculable score.

Part 4 separated fast candidate calculation for one patient from global optimization across many appointments. Timefold Solver fits the second problem, but connecting the engine does not define a good schedule.

“Do not schedule a doctor while absent” is relatively clear. “Stay near the patient’s requested date,” “keep the original doctor,” and “balance workload” can conflict. The clinic’s language must become constraints and scores that the solver can compare.

This article follows the current clinic-appointment source: three planning variables, fixed problem facts, 12 hard Constraint Streams, 6 soft constraints, and the resulting HardSoftScore. It also separates what the tests and local benchmark prove from what still needs operational evidence.

Separate Must-Not Rules from Better-If Goals

Section titled “Separate Must-Not Rules from Better-If Goals”

Putting every scheduling requirement into one flat penalty list loses a critical distinction. If an appointment outside operating hours and a changed original doctor are merely two weighted penalties, the solver could trade one for the other. The current model uses two score levels instead.

  • Hard constraint: violating it makes the schedule unacceptable for operation.
  • Soft constraint: ranks schedules that already satisfy every hard rule.

HardSoftScore compares the hard component first and the soft component only between equal hard scores. 0hard/-500soft is feasible with 500 points of preference penalties. -1hard/0soft is infeasible even though its soft component looks better.

This is clinic policy rather than scoring convenience. Moving an appointment away from a preferred date and scheduling it during a closure are different categories of harm. Build the feasible boundary first; optimize operating quality inside that boundary.

Separate Values the Solver Changes from Facts It Reads

Section titled “Separate Values the Solver Changes from Facts It Reads”

AppointmentPlanning is the planning entity. Timefold may change doctorId, appointmentDate, and startTime. @PlanningPin keeps committed or in-progress appointments fixed through the pinned property.

ScheduleSolution provides appointments plus the clinic, doctors, treatments, equipment, hours, breaks, absences, closures, holidays, treatment-equipment mappings, and equipment downtime. These are problem facts: they evaluate candidate schedules but do not move during solving.

Architecture diagram showing AppointmentPlanning variables and fixed ScheduleSolution facts flowing through 12 hard Constraint Streams and 6 soft goals into a HardSoftScore and SolverResult
Planning entities and problem facts meet in the ConstraintProvider. Hard score decides feasibility first; soft score ranks feasible schedules before assignments and score are returned.

The current AppointmentConstraintProvider registers 12 hard functions and 6 soft functions. The labels run from H1 to H11, but H4 is implemented as H4a for weekday breaks and H4b for the clinic default break. That produces 12 hard streams.

override fun defineConstraints(factory: ConstraintFactory): Array<Constraint> = arrayOf(
HardConstraints.withinOperatingHours(factory),
HardConstraints.withinDoctorSchedule(factory),
HardConstraints.noDoctorAbsenceConflict(factory),
HardConstraints.noBreakTimeConflict(factory),
HardConstraints.noDefaultBreakTimeConflict(factory),
// H5 through H11 omitted
SoftConstraints.doctorLoadBalance(factory),
SoftConstraints.minimizeGaps(factory),
SoftConstraints.preferOriginalDoctor(factory),
// S4 through S6 omitted
)

Hard Constraints Reject Schedules the Clinic Cannot Operate

Section titled “Hard Constraints Reject Schedules the Clinic Cannot Operate”

Clinic feasibility is more than avoiding overlapping start times. A candidate must fit clinic hours, doctor work and absence, closures and holidays, concurrent-patient policy, equipment capacity and downtime, and provider type.

LabelCurrent constraintClinic rule
H1withinOperatingHoursThe whole appointment fits active weekday clinic hours.
H2withinDoctorScheduleThe whole appointment fits the assigned doctor’s schedule.
H3noDoctorAbsenceConflictIt does not overlap a full or partial doctor absence.
H4anoBreakTimeConflictIt does not overlap weekday-specific breaks.
H4bnoDefaultBreakTimeConflictIt does not overlap the clinic default break.
H5noClinicClosureConflictIt does not overlap a full or partial clinic closure.
H6noHolidayConflictA clinic closed on holidays does not accept holiday appointments.
H7maxConcurrentPatientsPerDoctorThe doctor’s concurrent-patient policy is respected.
H8equipmentAvailabilityConcurrent use does not exceed equipment quantity.
H9providerTypeMatchThe doctor’s provider type matches the treatment requirement.
H10doctorBelongsToClinicThe assigned doctor belongs to the appointment’s clinic.
H11equipmentUnavailabilityConflictThe appointment avoids maintenance and failure intervals.

Constraint Streams connect appointments to problem facts using join, ifExists, and ifNotExists. H3, for example, finds an absence for the same doctor and date, then penalizes a full-day absence or an overlapping interval.

fun noDoctorAbsenceConflict(factory: ConstraintFactory): Constraint =
factory.forEach(AppointmentPlanning::class.java)
.filter { it.doctorId != null && it.appointmentDate != null && it.startTime != null }
.ifExists(
DoctorAbsenceRecord::class.java,
Joiners.equal({ it.doctorId!! }, { it.doctorId }),
Joiners.equal({ it.appointmentDate!! }, { it.absenceDate }),
Joiners.filtering { appointment, absence ->
absence.startTime == null ||
(absence.endTime != null &&
appointment.startTime!! < absence.endTime &&
absence.startTime < appointment.endTime!!)
},
)
.penalize(HardSoftScore.ONE_HARD)

The comparison uses the appointment’s end as well as its start. A 30-minute treatment beginning at 10:50 overlaps an 11:00 break. Full-day versus partial absence and full-day versus partial closure similarly require explicit data interpretation.

Many schedules can satisfy all hard constraints. One keeps the original doctor but leaves large gaps. Another compacts the day while moving the patient farther from the requested date. The current model compares six goals.

LabelGoalPenalty
S1Balance appointments among doctors100 per appointment pair on the same doctor and date
S2Minimize gaps in a doctor’s day10 per minute between separated appointments
S3Keep the original doctor during rescheduling1,000 when doctorId differs from originalDoctorId
S4Prefer earlier rescheduling dates10 per day after the requested date
S5Group appointments using the same equipment5 per gap minute between equipment reservations
S6Stay close to the patient’s requested date500 per absolute day of difference

Weights encode operating priorities, but a single weight is not the final priority. S3’s 1,000-point penalty is larger than one S1 pair at 100, yet pair and minute penalties can occur many times across a full schedule. Entity count and accumulation determine the total trade-off.

S4 and S6 also have different directions. S4 penalizes only dates later than requested to encourage early rescheduling. S6 penalizes distance on either side to preserve closeness to the original request. Whether both should remain and whether weights vary by clinic are policy questions that need explicit review.

One aggregate score does not tell an administrator why a doctor changed or a date moved. Names such as H3 noDoctorAbsenceConflict and S3 preferOriginalDoctor make Constraint Match analysis possible. An operational API or UI can use those matches to show which appointment incurred which penalty.

The current SolverResult returns the aggregate HardSoftScore, assignments, feasibility, solve duration, entity count, and pinned count. It does not yet expose per-constraint explanations. A staff approval workflow needs more than “this score is better”; explaining the proposed changes is a follow-up requirement.

Pinned appointments also belong in that explanation. They do not move, but they reduce the time and resources available to other entities. Recording date range, total entities, and pinned count makes runs easier to compare and reproduce.

Verify One Constraint with a Small Scenario

Section titled “Verify One Constraint with a Small Scenario”

Full solver tests make it difficult to identify which constraint caused a score change. ConstraintVerifierTest selects one constraint, supplies the smallest useful appointments and facts, and asserts the expected penalty.

Current tests include representative hard rules for operating hours, doctor absence, closure, provider type, and clinic ownership, plus soft rules for load balance and original doctor. They do not provide one isolated test for every one of the 18 registered functions. Boundary times, capacities greater than one, and accumulated penalties need further regression coverage.

Source review also exposes a calculation limit. H7 and H8 form overlapping appointment pairs and penalize only when allowed capacity is below two. They do not generally count a third or fourth simultaneous appointment when doctor capacity or equipment quantity is two or higher. Supporting arbitrary capacity requires grouped concurrent-use counting and tests rather than assuming the requirement table already matches the implementation.

The repository’s solver benchmark report records a 2026-04-19 run on Apple M4 Pro and JDK 25. Every scenario returned a feasible solution with a hard score of zero.

Benchmark chart comparing measured solve time with configured limits for 10, 30, and 100 appointments, alongside HardSoftScore, score calculations per second, and search-space scale
Measured runs finished below their configured limits and produced feasible solutions. These values are a local regression baseline, not a production latency SLA or proof of optimality.
ScenarioAppointmentsMeasured / limitScoreScores per second
Small105,030ms / 10,000ms0hard/0soft133,758
Medium308,270ms / 15,000ms0hard/-500soft100,613
Large10016,163ms / 30,000ms0hard/-2000soft116,135

The reported search space grows from 3.57 × 10²² to 3.37 × 10³²⁵, making exhaustive enumeration impractical. The current configuration builds an initial solution with FIRST_FIT_DECREASING Construction Heuristic and improves it with LATE_ACCEPTANCE Local Search. The time limit is a search budget, not an optimality guarantee.

Score, time, and input size must be read together. A lower soft score in a larger scenario does not mean the algorithm regressed; more appointments create more possible penalty matches. Production validation needs clinic-shaped datasets that include actual appointment distributions, equipment quantities, and pinned ratios.

Constraint code is not a one-time algorithm setting. Holiday operation, new equipment, higher concurrent capacity, or different patient priorities all change the facts, calculation, or weights.

An operable constraint policy needs:

  • a stable constraint name, business description, and scope;
  • the reason it is hard or soft and the reason for its weight;
  • representative before-and-after schedules and score differences;
  • isolated constraint tests plus full-solver regression benchmarks;
  • the person who approved the result and when it became the live schedule.

Part 6 follows these constraints into operational incidents. Clinic closures and equipment failures move appointments to PENDING_RESCHEDULE and connect candidate generation, global optimization, approval, history, and notification.

Comments

Leave a note or reaction with your GitHub account.