Quarkus Quick Start Guide

This guide walks you through the process of creating a Quarkus application with Timefold's constraint solving Artificial Intelligence (AI).

1. What you will build

You will build a REST application that optimizes a school timetable for students and teachers:

schoolTimetablingScreenshot

Your service will assign Lesson instances to Timeslot and Room instances automatically by using AI to adhere to hard and soft scheduling constraints, such as the following examples:

  • A room can have at most one lesson at the same time.

  • A teacher can teach at most one lesson at the same time.

  • A student can attend at most one lesson at the same time.

  • A teacher prefers to teach all lessons in the same room.

  • A teacher prefers to teach sequential lessons and dislikes gaps between lessons.

  • A student dislikes sequential lessons on the same subject.

Mathematically speaking, school timetabling is an NP-hard problem. This means it is difficult to scale. Simply brute force iterating through all possible combinations takes millions of years for a non-trivial dataset, even on a supercomputer. Luckily, AI constraint solvers such as Timefold Solver have advanced algorithms that deliver a near-optimal solution in a reasonable amount of time.

2. Solution source code

Follow the instructions in the next sections to create the application step by step (recommended).

Alternatively, you can also skip right to the completed example:

  1. Clone the Git repository:

    $ git clone https://github.com/TimefoldAI/timefold-quickstarts

    or download an archive.

  2. Find the solution in the java directory and run it (see its README file).

3. Prerequisites

To complete this guide, you need:

4. The build file and the dependencies

Use code.quarkus.io to generate an application with the following extensions, for Maven or Gradle:

  • RESTEasy JAX-RS (quarkus-resteasy)

  • RESTEasy Jackson (quarkus-resteasy-jackson)

  • Timefold Solver (timefold-solver-quarkus)

  • Timefold Solver Jackson (timefold-solver-quarkus-jackson)

  • Maven

  • Gradle

Your pom.xml file has the following content:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>org.acme</groupId>
  <artifactId>school-timetabling</artifactId>
  <version>1.0-SNAPSHOT</version>

  <properties>
    <maven.compiler.release>11</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

    <version.io.quarkus>3.14.4</version.io.quarkus>
    <version.ai.timefold.solver>1.14.0</version.ai.timefold.solver>
  </properties>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-bom</artifactId>
        <version>${version.io.quarkus}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
      <dependency>
        <groupId>ai.timefold.solver</groupId>
        <artifactId>timefold-solver-bom</artifactId>
        <version>${version.ai.timefold.solver}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-resteasy</artifactId>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-resteasy-jackson</artifactId>
    </dependency>
    <dependency>
      <groupId>ai.timefold.solver</groupId>
      <artifactId>timefold-solver-quarkus</artifactId>
    </dependency>
    <dependency>
      <groupId>ai.timefold.solver</groupId>
      <artifactId>timefold-solver-quarkus-jackson</artifactId>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>io.quarkus</groupId>
        <artifactId>quarkus-maven-plugin</artifactId>
        <version>${version.io.quarkus}</version>
        <executions>
          <execution>
            <goals>
              <goal>build</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <configuration>
          <systemPropertyVariables>
            <java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
          </systemPropertyVariables>
        </configuration>
      </plugin>
    </plugins>
  </build>
  <profiles>
    <profile>
        <id>native</id>
        <properties>
            <quarkus.package.type>native</quarkus.package.type>
        </properties>
    </profile>
  </profiles>
</project>

Your build.gradle file has this content:

plugins {
    id "java"
    id "io.quarkus" version "3.14.4"
}

def quarkusVersion = "3.14.4"
def timefoldSolverVersion = "1.14.0"

group = "org.acme"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    implementation platform("io.quarkus:quarkus-bom:${quarkusVersion}")
    implementation "io.quarkus:quarkus-resteasy"
    implementation "io.quarkus:quarkus-resteasy-jackson"
    testImplementation "io.quarkus:quarkus-junit5"

    implementation platform("ai.timefold.solver:timefold-solver-bom:${timefoldSolverVersion}")
    implementation "ai.timefold.solver:timefold-solver-quarkus"
    implementation "ai.timefold.solver:timefold-solver-quarkus-jackson"
    testImplementation "ai.timefold.solver:timefold-solver-test"
}

java {
    sourceCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
}

compileJava {
    options.encoding = "UTF-8"
    options.compilerArgs << "-parameters"
}

compileTestJava {
    options.encoding = "UTF-8"
}

test {
    systemProperty "java.util.logging.manager", "org.jboss.logmanager.LogManager"
}

5. Model the domain objects

Your goal is to assign each lesson to a time slot and a room. You will create these classes:

schoolTimetablingClassDiagramPure

5.1. Timeslot

The Timeslot class represents a time interval when lessons are taught, for example, Monday 10:30 - 11:30 or Tuesday 13:30 - 14:30. For simplicity’s sake, all time slots have the same duration and there are no time slots during lunch or other breaks.

A time slot has no date, because a high school schedule just repeats every week. So there is no need for continuous planning.

  • Java

  • Kotlin

  • Python

Create the src/main/java/org/acme/schooltimetabling/domain/Timeslot.java class:

package org.acme.schooltimetabling.domain;

import java.time.DayOfWeek;
import java.time.LocalTime;

public class Timeslot {

    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    public Timeslot() {
    }

    public Timeslot(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
        this.dayOfWeek = dayOfWeek;
        this.startTime = startTime;
        this.endTime = endTime;
    }

    public DayOfWeek getDayOfWeek() {
        return dayOfWeek;
    }

    public LocalTime getStartTime() {
        return startTime;
    }

    public LocalTime getEndTime() {
        return endTime;
    }

    @Override
    public String toString() {
        return dayOfWeek + " " + startTime;
    }

}

Create the src/main/kotlin/org/acme/schooltimetabling/domain/Timeslot.kt class:

package org.acme.schooltimetabling.domain

import java.time.DayOfWeek
import java.time.LocalTime

data class Timeslot(
    val dayOfWeek: DayOfWeek,
    val startTime: LocalTime,
    val endTime: LocalTime) {

    override fun toString(): String = "$dayOfWeek $startTime"

}

Create the Timeslot class in src/hello_world/domain.py:

from dataclasses import dataclass
from datetime import time


@dataclass
class Timeslot:
    day_of_week: str
    start_time: time
    end_time: time

    def __str__(self):
        return f'{self.day_of_week} {self.start_time.strftime('%H:%M')}'

Because no Timeslot instances change during solving, a Timeslot is called a problem fact. Such classes do not require any Timefold Solver specific annotations.

Notice the toString() method keeps the output short, so it is easier to read Timefold Solver’s DEBUG or TRACE log, as shown later.

5.2. Room

The Room class represents a location where lessons are taught, for example, Room A or Room B. For simplicity’s sake, all rooms are without capacity limits and they can accommodate all lessons.

  • Java

  • Kotlin

  • Python

Create the src/main/java/org/acme/schooltimetabling/domain/Room.java class:

package org.acme.schooltimetabling.domain;

public class Room {

    private String name;

    public Room() {
    }

    public Room(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    @Override
    public String toString() {
        return name;
    }

}

Create the src/main/kotlin/org/acme/schooltimetabling/domain/Room.kt class:

package org.acme.schooltimetabling.domain

data class Room(
    val name: String) {

    override fun toString(): String = name

}

Create the Room class in src/hello_world/domain.py:

from dataclasses import dataclass


@dataclass
class Room:
    name: str

    def __str__(self):
        return f'{self.name}'

Room instances do not change during solving, so Room is also a problem fact.

5.3. Lesson

During a lesson, represented by the Lesson class, a teacher teaches a subject to a group of students, for example, Math by A.Turing for 9th grade or Chemistry by M.Curie for 10th grade. If a subject is taught multiple times per week by the same teacher to the same student group, there are multiple Lesson instances that are only distinguishable by id. For example, the 9th grade has six math lessons a week.

During solving, Timefold Solver changes the timeslot and room fields of the Lesson class, to assign each lesson to a time slot and a room. Because Timefold Solver changes these fields, Lesson is a planning entity:

schoolTimetablingClassDiagramAnnotated

Most of the fields in the previous diagram contain input data, except for the orange fields: A lesson’s timeslot and room fields are unassigned (null) in the input data and assigned (not null) in the output data. Timefold Solver changes these fields during solving. Such fields are called planning variables. In order for Timefold Solver to recognize them, both the timeslot and room fields require an @PlanningVariable annotation. Their containing class, Lesson, requires an @PlanningEntity annotation.

  • Java

  • Kotlin

  • Python

Create the src/main/java/org/acme/schooltimetabling/domain/Lesson.java class:

package org.acme.schooltimetabling.domain;

import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
import ai.timefold.solver.core.api.domain.lookup.PlanningId;
import ai.timefold.solver.core.api.domain.variable.PlanningVariable;

@PlanningEntity
public class Lesson {

    @PlanningId
    private String id;

    private String subject;
    private String teacher;
    private String studentGroup;

    @PlanningVariable
    private Timeslot timeslot;
    @PlanningVariable
    private Room room;

    public Lesson() {
    }

    public Lesson(String id, String subject, String teacher, String studentGroup) {
        this.id = id;
        this.subject = subject;
        this.teacher = teacher;
        this.studentGroup = studentGroup;
    }

    public String getId() {
        return id;
    }

    public String getSubject() {
        return subject;
    }

    public String getTeacher() {
        return teacher;
    }

    public String getStudentGroup() {
        return studentGroup;
    }

    public Timeslot getTimeslot() {
        return timeslot;
    }

    public void setTimeslot(Timeslot timeslot) {
        this.timeslot = timeslot;
    }

    public Room getRoom() {
        return room;
    }

    public void setRoom(Room room) {
        this.room = room;
    }

    @Override
    public String toString() {
        return subject + "(" + id + ")";
    }

}

Create the src/main/kotlin/org/acme/schooltimetabling/domain/Lesson.kt class:

package org.acme.schooltimetabling.domain

import ai.timefold.solver.core.api.domain.entity.PlanningEntity
import ai.timefold.solver.core.api.domain.lookup.PlanningId
import ai.timefold.solver.core.api.domain.variable.PlanningVariable

@PlanningEntity
data class Lesson (
    @PlanningId
    val id: String,
    val subject: String,
    val teacher: String,
    val studentGroup: String) {

    @PlanningVariable
    var timeslot: Timeslot? = null

    @PlanningVariable
    var room: Room? = null

    // No-arg constructor required for Timefold
    constructor() : this("0", "", "", "")

    override fun toString(): String = "$subject($id)"

}

Create the Lesson class in src/hello_world/domain.py:

from timefold.solver.domain import planning_entity, PlanningId, PlanningVariable
from dataclasses import dataclass, field
from typing import Annotated


@planning_entity
@dataclass
class Lesson:
    id: Annotated[str, PlanningId]
    subject: str
    teacher: str
    student_group: str
    timeslot: Annotated[Timeslot | None, PlanningVariable] = field(default=None)
    room: Annotated[Room | None, PlanningVariable] = field(default=None)

The Lesson class has an @PlanningEntity annotation, so Timefold Solver knows that this class changes during solving because it contains one or more planning variables.

The timeslot field has an @PlanningVariable annotation, so Timefold Solver knows that it can change its value. In order to find potential Timeslot instances to assign to this field, Timefold Solver uses the variable type to connect to a value range provider that provides a List<Timeslot> to pick from.

The room field also has an @PlanningVariable annotation, for the same reasons.

Determining the @PlanningVariable fields for an arbitrary constraint solving use case is often challenging the first time. Read the domain modeling guidelines to avoid common pitfalls.

6. Define the constraints and calculate the score

A score represents the quality of a specific solution. The higher the better. Timefold Solver looks for the best solution, which is the solution with the highest score found in the available time. It might be the optimal solution.

Because this use case has hard and soft constraints, use the HardSoftScore class to represent the score:

  • Hard constraints must not be broken. For example: A room can have at most one lesson at the same time.

  • Soft constraints should not be broken. For example: A teacher prefers to teach in a single room.

Hard constraints are weighted against other hard constraints. Soft constraints are weighted too, against other soft constraints. Hard constraints always outweigh soft constraints, regardless of their respective weights.

To calculate the score, you could implement an EasyScoreCalculator class:

  • Java

  • Kotlin

  • Python

public class TimetableEasyScoreCalculator implements EasyScoreCalculator<Timetable, HardSoftScore> {

    @Override
    public HardSoftScore calculateScore(Timetable timetable) {
        List<Lesson> lessons = timetable.getLessons();
        int hardScore = 0;
        for (Lesson a : lessons) {
            for (Lesson b : lessons) {
                if (a.getTimeslot() != null && a.getTimeslot().equals(b.getTimeslot())
                        && a.getId() < b.getId()) {
                    // A room can accommodate at most one lesson at the same time.
                    if (a.getRoom() != null && a.getRoom().equals(b.getRoom())) {
                        hardScore--;
                    }
                    // A teacher can teach at most one lesson at the same time.
                    if (a.getTeacher().equals(b.getTeacher())) {
                        hardScore--;
                    }
                    // A student can attend at most one lesson at the same time.
                    if (a.getStudentGroup().equals(b.getStudentGroup())) {
                        hardScore--;
                    }
                }
            }
        }
        int softScore = 0;
        // Soft constraints are only implemented in the timefold-quickstarts code
        return HardSoftScore.of(hardScore, softScore);
    }

}
class TimetableEasyScoreCalculator : EasyScoreCalculator<Timetable, HardSoftScore> {
    override fun calculateScore(solution: Timetable): HardSoftScore {
        val lessons = solution.lessons
        var hardScore = 0
        for (a in lessons) {
            for (b in lessons) {
                if (a.timeslot != null && a.timeslot == b.timeslot && a.id!! < b.id!!) {
                    // A room can accommodate at most one lesson at the same time.
                    if (a.room != null && a.room == b.room) {
                        hardScore--
                    }
                    // A teacher can teach at most one lesson at the same time.
                    if (a.teacher == b.teacher) {
                        hardScore--
                    }
                    // A student can attend at most one lesson at the same time.
                    if (a.studentGroup == b.studentGroup) {
                        hardScore--
                    }
                }
            }
        }
        val softScore = 0
        // Soft constraints are only implemented in the timefold-quickstarts code
        return HardSoftScore.of(hardScore, softScore)
    }
}
from timefold.score.score import easy_score_calculator, HardSoftScore

@easy_score_calculator
def school_timetable_constraints(solution: Timetable):
    lessons = solution.lessons
    hard_score = 0
    for a in lessons:
        for b in lessons:
            if a.timeslot != null and a.timeslot == b.timeslot and a.id < b.id:
                # A room can accommodate at most one lesson at the same time.
                if a.room != null and a.room == b.room:
                    hard_score -= 1

                # A teacher can teach at most one lesson at the same time.
                if a.teacher == b.teacher:
                    hard_score -= 1

                # A student can attend at most one lesson at the same time.
                if a.student_group == b.student_group:
                    hard_score -= 1
    soft_score = 0
    # Soft constraints are only implemented in the timefold-quickstarts code
    return HardSoftScore.of(hard_score, soft_score)

Unfortunately that does not scale well, because it is non-incremental: every time a lesson is assigned to a different time slot or room, all lessons are re-evaluated to calculate the new score.

Instead, create a TimetableConstraintProvider class to perform incremental score calculation. It uses Timefold Solver’s Constraint Streams API which is inspired by Java Streams and SQL:

  • Java

  • Kotlin

  • Python

Create a src/main/java/org/acme/schooltimetabling/solver/TimetableConstraintProvider.java class:

package org.acme.schooltimetabling.solver;

import org.acme.schooltimetabling.domain.Lesson;
import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore;
import ai.timefold.solver.core.api.score.stream.Constraint;
import ai.timefold.solver.core.api.score.stream.ConstraintFactory;
import ai.timefold.solver.core.api.score.stream.ConstraintProvider;
import ai.timefold.solver.core.api.score.stream.Joiners;

public class TimetableConstraintProvider implements ConstraintProvider {

    @Override
    public Constraint[] defineConstraints(ConstraintFactory constraintFactory) {
        return new Constraint[] {
                // Hard constraints
                roomConflict(constraintFactory),
                teacherConflict(constraintFactory),
                studentGroupConflict(constraintFactory),
                // Soft constraints are only implemented in the timefold-quickstarts code
        };
    }

    private Constraint roomConflict(ConstraintFactory constraintFactory) {
        // A room can accommodate at most one lesson at the same time.
        return constraintFactory
                // Select each pair of 2 different lessons ...
                .forEachUniquePair(Lesson.class,
                        // ... in the same timeslot ...
                        Joiners.equal(Lesson::getTimeslot),
                        // ... in the same room ...
                        Joiners.equal(Lesson::getRoom))
                // ... and penalize each pair with a hard weight.
                .penalize(HardSoftScore.ONE_HARD)
                .asConstraint("Room conflict");
    }

    private Constraint teacherConflict(ConstraintFactory constraintFactory) {
        // A teacher can teach at most one lesson at the same time.
        return constraintFactory
                .forEachUniquePair(Lesson.class,
                        Joiners.equal(Lesson::getTimeslot),
                        Joiners.equal(Lesson::getTeacher))
                .penalize(HardSoftScore.ONE_HARD)
                .asConstraint("Teacher conflict");
    }

    private Constraint studentGroupConflict(ConstraintFactory constraintFactory) {
        // A student can attend at most one lesson at the same time.
        return constraintFactory
                .forEachUniquePair(Lesson.class,
                        Joiners.equal(Lesson::getTimeslot),
                        Joiners.equal(Lesson::getStudentGroup))
                .penalize(HardSoftScore.ONE_HARD)
                .asConstraint("Student group conflict");
    }

}

Create a src/main/kotlin/org/acme/schooltimetabling/solver/TimetableConstraintProvider.kt class:

package org.acme.kotlin.schooltimetabling.solver

import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore
import ai.timefold.solver.core.api.score.stream.Constraint
import ai.timefold.solver.core.api.score.stream.ConstraintFactory
import ai.timefold.solver.core.api.score.stream.ConstraintProvider
import ai.timefold.solver.core.api.score.stream.Joiners
import org.acme.kotlin.schooltimetabling.domain.Lesson
import org.acme.kotlin.schooltimetabling.solver.justifications.*
import java.time.Duration

class TimeTableConstraintProvider : ConstraintProvider {

    override fun defineConstraints(constraintFactory: ConstraintFactory): Array<Constraint> {
        return arrayOf(
            // Hard constraints
            roomConflict(constraintFactory),
            teacherConflict(constraintFactory),
            studentGroupConflict(constraintFactory),
            // Soft constraints
            teacherRoomStability(constraintFactory),
            teacherTimeEfficiency(constraintFactory),
            studentGroupSubjectVariety(constraintFactory)
        )
    }

    fun roomConflict(constraintFactory: ConstraintFactory): Constraint {
        // A room can accommodate at most one lesson at the same time.
        return constraintFactory
            // Select each pair of 2 different lessons ...
            .forEachUniquePair(
                Lesson::class.java,
                // ... in the same timeslot ...
                Joiners.equal(Lesson::timeslot),
                // ... in the same room ...
                Joiners.equal(Lesson::room)
            )
            // ... and penalize each pair with a hard weight.
            .penalize(HardSoftScore.ONE_HARD)
            .justifyWith { lesson1: Lesson, lesson2: Lesson, _ ->
                RoomConflictJustification(lesson1.room, lesson1,lesson2)}
            .asConstraint("Room conflict")
    }

    fun teacherConflict(constraintFactory: ConstraintFactory): Constraint {
        // A teacher can teach at most one lesson at the same time.
        return constraintFactory
            .forEachUniquePair(
                Lesson::class.java,
                Joiners.equal(Lesson::timeslot),
                Joiners.equal(Lesson::teacher)
            )
            .penalize(HardSoftScore.ONE_HARD)
            .justifyWith { lesson1: Lesson, lesson2: Lesson, _ ->
                TeacherConflictJustification(lesson1.teacher, lesson1, lesson2)}
            .asConstraint("Teacher conflict")
    }

    fun studentGroupConflict(constraintFactory: ConstraintFactory): Constraint {
        // A student can attend at most one lesson at the same time.
        return constraintFactory
            .forEachUniquePair(
                Lesson::class.java,
                Joiners.equal(Lesson::timeslot),
                Joiners.equal(Lesson::studentGroup)
            )
            .penalize(HardSoftScore.ONE_HARD)
            .justifyWith { lesson1: Lesson, lesson2: Lesson, _ ->
                StudentGroupConflictJustification(lesson1.studentGroup, lesson1, lesson2)}
            .asConstraint("Student group conflict")
    }

    fun teacherRoomStability(constraintFactory: ConstraintFactory): Constraint {
        // A teacher prefers to teach in a single room.
        return constraintFactory
            .forEachUniquePair(
                Lesson::class.java,
                Joiners.equal(Lesson::teacher)
            )
            .filter { lesson1: Lesson, lesson2: Lesson -> lesson1.room !== lesson2.room }
            .penalize(HardSoftScore.ONE_SOFT)
            .justifyWith { lesson1: Lesson, lesson2: Lesson, _ ->
                TeacherRoomStabilityJustification(lesson1.teacher, lesson1, lesson2)}
            .asConstraint("Teacher room stability")
    }

    fun teacherTimeEfficiency(constraintFactory: ConstraintFactory): Constraint {
        // A teacher prefers to teach sequential lessons and dislikes gaps between lessons.
        return constraintFactory
            .forEach(Lesson::class.java)
            .join(Lesson::class.java,
                Joiners.equal(Lesson::teacher),
                Joiners.equal { lesson: Lesson -> lesson.timeslot?.dayOfWeek })
            .filter { lesson1: Lesson, lesson2: Lesson ->
                val between = Duration.between(
                    lesson1.timeslot?.endTime,
                    lesson2.timeslot?.startTime
                )
                !between.isNegative && between <= Duration.ofMinutes(30)
            }
            .reward(HardSoftScore.ONE_SOFT)
            .justifyWith{ lesson1: Lesson, lesson2: Lesson, _ ->
                TeacherTimeEfficiencyJustification(lesson1.teacher, lesson1, lesson2)}
            .asConstraint("Teacher time efficiency")
    }

    fun studentGroupSubjectVariety(constraintFactory: ConstraintFactory): Constraint {
        // A student group dislikes sequential lessons on the same subject.
        return constraintFactory
            .forEach(Lesson::class.java)
            .join(Lesson::class.java,
                Joiners.equal(Lesson::subject),
                Joiners.equal(Lesson::studentGroup),
                Joiners.equal { lesson: Lesson -> lesson.timeslot?.dayOfWeek })
            .filter { lesson1: Lesson, lesson2: Lesson ->
                val between = Duration.between(
                    lesson1.timeslot?.endTime,
                    lesson2.timeslot?.startTime
                )
                !between.isNegative && between <= Duration.ofMinutes(30)
            }
            .penalize(HardSoftScore.ONE_SOFT)
            .justifyWith { lesson1: Lesson, lesson2: Lesson, _ ->
                StudentGroupSubjectVarietyJustification(lesson1.studentGroup, lesson1, lesson2)}
            .asConstraint("Student group subject variety")
    }

}

Create a school_timetabling_constraints function in src/hello_world/constraints.py:

from timefold.solver.score import (constraint_provider, HardSoftScore, Joiners,
                                   ConstraintFactory, Constraint)
from .domain import Lesson

@constraint_provider
def define_constraints(constraint_factory: ConstraintFactory):
    return [
        room_conflict(constraint_factory),
        teacher_conflict(constraint_factory),
        student_group_conflict(constraint_factory),

        # Soft constraints are only implemented in the timefold-quickstarts code
    ]


def room_conflict(constraint_factory: ConstraintFactory) -> Constraint:
    # A room can accommodate at most one lesson at the same time.
    return (constraint_factory
            # Select each pair of 2 different lessons ...
            .for_each_unique_pair(Lesson,
                                  # ... in the same timeslot ...
                                  Joiners.equal(lambda lesson: lesson.timeslot),
                                  # ... in the same room ...
                                  Joiners.equal(lambda lesson: lesson.room))
            # ... and penalize each pair with a hard weight.
            .penalize(HardSoftScore.ONE_HARD)
            .as_constraint("Room conflict"))


def teacher_conflict(constraint_factory: ConstraintFactory) -> Constraint:
    # A teacher can teach at most one lesson at the same time.
    return (constraint_factory
            .for_each_unique_pair(Lesson,
                                  Joiners.equal(lambda lesson: lesson.timeslot),
                                  Joiners.equal(lambda lesson: lesson.teacher))
            .penalize(HardSoftScore.ONE_HARD)
            .as_constraint("Teacher conflict"))


def student_group_conflict(constraint_factory: ConstraintFactory) -> Constraint:
    # A student can attend at most one lesson at the same time.
    return (constraint_factory
            .for_each_unique_pair(Lesson,
                                  Joiners.equal(lambda lesson: lesson.timeslot),
                                  Joiners.equal(lambda lesson: lesson.student_group))
            .penalize(HardSoftScore.ONE_HARD)
            .as_constraint("Student group conflict"))

The ConstraintProvider scales an order of magnitude better than the EasyScoreCalculator: O(n) instead of O(n²).

7. Gather the domain objects in a planning solution

A Timetable wraps all Timeslot, Room, and Lesson instances of a single dataset. Furthermore, because it contains all lessons, each with a specific planning variable state, it is a planning solution and it has a score:

  • If lessons are still unassigned, then it is an uninitialized solution, for example, a solution with the score -4init/0hard/0soft.

  • If it breaks hard constraints, then it is an infeasible solution, for example, a solution with the score -2hard/-3soft.

  • If it adheres to all hard constraints, then it is a feasible solution, for example, a solution with the score 0hard/-7soft.

  • Java

  • Kotlin

  • Python

Create the src/main/java/org/acme/schooltimetabling/domain/Timetable.java class:

package org.acme.schooltimetabling.domain;

import java.util.List;

import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty;
import ai.timefold.solver.core.api.domain.solution.PlanningScore;
import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty;
import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider;
import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore;

@PlanningSolution
public class Timetable {

    @ValueRangeProvider
    @ProblemFactCollectionProperty
    private List<Timeslot> timeslots;
    @ValueRangeProvider
    @ProblemFactCollectionProperty
    private List<Room> rooms;
    @PlanningEntityCollectionProperty
    private List<Lesson> lessons;

    @PlanningScore
    private HardSoftScore score;

    public Timetable() {
    }

    public Timetable(List<Timeslot> timeslots, List<Room> rooms, List<Lesson> lessons) {
        this.timeslots = timeslots;
        this.rooms = rooms;
        this.lessons = lessons;
    }

    public List<Timeslot> getTimeslots() {
        return timeslots;
    }

    public List<Room> getRooms() {
        return rooms;
    }

    public List<Lesson> getLessons() {
        return lessons;
    }

    public HardSoftScore getScore() {
        return score;
    }

}

Create the src/main/kotlin/org/acme/schooltimetabling/TimetableApp.kt class:

package org.acme.schooltimetabling.domain

import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty
import ai.timefold.solver.core.api.domain.solution.PlanningScore
import ai.timefold.solver.core.api.domain.solution.PlanningSolution
import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty
import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider
import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore
import ai.timefold.solver.core.api.solver.SolverStatus

@PlanningSolution
data class Timetable (
    @ProblemFactCollectionProperty
    @ValueRangeProvider
    val timeslots: List<Timeslot>,
    @ProblemFactCollectionProperty
    @ValueRangeProvider
    val rooms: List<Room>,
    @PlanningEntityCollectionProperty
    val lessons: List<Lesson>,
    @PlanningScore
    var score: HardSoftScore? = null) {

    // No-arg constructor required for Timefold
    constructor() : this(emptyList(), emptyList(), emptyList())

}

Create the Solution class in src/hello_world/domain.py:

from timefold.solver.domain import (planning_solution, PlanningEntityCollectionProperty,
                                    ProblemFactCollectionProperty, ValueRangeProvider,
                                    PlanningScore)
from timefold.solver.score import HardSoftScore
from dataclasses import dataclass, field
from typing import Annotated


@planning_solution
@dataclass
class Timetable:
    id: str
    timeslots: Annotated[list[Timeslot],
                         ProblemFactCollectionProperty,
                         ValueRangeProvider]
    rooms: Annotated[list[Room],
                     ProblemFactCollectionProperty,
                     ValueRangeProvider]
    lessons: Annotated[list[Lesson],
                       PlanningEntityCollectionProperty]
    score: Annotated[HardSoftScore, PlanningScore] = field(default=None)

The Timetable class has an @PlanningSolution annotation, so Timefold Solver knows that this class contains all of the input and output data.

Specifically, these classes are the input of the problem:

  • The timeslots field with all time slots

    • This is a list of problem facts, because they do not change during solving.

  • The rooms field with all rooms

    • This is a list of problem facts, because they do not change during solving.

  • The lessons field with all lessons

    • This is a list of planning entities, because they change during solving.

    • Of each Lesson:

      • The values of the timeslot and room fields are typically still null, so unassigned. They are planning variables.

      • The other fields, such as subject, teacher and studentGroup, are filled in. These fields are problem properties.

However, this class is also the output of the solution:

  • The lessons field for which each Lesson instance has non-null timeslot and room fields after solving.

  • The score field that represents the quality of the output solution, for example, 0hard/-5soft.

7.1. The value range providers

The timeslots field is a value range provider. It holds the Timeslot instances which Timefold Solver can pick from to assign to the timeslot field of Lesson instances. The timeslots field has an @ValueRangeProvider annotation to connect the @PlanningVariable with the @ValueRangeProvider, by matching the type of the planning variable with the type returned by the value range provider.

Following the same logic, the rooms field also has an @ValueRangeProvider annotation.

7.2. The problem fact and planning entity properties

Furthermore, Timefold Solver needs to know which Lesson instances it can change as well as how to retrieve the Timeslot and Room instances used for score calculation by your TimetableConstraintProvider.

The timeslots and rooms fields have an @ProblemFactCollectionProperty annotation, so your TimetableConstraintProvider can select from those instances.

The lessons has an @PlanningEntityCollectionProperty annotation, so Timefold Solver can change them during solving and your TimetableConstraintProvider can select from those too.

8. Create the solver service

Now you are ready to put everything together and create a REST service. But solving planning problems on REST threads causes HTTP timeout issues. Therefore, the Quarkus extension injects a SolverManager instance, which runs solvers in a separate thread pool and can solve multiple datasets in parallel.

  • Java

  • Kotlin

Create the src/main/java/org/acme/schooltimetabling/rest/TimetableResource.java class:

package org.acme.schooltimetabling.rest;

import java.util.UUID;
import java.util.concurrent.ExecutionException;
import javax.inject.Inject;
import javax.ws.rs.POST;
import javax.ws.rs.Path;

import org.acme.schooltimetabling.domain.Timetable;
import ai.timefold.solver.core.api.solver.SolverJob;
import ai.timefold.solver.core.api.solver.SolverManager;

@Path("/timetables")
public class TimetableResource {

    @Inject
    SolverManager<Timetable, UUID> solverManager;

    @POST
    @Path("/solve")
    public Timetable solve(Timetable problem) {
        UUID problemId = UUID.randomUUID();
        // Submit the problem to start solving
        SolverJob<Timetable, UUID> solverJob = solverManager.solve(problemId, problem);
        Timetable solution;
        try {
            // Wait until the solving ends
            solution = solverJob.getFinalBestSolution();
        } catch (InterruptedException | ExecutionException e) {
            throw new IllegalStateException("Solving failed.", e);
        }
        return solution;
    }

}

Create the src/main/kotlin/org/acme/schooltimetabling/rest/TimetableResource.kt class:

package org.acme.schooltimetabling.rest

import ai.timefold.solver.core.api.score.analysis.ScoreAnalysis
import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore
import ai.timefold.solver.core.api.solver.ScoreAnalysisFetchPolicy
import ai.timefold.solver.core.api.solver.SolutionManager
import ai.timefold.solver.core.api.solver.SolverManager
import jakarta.inject.Inject
import jakarta.ws.rs.Consumes
import jakarta.ws.rs.DELETE
import jakarta.ws.rs.GET
import jakarta.ws.rs.POST
import jakarta.ws.rs.PUT
import jakarta.ws.rs.Path
import jakarta.ws.rs.PathParam
import jakarta.ws.rs.Produces
import jakarta.ws.rs.QueryParam
import jakarta.ws.rs.core.MediaType
import jakarta.ws.rs.core.Response
import org.acme.schooltimetabling.domain.Timetable
import org.acme.schooltimetabling.rest.exception.ErrorInfo
import org.acme.schooltimetabling.rest.exception.TimetableSolverException
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap
import java.util.function.BiConsumer
import java.util.function.Consumer
import java.util.function.Function

@Path("timetables")
class TimetableResource {

    private val LOGGER: Logger = LoggerFactory.getLogger(TimetableResource::class.java)

    private final var solverManager: SolverManager<Timetable, String>?

    private final var solutionManager: SolutionManager<Timetable, HardSoftScore>?

    // TODO: Without any "time to live", the map may eventually grow out of memory.
    private val jobIdToJob: ConcurrentMap<String, Job> = ConcurrentHashMap()

    // Workaround to make Quarkus CDI happy. Do not use.
    constructor() {
        solverManager = null
        solutionManager = null
    }

    @Inject
    constructor(
        solverManager: SolverManager<Timetable, String>, solutionManager: SolutionManager<Timetable, HardSoftScore>
    ) {
        this.solverManager = solverManager
        this.solutionManager = solutionManager
    }

    @GET
    @Produces(
        MediaType.APPLICATION_JSON
    )
    fun list(): Collection<String> {
        return jobIdToJob.keys
    }

    @POST
    @Consumes(
        MediaType.APPLICATION_JSON
    )
    @Produces(MediaType.TEXT_PLAIN)
    fun solve(problem: Timetable?): String {
        val jobId = UUID.randomUUID().toString()
        jobIdToJob[jobId] = Job.ofTimetable(problem)
        solverManager!!.solveAndListen(jobId, Function<String, Timetable?> { jobId_: String? ->
            jobIdToJob[jobId]!!.timetable
        }, Consumer { solution: Timetable? ->
            jobIdToJob[jobId] = Job.ofTimetable(solution)
        }, BiConsumer { jobId_: String?, exception: Throwable? ->
            jobIdToJob[jobId] = Job.ofException(exception)
            LOGGER.error("Failed solving jobId ({}).", jobId, exception)
        })
        return jobId
    }

    @PUT
    @Consumes(
        MediaType.APPLICATION_JSON
    )
    @Produces(MediaType.APPLICATION_JSON)
    @Path("analyze")
    fun analyze(
        problem: Timetable, @QueryParam("fetchPolicy") fetchPolicy: ScoreAnalysisFetchPolicy?
    ): ScoreAnalysis<HardSoftScore> {
        return if (fetchPolicy == null) solutionManager!!.analyze(problem) else solutionManager!!.analyze(
            problem, fetchPolicy
        )
    }

    @GET
    @Produces(
        MediaType.APPLICATION_JSON
    )
    @Path("{jobId}")
    fun getTimeTable(
        @Parameter(description = "The job ID returned by the POST method.") @PathParam("jobId") jobId: String
    ): Timetable? {
        val timetable: Timetable? = getTimetableAndCheckForExceptions(jobId)
        val solverStatus = solverManager!!.getSolverStatus(jobId)
        timetable?.solverStatus = solverStatus
        return timetable
    }

    @GET
    @Produces(
        MediaType.APPLICATION_JSON
    )
    @Path("{jobId}/status")
    fun getStatus(
        @Parameter(description = "The job ID returned by the POST method.") @PathParam("jobId") jobId: String
    ): Timetable {
        val timetable: Timetable = getTimetableAndCheckForExceptions(jobId)
        val solverStatus = solverManager!!.getSolverStatus(jobId)
        return Timetable(timetable.name, timetable.score, solverStatus)
    }

    private fun getTimetableAndCheckForExceptions(jobId: String): Timetable {
        val job = jobIdToJob[jobId] ?: throw TimetableSolverException(
            jobId, Response.Status.NOT_FOUND, "No timetable found."
        )
        if (job.exception != null) {
            throw TimetableSolverException(jobId, job.exception)
        }
        return job.timetable!!
    }

    @DELETE
    @Produces(
        MediaType.APPLICATION_JSON
    )
    @Path("{jobId}")
    fun terminateSolving(
        @Parameter(description = "The job ID returned by the POST method.") @PathParam("jobId") jobId: String
    ): Timetable? {
        solverManager!!.terminateEarly(jobId)
        return getTimeTable(jobId)
    }


    data class Job(val timetable: Timetable?, val exception: Throwable?) {
        companion object {
            fun ofTimetable(timetable: Timetable?): Job {
                return Job(timetable, null)
            }

            fun ofException(error: Throwable?): Job {
                return Job(null, error)
            }
        }
    }
}

For simplicity’s sake, this initial implementation waits for the solver to finish, which can still cause an HTTP timeout. The complete implementation avoids HTTP timeouts much more elegantly.

9. Set the termination time

Without a termination setting or a terminationEarly() event, the solver runs forever. To avoid that, limit the solving time to five seconds. That is short enough to avoid the HTTP timeout.

Create the src/main/resources/application.properties file:

# The solver runs only for 5 seconds to avoid a HTTP timeout in this simple implementation.
# It's recommended to run for at least 5 minutes ("5m") otherwise.
quarkus.timefold.solver.termination.spent-limit=5s

Timefold Solver returns the best solution found in the available termination time. Due to the nature of NP-hard problems, the best solution might not be optimal, especially for larger datasets. Increase the termination time to potentially find a better solution.

10. Run the application

First start the application in dev mode:

  • Maven

  • Gradle

$ mvn compile quarkus:dev
$ gradle --console=plain quarkusDev

10.1. Try the application

Now that the application is running, you can test the REST service. You can use any REST client you wish. The following example uses the Linux command curl to send a POST request:

$ curl -i -X POST http://localhost:8080/timetables/solve -H "Content-Type:application/json" -d '{"timeslots":[{"dayOfWeek":"MONDAY","startTime":"08:30:00","endTime":"09:30:00"},{"dayOfWeek":"MONDAY","startTime":"09:30:00","endTime":"10:30:00"}],"rooms":[{"name":"Room A"},{"name":"Room B"}],"lessons":[{"id":1,"subject":"Math","teacher":"A. Turing","studentGroup":"9th grade"},{"id":2,"subject":"Chemistry","teacher":"M. Curie","studentGroup":"9th grade"},{"id":3,"subject":"French","teacher":"M. Curie","studentGroup":"10th grade"},{"id":4,"subject":"History","teacher":"I. Jones","studentGroup":"10th grade"}]}'

After about five seconds, according to the termination spent time defined in your application.properties, the service returns an output similar to the following example:

HTTP/1.1 200
Content-Type: application/json
...

{"timeslots":...,"rooms":...,"lessons":[{"id":1,"subject":"Math","teacher":"A. Turing","studentGroup":"9th grade","timeslot":{"dayOfWeek":"MONDAY","startTime":"08:30:00","endTime":"09:30:00"},"room":{"name":"Room A"}},{"id":2,"subject":"Chemistry","teacher":"M. Curie","studentGroup":"9th grade","timeslot":{"dayOfWeek":"MONDAY","startTime":"09:30:00","endTime":"10:30:00"},"room":{"name":"Room A"}},{"id":3,"subject":"French","teacher":"M. Curie","studentGroup":"10th grade","timeslot":{"dayOfWeek":"MONDAY","startTime":"08:30:00","endTime":"09:30:00"},"room":{"name":"Room B"}},{"id":4,"subject":"History","teacher":"I. Jones","studentGroup":"10th grade","timeslot":{"dayOfWeek":"MONDAY","startTime":"09:30:00","endTime":"10:30:00"},"room":{"name":"Room B"}}],"score":"0hard/0soft"}

Notice that your application assigned all four lessons to one of the two time slots and one of the two rooms. Also notice that it conforms to all hard constraints. For example, M. Curie’s two lessons are in different time slots.

On the server side, the info log shows what Timefold Solver did in those five seconds:

... Solving started: time spent (33), best score (-8init/0hard/0soft), environment mode (REPRODUCIBLE), random (JDK with seed 0).
... Construction Heuristic phase (0) ended: time spent (73), best score (0hard/0soft), score calculation speed (459/sec), step total (4).
... Local Search phase (1) ended: time spent (5000), best score (0hard/0soft), score calculation speed (28949/sec), step total (28398).
... Solving ended: time spent (5000), best score (0hard/0soft), score calculation speed (28524/sec), phase total (2), environment mode (REPRODUCIBLE).

The solver runs considerably slower in dev mode since the JVM C2 compiler is disabled to decrease live reload times.

10.2. Test the application

A good application includes test coverage.

10.2.1. Test the constraints

To test each constraint in isolation, use a ConstraintVerifier in unit tests. It tests each constraint’s corner cases in isolation from the other tests, which lowers maintenance when adding a new constraint with proper test coverage.

First update your build tool configuration:

  • Maven

  • Gradle

Add a timefold-solver-test dependency in your pom.xml:

    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-junit5</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>ai.timefold.solver</groupId>
      <artifactId>timefold-solver-test</artifactId>
      <scope>test</scope>
    </dependency>

Add the subsequent dependencies to your build.gradle:

    testImplementation "io.quarkus:quarkus-junit5"
    testImplementation "ai.timefold.solver:timefold-solver-test"

Then create the test itself:

  • Java

  • Kotlin

Create the src/test/java/org/acme/schooltimetabling/solver/TimetableConstraintProviderTest.java class:

package org.acme.schooltimetabling.solver;

import java.time.DayOfWeek;
import java.time.LocalTime;

import javax.inject.Inject;

import io.quarkus.test.junit.QuarkusTest;
import org.acme.schooltimetabling.domain.Lesson;
import org.acme.schooltimetabling.domain.Room;
import org.acme.schooltimetabling.domain.Timetable;
import org.acme.schooltimetabling.domain.Timeslot;
import org.junit.jupiter.api.Test;
import ai.timefold.solver.test.api.score.stream.ConstraintVerifier;

@QuarkusTest
class TimetableConstraintProviderTest {

    private static final Room ROOM = new Room("Room1");
    private static final Timeslot TIMESLOT1 = new Timeslot(DayOfWeek.MONDAY, LocalTime.of(9,0), LocalTime.NOON);
    private static final Timeslot TIMESLOT2 = new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(9,0), LocalTime.NOON);

    @Inject
    ConstraintVerifier<TimetableConstraintProvider, Timetable> constraintVerifier;

    @Test
    void roomConflict() {
        Lesson firstLesson = new Lesson("1", "Subject1", "Teacher1", "Group1", TIMESLOT1, ROOM1);
        Lesson conflictingLesson = new Lesson("2", "Subject2", "Teacher2", "Group2", TIMESLOT1, ROOM1);
        Lesson nonConflictingLesson = new Lesson("3", "Subject3", "Teacher3", "Group3", TIMESLOT2, ROOM1);
        constraintVerifier.verifyThat(TimetableConstraintProvider::roomConflict)
                .given(firstLesson, conflictingLesson, nonConflictingLesson)
                .penalizesBy(1);
    }

}

Create the src/test/kotlin/org/acme/schooltimetabling/solver/TimetableConstraintProviderTest.kt class:

package org.acme.schooltimetabling.solver

import ai.timefold.solver.test.api.score.stream.ConstraintVerifier
import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
import org.acme.schooltimetabling.domain.Lesson
import org.acme.schooltimetabling.domain.Room
import org.acme.schooltimetabling.domain.Timeslot
import org.acme.schooltimetabling.domain.Timetable
import org.junit.jupiter.api.Test
import java.time.DayOfWeek
import java.time.LocalTime

@QuarkusTest
class TimetableConstraintProviderTest {

    val ROOM1: Room = Room(1, "Room1")
    private val TIMESLOT1: Timeslot = Timeslot(1, DayOfWeek.MONDAY, LocalTime.NOON)
    private val TIMESLOT2: Timeslot = Timeslot(2, DayOfWeek.TUESDAY, LocalTime.NOON)

    @Inject
    lateinit var constraintVerifier: ConstraintVerifier<TimeTableConstraintProvider, Timetable>

    @Test
    fun roomConflict() {
        val firstLesson = Lesson("1", "Subject1", "Teacher1", "Group1", TIMESLOT1, ROOM1)
        val conflictingLesson = Lesson("2", "Subject2", "Teacher2", "Group2", TIMESLOT1, ROOM1)
        val nonConflictingLesson = Lesson("3", "Subject3", "Teacher3", "Group3", TIMESLOT2, ROOM1)
        constraintVerifier.verifyThat(TimeTableConstraintProvider::roomConflict)
            .given(firstLesson, conflictingLesson, nonConflictingLesson)
            .penalizesBy(1)
    }

}

This test verifies that the constraint TimetableConstraintProvider::roomConflict, when given three lessons in the same room, where two lessons have the same timeslot, it penalizes with a match weight of 1. So with a constraint weight of 10hard it would reduce the score by -10hard.

Notice how ConstraintVerifier ignores the constraint weight during testing - even if those constraint weights are hard coded in the ConstraintProvider - because constraints weights change regularly before going into production. This way, constraint weight tweaking does not break the unit tests.

10.2.2. Test the solver

In a JUnit test, generate a test dataset and send it to the TimetableResource to solve.

  • Java

  • Kotlin

Create the src/test/java/org/acme/schooltimetabling/rest/TimetableResourceTest.java class:

package org.acme.schooltimetabling.rest;

import static io.restassured.RestAssured.get;
import static io.restassured.RestAssured.given;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.time.Duration;

import jakarta.inject.Singleton;

import ai.timefold.solver.core.api.score.analysis.ScoreAnalysis;
import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore;
import ai.timefold.solver.core.api.score.constraint.ConstraintRef;
import ai.timefold.solver.core.api.score.stream.ConstraintJustification;
import ai.timefold.solver.core.api.solver.SolverStatus;
import ai.timefold.solver.jackson.api.score.analysis.AbstractScoreAnalysisJacksonDeserializer;

import org.acme.schooltimetabling.domain.Timetable;
import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;

import io.quarkus.jackson.ObjectMapperCustomizer;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;

@QuarkusTest
class TimetableResourceTest {

    @Test
    void solveDemoDataUntilFeasible() {
        Timetable testTimetable = given()
                .when().get("/demo-data/SMALL")
                .then()
                .statusCode(200)
                .extract()
                .as(Timetable.class);

        String jobId = given()
                .contentType(ContentType.JSON)
                .body(testTimetable)
                .expect().contentType(ContentType.TEXT)
                .when().post("/timetables")
                .then()
                .statusCode(200)
                .extract()
                .asString();

        await()
                .atMost(Duration.ofMinutes(1))
                .pollInterval(Duration.ofMillis(500L))
                .until(() -> SolverStatus.NOT_SOLVING.name().equals(
                        get("/timetables/" + jobId + "/status")
                                .jsonPath().get("solverStatus")));

        Timetable solution = get("/timetables/" + jobId).then().extract().as(Timetable.class);
        assertEquals(SolverStatus.NOT_SOLVING, solution.getSolverStatus());
        assertNotNull(solution.getLessons());
        assertNotNull(solution.getTimeslots());
        assertNotNull(solution.getRooms());
        assertNotNull(solution.getLessons().get(0).getRoom());
        assertNotNull(solution.getLessons().get(0).getTimeslot());
        assertTrue(solution.getScore().isFeasible());
    }

}

Create the src/test/kotlin/org/acme/schooltimetabling/rest/TimetableResourceTest.kt class:

package org.acme.schooltimetabling.rest

import ai.timefold.solver.core.api.solver.SolverStatus
import io.quarkus.test.junit.QuarkusTest
import io.restassured.RestAssured.get
import io.restassured.RestAssured.given
import io.restassured.http.ContentType
import org.acme.schooltimetabling.domain.Room
import org.acme.schooltimetabling.domain.Timeslot
import org.acme.schooltimetabling.domain.Timetable
import org.awaitility.Awaitility.await
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Duration

@QuarkusTest
class TimetableResourceTest {

    @Test
    fun solveDemoDataUntilFeasible() {
        val testTimetable: Timetable = given()
            .`when`()["/demo-data/SMALL"]
            .then()
            .statusCode(200)
            .extract()
            .`as`(Timetable::class.java)

        val jobId: String = given()
            .contentType(ContentType.JSON)
            .body(testTimetable)
            .expect().contentType(ContentType.TEXT)
            .`when`().post("/timetables")
            .then()
            .statusCode(200)
            .extract()
            .asString()

        await()
            .atMost(Duration.ofMinutes(1))
            .pollInterval(Duration.ofMillis(500L))
            .until {
                SolverStatus.NOT_SOLVING.name ==
                        get("/timetables/$jobId/status")
                            .jsonPath().get("solverStatus")
            }
        val solution: Timetable =
            get("/timetables/$jobId").then().extract().`as`<Timetable>(
                Timetable::class.java
            )
        assertEquals(solution.solverStatus, SolverStatus.NOT_SOLVING)
        assertNotNull(solution.lessons)
        assertNotNull(solution.timeslots)
        assertNotNull(solution.rooms)
        assertNotNull(solution.lessons.get(0).room)
        assertNotNull(solution.lessons.get(0).timeslot)
        assertTrue(solution.score?.isFeasible!!)
    }

}

This test verifies that after solving, all lessons are assigned to a time slot and a room. It also verifies that it found a feasible solution (no hard constraints broken).

Add test properties to the src/main/resources/application.properties file:

quarkus.timefold.solver.termination.spent-limit=5s

# Effectively disable spent-time termination in favor of the best-score-limit
%test.quarkus.timefold.solver.termination.spent-limit=1h
%test.quarkus.timefold.solver.termination.best-score-limit=0hard/*soft

Normally, the solver finds a feasible solution in less than 200 milliseconds. Notice how the application.properties overwrites the solver termination during tests to terminate as soon as a feasible solution (0hard/*soft) is found. This avoids hard coding a solver time, because the unit test might run on arbitrary hardware. This approach ensures that the test runs long enough to find a feasible solution, even on slow machines. But it does not run a millisecond longer than it strictly must, even on fast machines.

10.3. Logging

When adding constraints in your ConstraintProvider, keep an eye on the score calculation speed in the info log, after solving for the same amount of time, to assess the performance impact:

... Solving ended: ..., score calculation speed (29455/sec), ...

To understand how Timefold Solver is solving your problem internally, change the logging in the application.properties file or with a -D system property:

quarkus.log.category."ai.timefold.solver".level=debug

Use debug logging to show every step:

... Solving started: time spent (67), best score (-20init/0hard/0soft), environment mode (REPRODUCIBLE), random (JDK with seed 0).
...     CH step (0), time spent (128), score (-18init/0hard/0soft), selected move count (15), picked move ([Math(101) {null -> Room A}, Math(101) {null -> MONDAY 08:30}]).
...     CH step (1), time spent (145), score (-16init/0hard/0soft), selected move count (15), picked move ([Physics(102) {null -> Room A}, Physics(102) {null -> MONDAY 09:30}]).
...

Use trace logging to show every step and every move per step.

10.4. Create a native image

To decrease startup times for serverless deployments, or to deploy to environments without a JVM, you can build the application as a native executable. As a prerequisite, install GraalVM and gu install the native-image tool. Then continue with your build tool of choice:

  • Maven

  • Gradle

  1. Compile it natively. This takes a few minutes:

    $ mvn -Pnative package
  2. Run the native executable:

    $ ./target/*-runner
  1. Compile it natively. This takes a few minutes:

    $ gradle build -Dquarkus.package.type=native
  2. Run the native executable:

    $ ./build/*-runner

11. Summary

Congratulations! You have just developed a Quarkus application with Timefold!

For a full implementation with a web UI and in-memory storage, check out the Quarkus quickstart source code.