From fec5a0157a22ad65499f27de0b5029a3115bebaf Mon Sep 17 00:00:00 2001 From: larsbeck Date: Thu, 9 Jul 2026 18:02:39 +0200 Subject: [PATCH 1/8] refactor(conference-scheduling): convert to Models Service SDK structure - Move solver justifications to domain/justification with shared ConferenceSchedulingJustification interface - Add DTO layer (input/output/metrics/config overrides/validation) - Add DemoDataBuilder/DemoDataGenerator for demo data - Update pom.xml for platform model conventions --- use-cases/conference-scheduling/README.md | 28 +- .../conference-scheduling/lint-markdown.sh | 2 + use-cases/conference-scheduling/pom.xml | 502 ++++++++---- .../demo/DemoDataBuilder.java | 124 +++ .../demo/DemoDataGenerator.java | 28 + .../ConferenceConstraintProperties.java | 3 - .../domain/ConferenceSchedule.java | 57 +- .../conferencescheduling/domain/Room.java | 6 +- .../conferencescheduling/domain/Speaker.java | 27 +- .../conferencescheduling/domain/Talk.java | 32 +- .../conferencescheduling/domain/TalkType.java | 13 +- .../conferencescheduling/domain/Timeslot.java | 14 +- .../ConferenceSchedulingJustification.java | 13 + .../ConflictTalkJustification.java | 8 +- .../DiversityTalkJustification.java | 8 +- .../PreferredTagsJustification.java | 10 +- .../ProhibitedTagsJustification.java | 11 +- .../RequiredTagsJustification.java | 8 +- .../UnavailableTimeslotJustification.java | 8 +- .../UndesiredTagsJustification.java | 15 +- .../ConferenceScheduleConfigOverrides.java | 307 ++++++++ .../dto/ConferenceScheduleInput.java | 52 ++ .../dto/ConferenceScheduleInputMetrics.java | 77 ++ .../dto/ConferenceScheduleOutput.java | 56 ++ .../dto/ConferenceScheduleOutputMetrics.java | 75 ++ .../ConferenceScheduleValidationIssue.java | 46 ++ .../conferencescheduling/dto/RoomDTO.java | 46 ++ .../dto/RoomIdDetail.java | 23 + .../conferencescheduling/dto/SpeakerDTO.java | 99 +++ .../dto/SpeakerIdDetail.java | 23 + .../conferencescheduling/dto/TalkDTO.java | 229 ++++++ .../dto/TalkIdDetail.java | 23 + .../conferencescheduling/dto/TalkTypeDTO.java | 16 + .../conferencescheduling/dto/TimeslotDTO.java | 39 + .../dto/TimeslotIdDetail.java | 23 + .../rest/ConferenceScheduleResource.java | 9 + .../ConferenceSchedulingDemoResource.java | 38 - .../rest/ConferenceSchedulingResource.java | 229 ------ .../rest/DemoDataGenerator.java | 214 ----- .../ConferenceScheduleSolverException.java | 30 - ...nferenceScheduleSolverExceptionMapper.java | 19 - .../rest/exception/ErrorInfo.java | 4 - .../service/ConferenceScheduleIssues.java | 161 ++++ .../ConferenceScheduleModelConvertor.java | 292 +++++++ .../service/ConferenceScheduleValidator.java | 121 +++ .../ConferenceScheduleConstraintGroup.java | 33 + ...onferenceSchedulingConstraintProvider.java | 451 +++++++---- .../solver/ConstraintGroupTag.java | 18 + .../ConferenceSchedulingJustification.java | 7 - .../main/resources/META-INF/resources/app.js | 465 ++++++----- .../resources/META-INF/resources/index.html | 41 +- .../src/main/resources/application.properties | 52 +- .../DtoWithMethodsUsageTest.java | 153 ++++ .../ProjectStructureTest.java | 736 ++++++++++++++++++ .../demo/DemoDataBuilderTest.java | 26 + .../rest/ConferenceScheduleResourceTest.java | 109 --- .../rest/ConferenceSchedulingResourceIT.java | 50 -- .../ConferenceScheduleEnvironmentTest.java} | 29 +- .../solver/ConferenceScheduleResourceIT.java | 42 + ...renceSchedulingConstraintProviderTest.java | 2 +- .../solver/SolverManagerTest.java | 53 ++ .../solver/SolverTestDataFactory.java | 55 ++ .../support/CoverageReportSummary.java | 85 ++ .../support/CoverageReportSummaryTest.java | 42 + .../support/RecordWithMethodCoverageTest.java | 106 +++ .../support/ShadowSourcesCoverageCheck.java | 402 ++++++++++ 66 files changed, 4781 insertions(+), 1344 deletions(-) create mode 100755 use-cases/conference-scheduling/lint-markdown.sh create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/demo/DemoDataBuilder.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/demo/DemoDataGenerator.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/ConferenceSchedulingJustification.java rename use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/{solver/justifications => domain/justification}/ConflictTalkJustification.java (90%) rename use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/{solver/justifications => domain/justification}/DiversityTalkJustification.java (83%) rename use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/{solver/justifications => domain/justification}/PreferredTagsJustification.java (83%) rename use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/{solver/justifications => domain/justification}/ProhibitedTagsJustification.java (79%) rename use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/{solver/justifications => domain/justification}/RequiredTagsJustification.java (86%) rename use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/{solver/justifications => domain/justification}/UnavailableTimeslotJustification.java (86%) rename use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/{solver/justifications => domain/justification}/UndesiredTagsJustification.java (72%) create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleConfigOverrides.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleInput.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleInputMetrics.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleOutput.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleOutputMetrics.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleValidationIssue.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/RoomDTO.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/RoomIdDetail.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/SpeakerDTO.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/SpeakerIdDetail.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TalkDTO.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TalkIdDetail.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TalkTypeDTO.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TimeslotDTO.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TimeslotIdDetail.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/ConferenceScheduleResource.java delete mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/ConferenceSchedulingDemoResource.java delete mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/ConferenceSchedulingResource.java delete mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/DemoDataGenerator.java delete mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/exception/ConferenceScheduleSolverException.java delete mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/exception/ConferenceScheduleSolverExceptionMapper.java delete mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/exception/ErrorInfo.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/service/ConferenceScheduleIssues.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/service/ConferenceScheduleModelConvertor.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/service/ConferenceScheduleValidator.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/ConferenceScheduleConstraintGroup.java create mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/ConstraintGroupTag.java delete mode 100644 use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/ConferenceSchedulingJustification.java create mode 100644 use-cases/conference-scheduling/src/test/java/org/acme/conferencescheduling/DtoWithMethodsUsageTest.java create mode 100644 use-cases/conference-scheduling/src/test/java/org/acme/conferencescheduling/ProjectStructureTest.java create mode 100644 use-cases/conference-scheduling/src/test/java/org/acme/conferencescheduling/demo/DemoDataBuilderTest.java delete mode 100644 use-cases/conference-scheduling/src/test/java/org/acme/conferencescheduling/rest/ConferenceScheduleResourceTest.java delete mode 100644 use-cases/conference-scheduling/src/test/java/org/acme/conferencescheduling/rest/ConferenceSchedulingResourceIT.java rename use-cases/conference-scheduling/src/test/java/org/acme/conferencescheduling/{rest/ConferenceSchedulingEnvironmentTest.java => solver/ConferenceScheduleEnvironmentTest.java} (68%) create mode 100644 use-cases/conference-scheduling/src/test/java/org/acme/conferencescheduling/solver/ConferenceScheduleResourceIT.java create mode 100644 use-cases/conference-scheduling/src/test/java/org/acme/conferencescheduling/solver/SolverManagerTest.java create mode 100644 use-cases/conference-scheduling/src/test/java/org/acme/conferencescheduling/solver/SolverTestDataFactory.java create mode 100644 use-cases/conference-scheduling/src/test/java/org/acme/conferencescheduling/support/CoverageReportSummary.java create mode 100644 use-cases/conference-scheduling/src/test/java/org/acme/conferencescheduling/support/CoverageReportSummaryTest.java create mode 100644 use-cases/conference-scheduling/src/test/java/org/acme/conferencescheduling/support/RecordWithMethodCoverageTest.java create mode 100644 use-cases/conference-scheduling/src/test/java/org/acme/conferencescheduling/support/ShadowSourcesCoverageCheck.java diff --git a/use-cases/conference-scheduling/README.md b/use-cases/conference-scheduling/README.md index ecb742cad6..f930533e59 100644 --- a/use-cases/conference-scheduling/README.md +++ b/use-cases/conference-scheduling/README.md @@ -55,8 +55,8 @@ Assign conference talks to timeslots and rooms to produce a better schedule for 1. Install Java and Maven, for example with [Sdkman](https://sdkman.io): ```sh - $ sdk install java - $ sdk install maven + sdk install java + sdk install maven ``` ## Run the application @@ -64,9 +64,9 @@ Assign conference talks to timeslots and rooms to produce a better schedule for 1. Git clone the timefold-quickstarts repo and navigate to this directory: ```sh - $ git clone https://github.com/TimefoldAI/timefold-quickstarts.git + git clone https://github.com/TimefoldAI/timefold-quickstarts.git ... - $ cd timefold-quickstarts/use-cases/conference-scheduling + cd timefold-quickstarts/java/conference-scheduling ``` 2. (Optional) If you want to run a licensed edition (Plus / Enterprise), set up your license key first. See the [Timefold license tool](https://licenses.timefold.ai/) for instructions. @@ -74,15 +74,15 @@ Assign conference talks to timeslots and rooms to produce a better schedule for 3. Start the application with Maven: 1. Community Edition - + ```sh - $ mvn quarkus:dev + mvn quarkus:dev ``` - + 2. Plus / Enterprise Edition: The profile sets up the correct Maven artifacts to run the licensed version. See the `pom.xml` for the implementation details. ```sh - $ mvn quarkus:dev -Denterprise + mvn quarkus:dev -Denterprise ``` 4. Visit [http://localhost:8080](http://localhost:8080) in your browser. @@ -103,13 +103,13 @@ When you're done iterating in `quarkus:dev` mode, package the application to run 1. Build it with Maven: ```sh - $ mvn package + mvn package ``` 2. Run the Maven output: ```sh - $ java -jar ./target/quarkus-app/quarkus-run.jar + java -jar ./target/quarkus-app/quarkus-run.jar ``` > **Note** @@ -124,13 +124,13 @@ When you're done iterating in `quarkus:dev` mode, package the application to run 1. Build a container image: ```sh - $ mvn package -Dcontainer + mvn package -Dcontainer ``` 2. Run a container: ```sh - $ docker run -p 8080:8080 --rm $USER/conference-scheduling:1.0-SNAPSHOT + docker run -p 8080:8080 --rm $USER/conference-scheduling:1.0-SNAPSHOT ``` ## Run it native @@ -142,13 +142,13 @@ To increase startup performance for serverless deployments, build the applicatio 2. Compile it natively. This takes a few minutes: ```sh - $ mvn package -Dnative + mvn package -Dnative ``` 3. Run the native executable: ```sh - $ ./target/*-runner + ./target/*-runner ``` 4. Visit [http://localhost:8080](http://localhost:8080) in your browser. diff --git a/use-cases/conference-scheduling/lint-markdown.sh b/use-cases/conference-scheduling/lint-markdown.sh new file mode 100755 index 0000000000..4eddf53f40 --- /dev/null +++ b/use-cases/conference-scheduling/lint-markdown.sh @@ -0,0 +1,2 @@ +#!/bin/bash +npx --yes markdownlint-cli2 "$@" diff --git a/use-cases/conference-scheduling/pom.xml b/use-cases/conference-scheduling/pom.xml index 50d81fa81b..4452308fb1 100644 --- a/use-cases/conference-scheduling/pom.xml +++ b/use-cases/conference-scheduling/pom.xml @@ -1,66 +1,35 @@ - + 4.0.0 - org.acme - conference-scheduling - 1.0-SNAPSHOT + + ai.timefold.solver + timefold-solver-service-parent + 2.2.0 + + + ai.timefold.model + conferencescheduling + 0.0.1 + 1.0.0-SNAPSHOT + 2.50.0 + 0.8.15 + 2.0.1 + v24.14.0 21 - UTF-8 - - 3.37.2 - 999-SNAPSHOT - - 3.15.0 - 3.5.0 - 3.5.6 - - - - io.quarkus - quarkus-bom - ${version.io.quarkus} - pom - import - - - ai.timefold.solver - timefold-solver-bom - ${version.ai.timefold.solver} - pom - import - - - - + + io.quarkus - quarkus-rest - - - io.quarkus - quarkus-rest-jackson - - - io.quarkus - quarkus-smallrye-openapi - - - ai.timefold.solver - timefold-solver-quarkus - - - ai.timefold.solver - timefold-solver-quarkus-jackson + quarkus-jacoco + test - - io.quarkus quarkus-junit @@ -82,6 +51,12 @@ 3.27.7 test + + com.tngtech.archunit + archunit-junit5 + 1.4.2 + test + @@ -103,7 +78,7 @@ org.webjars font-awesome - 7.1.0 + 7.2.0 runtime @@ -117,119 +92,358 @@ - maven-resources-plugin - ${version.resources.plugin} + maven-compiler-plugin + + true + + -Werror + -XDcompilePolicy=simple + --should-stop=ifError=FLOW + -Xplugin:ErrorProne + -J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED + -J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED + -J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED + + + + com.google.errorprone + error_prone_core + ${error-prone.version} + + + + - maven-compiler-plugin - ${version.compiler.plugin} + com.diffplug.spotless + spotless-maven-plugin + 3.7.0 + + + + 4.31 + https://raw.githubusercontent.com/TimefoldAI/timefold-solver/refs/heads/main/build/ide-config/src/main/resources/eclipse-format.xml + + + + + + + + **/*.md + + + **/node_modules/** + **/target/** + + + + true + + + + + spotless-apply + compile + + apply + + + + io.quarkus quarkus-maven-plugin ${version.io.quarkus} - true + + + com.google.errorprone + error_prone_core + ${error-prone.version} + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + default-prepare-agent - build + prepare-agent + + + org.acme.conferencescheduling.* + + + + + default-report + test + + report + + + + default-check + test + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.30 + + + BRANCH + COVEREDRATIO + 0.20 + + + + + CLASS + + org.acme.conferencescheduling.solver.*ConstraintProvider + + + + LINE + COVEREDRATIO + 1.00 + + + + + + maven-surefire-plugin - ${version.surefire.plugin} + + @{argLine} --add-opens java.base/java.lang=ALL-UNNAMED + - - - - - native - - - native - - - - - - maven-failsafe-plugin - ${version.surefire.plugin} - - - - integration-test - verify - - - - - ${project.build.directory}/${project.build.finalName}-runner - - - - - - - - - - true - - native - - - - container - - - container - - - - - io.quarkus - quarkus-container-image-jib - - - - true - - - - enterprise - - - enterprise - - - + + com.github.eirslett + frontend-maven-plugin + ${frontend-maven-plugin.version} + + ${project.build.directory} + ${project.basedir} + + + + install-node-and-npm + generate-resources + + install-node-and-npm + + + ${node.version} + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + + report-coverage + test + + java + + + test + org.acme.conferencescheduling.support.CoverageReportSummary + + ${project.reporting.outputDirectory}/jacoco/jacoco.csv + + + + + lint-openapi-descriptions + verify + + exec + + + + ${project.build.directory}/node:${env.PATH} + + ${project.build.directory}/node/npx + + --prefix + ${project.build.directory}/node + --yes + @quobix/vacuum + lint + --ruleset + ${project.basedir}/.vacuum-ruleset.yaml + --details + --fail-severity + error + ${project.basedir}/src/build/openapi.json + + + + + check-shadow-sources-coverage + verify + + java + + + test + org.acme.conferencescheduling.support.ShadowSourcesCoverageCheck + + --source-root + ${project.basedir}/src/main/java + --jacoco-report + ${project.reporting.outputDirectory}/jacoco/jacoco.xml + + + + + lint-markdown + verify + + exec + + + + ${project.build.directory}/node:${env.PATH} + + ${project.build.directory}/node/npx + + --prefix + ${project.build.directory}/node + --yes + markdownlint-cli2 + --config + ${project.basedir}/.markdownlint-cli2.jsonc + -- + **/*.md + !target/** + + + + + + + maven-resources-plugin + 3.5.0 + + + copy-openapi-json + package + + copy-resources + + + ${project.basedir}/src/build + + + ${project.build.directory}/openapi-schema + + openapi.json + + + + true + + + + + + + org.apache.maven.plugins + maven-pmd-plugin + 3.28.0 + + + ${project.basedir}/.pmd-ruleset.xml + + 150 + true + true + false + + + + pmd-check + compile + + pmd + cpd + check + cpd-check + + + - ai.timefold.solver.enterprise - timefold-solver-enterprise-bom - ${version.ai.timefold.solver} - pom - import + net.sourceforge.pmd + pmd-core + 7.25.0 + + + net.sourceforge.pmd + pmd-java + 7.25.0 - - - - ai.timefold.solver.enterprise - timefold-solver-enterprise-quarkus - - - ai.timefold.solver.enterprise - timefold-solver-enterprise-quarkus-jackson - - - - enterprise - - - + + + + + + + timefold + Timefold JFrog Solver and SDK releases repository + https://timefold.jfrog.io/artifactory/releases-all + + true + + + false + + + + + + timefold + Timefold JFrog Solver and SDK releases repository + https://timefold.jfrog.io/artifactory/releases-all + + + false + + + diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/demo/DemoDataBuilder.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/demo/DemoDataBuilder.java new file mode 100644 index 0000000000..9323575b8a --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/demo/DemoDataBuilder.java @@ -0,0 +1,124 @@ +package org.acme.conferencescheduling.demo; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; + +import org.acme.conferencescheduling.dto.ConferenceScheduleInput; +import org.acme.conferencescheduling.dto.RoomDTO; +import org.acme.conferencescheduling.dto.SpeakerDTO; +import org.acme.conferencescheduling.dto.TalkDTO; +import org.acme.conferencescheduling.dto.TalkTypeDTO; +import org.acme.conferencescheduling.dto.TimeslotDTO; + +public final class DemoDataBuilder { + + private static final String LAB = "Lab"; + private static final String BREAKOUT = "Breakout"; + private static final String AFTER_LUNCH = "After lunch"; + private static final String RECORDED = "Recorded"; + private static final String LARGE = "Large"; + + private static final List THEME_TAGS = List.of("Optimization", "AI", "Cloud"); + private static final List SECTOR_TAGS = List.of("Green", "Blue", "Orange"); + private static final List AUDIENCE_TAGS = List.of("Programmers", "Analysts", "Managers"); + private static final List CONTENT_TAGS = List.of("Timefold", "Constraints", "Metaheuristics", "Kubernetes"); + private static final LocalDate CONFERENCE_DATE = LocalDate.of(2024, 1, 1); + + private DemoDataBuilder() { + } + + public static DemoDataBuilder builder() { + return new DemoDataBuilder(); + } + + public ConferenceScheduleInput build() { + List talkTypes = List.of(new TalkTypeDTO(LAB), new TalkTypeDTO(BREAKOUT)); + return new ConferenceScheduleInput("Conference", talkTypes, buildTimeslots(), buildRooms(), buildSpeakers(), + buildTalks()); + } + + private static String dateTime(int hour, int minute) { + return LocalDateTime.of(CONFERENCE_DATE, LocalTime.of(hour, minute)).toString(); + } + + private static List buildTimeslots() { + List timeslots = new ArrayList<>(); + timeslots.add(new TimeslotDTO("T1", dateTime(10, 15), dateTime(12, 15), List.of(LAB), List.of())); + timeslots.add(new TimeslotDTO("T2", dateTime(10, 15), dateTime(11, 0), List.of(BREAKOUT), List.of())); + timeslots.add(new TimeslotDTO("T3", dateTime(11, 30), dateTime(12, 15), List.of(BREAKOUT), List.of())); + timeslots.add(new TimeslotDTO("T4", dateTime(13, 0), dateTime(15, 0), List.of(LAB), List.of(AFTER_LUNCH))); + timeslots.add(new TimeslotDTO("T5", dateTime(15, 30), dateTime(16, 15), List.of(BREAKOUT), List.of())); + timeslots.add(new TimeslotDTO("T6", dateTime(16, 30), dateTime(17, 15), List.of(BREAKOUT), List.of())); + return timeslots; + } + + private static List buildRooms() { + List rooms = new ArrayList<>(); + rooms.add(new RoomDTO("R1", "Room A", 60, List.of(BREAKOUT), List.of(), List.of(RECORDED))); + rooms.add(new RoomDTO("R2", "Room B", 240, List.of(BREAKOUT), List.of(), List.of())); + rooms.add(new RoomDTO("R3", "Room C", 630, List.of(BREAKOUT), List.of(), List.of(RECORDED, LARGE))); + rooms.add(new RoomDTO("R4", "Room D", 70, List.of(BREAKOUT), List.of(), List.of(RECORDED))); + rooms.add(new RoomDTO("R5", "Room E (LAB)", 490, List.of(LAB), List.of(), List.of(RECORDED))); + return rooms; + } + + private static SpeakerDTO speaker(String id, String name) { + return new SpeakerDTO(id, name, List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(), List.of()); + } + + private static List buildSpeakers() { + List speakers = new ArrayList<>(); + speakers.add(speaker("1", "Amy Cole")); + speakers.add(speaker("2", "Beth Fox")); + speakers.add(speaker("3", "Carl Green")); + speakers.add(speaker("4", "Dan Jones")); + speakers.add(speaker("5", "Elsa King")); + speakers.add(speaker("6", "Flo Li")); + speakers.add(speaker("7", "Gus Poe")); + speakers.add(speaker("8", "Hugo Rye")); + speakers.add(speaker("9", "Ivy Smith")); + speakers.add(speaker("10", "Jay Watt")); + speakers.add(speaker("11", "Amy Fox")); + speakers.add(speaker("12", "Beth Green").withUndesiredTimeslotTags(List.of(AFTER_LUNCH))); + return speakers; + } + + private static TalkDTO talk(int index, String code, String title, String talkType, List speakerIds, + int audienceLevel, int favoriteCount, int crowdControlRisk) { + return new TalkDTO(code, title, talkType, speakerIds, + List.of(THEME_TAGS.get(index % THEME_TAGS.size())), + List.of(SECTOR_TAGS.get(index % SECTOR_TAGS.size())), + List.of(AUDIENCE_TAGS.get(index % AUDIENCE_TAGS.size())), + audienceLevel, + List.of(CONTENT_TAGS.get(index % CONTENT_TAGS.size())), + "en", List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(), List.of(), favoriteCount, crowdControlRisk, "", ""); + } + + private static List buildTalks() { + List talks = new ArrayList<>(); + talks.add(talk(0, "S01", "Talk One", LAB, List.of("1", "2"), 2, 551, 1).withUndesiredRoomTags(List.of(RECORDED))); + talks.add(talk(1, "S02", "Talk Two", LAB, List.of("3"), 3, 528, 0)); + talks.add(talk(2, "S03", "Talk Three", BREAKOUT, List.of("4"), 3, 497, 0)); + talks.add(talk(3, "S04", "Talk Four", BREAKOUT, List.of("5", "6"), 1, 560, 0)); + talks.add(talk(4, "S05", "Talk Five", BREAKOUT, List.of("7", "8"), 1, 957, 0) + .withPrerequisiteTalkCodes(List.of("S02"))); + talks.add(talk(5, "S06", "Talk Six", BREAKOUT, List.of("9"), 1, 957, 0)); + talks.add(talk(6, "S07", "Talk Seven", BREAKOUT, List.of("10"), 3, 568, 0)); + talks.add(talk(7, "S08", "Talk Eight", BREAKOUT, List.of("11"), 3, 183, 0)); + talks.add(talk(8, "S09", "Talk Nine", BREAKOUT, List.of("12", "1"), 3, 619, 0)); + talks.add(talk(9, "S10", "Talk Ten", BREAKOUT, List.of("2", "3"), 3, 603, 1)); + talks.add(talk(10, "S11", "Talk Eleven", BREAKOUT, List.of("4", "5"), 1, 39, 0) + .withMutuallyExclusiveTalksTags(List.of("Constraints")) + .withRequiredRoomTags(List.of(RECORDED))); + talks.add(talk(11, "S12", "Talk Twelve", BREAKOUT, List.of("6", "7"), 3, 977, 0)); + talks.add(talk(12, "S13", "Talk Thirteen", BREAKOUT, List.of("8"), 3, 494, 0)); + talks.add(talk(13, "S14", "Talk Fourteen", BREAKOUT, List.of("9"), 3, 500, 0)); + talks.add(talk(14, "S15", "Talk Fifteen", BREAKOUT, List.of("10"), 2, 658, 0)); + return talks; + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/demo/DemoDataGenerator.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/demo/DemoDataGenerator.java new file mode 100644 index 0000000000..bfeb853ed7 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/demo/DemoDataGenerator.java @@ -0,0 +1,28 @@ +package org.acme.conferencescheduling.demo; + +import jakarta.enterprise.context.ApplicationScoped; + +import ai.timefold.solver.service.definition.api.data.AbstractBasicDemoDataGenerator; +import ai.timefold.solver.service.definition.api.domain.Configuration; +import ai.timefold.solver.service.definition.api.domain.ModelConfig; +import ai.timefold.solver.service.definition.api.domain.ModelRequest; +import ai.timefold.solver.service.definition.api.domain.RunConfiguration; + +import org.acme.conferencescheduling.dto.ConferenceScheduleConfigOverrides; +import org.acme.conferencescheduling.dto.ConferenceScheduleInput; + +@ApplicationScoped +public class DemoDataGenerator + extends AbstractBasicDemoDataGenerator { + + @Override + protected ModelRequest generateBasicDemoDataRequest() { + ConferenceScheduleInput problem = DemoDataBuilder.builder().build(); + // Ship no constraint weight overrides in the demo input, so that any overrides coming from the + // configuration profile are applied instead of being masked. Callers that want to override + // specific weights via the input can build a ConferenceScheduleConfigOverrides and set only those. + Configuration configuration = new Configuration<>( + new RunConfiguration("BASIC"), ModelConfig.empty()); + return new ModelRequest<>(configuration, problem); + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/ConferenceConstraintProperties.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/ConferenceConstraintProperties.java index 5cc9cdf3a1..a5ccd357a0 100644 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/ConferenceConstraintProperties.java +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/ConferenceConstraintProperties.java @@ -45,9 +45,6 @@ public class ConferenceConstraintProperties { private int minimumConsecutiveTalksPauseInMinutes = 30; - public ConferenceConstraintProperties() { - } - // ************************************************************************ // Simple getters and setters // ************************************************************************ diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/ConferenceSchedule.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/ConferenceSchedule.java index 9f801a7ea0..4a16aed5fc 100644 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/ConferenceSchedule.java +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/ConferenceSchedule.java @@ -2,16 +2,23 @@ import java.util.Set; +import ai.timefold.solver.core.api.domain.solution.ConstraintWeightOverrides; 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.solution.ProblemFactProperty; -import ai.timefold.solver.core.api.score.HardSoftScore; -import ai.timefold.solver.core.api.solver.SolverStatus; +import ai.timefold.solver.core.api.score.HardMediumSoftScore; +import ai.timefold.solver.service.definition.api.SolverModel; +import ai.timefold.solver.service.definition.api.metrics.InputMetricsAware; +import ai.timefold.solver.service.definition.api.metrics.OutputMetricsAware; + +import org.acme.conferencescheduling.dto.ConferenceScheduleInputMetrics; +import org.acme.conferencescheduling.dto.ConferenceScheduleOutputMetrics; @PlanningSolution -public class ConferenceSchedule { +public class ConferenceSchedule implements SolverModel, + InputMetricsAware, OutputMetricsAware { private String name; @@ -34,20 +41,13 @@ public class ConferenceSchedule { private Set talks; @PlanningScore - private HardSoftScore score = null; + private HardMediumSoftScore score; - // Ignored by Timefold, used by the UI to display solve or stop solving button - private SolverStatus solverStatus; + private ConstraintWeightOverrides constraintWeightOverrides = ConstraintWeightOverrides.none(); public ConferenceSchedule() { } - public ConferenceSchedule(String name, HardSoftScore score, SolverStatus solverStatus) { - this.name = name; - this.score = score; - this.solverStatus = solverStatus; - } - public ConferenceSchedule(String name, Set talkTypes, Set timeslots, Set rooms, Set speakers, Set talks) { this.name = name; @@ -58,10 +58,6 @@ public ConferenceSchedule(String name, Set talkTypes, Set ti this.talks = talks; } - // ************************************************************************ - // Simple getters and setters - // ************************************************************************ - public String getName() { return name; } @@ -118,20 +114,37 @@ public void setTalks(Set talks) { this.talks = talks; } - public HardSoftScore getScore() { + @Override + public HardMediumSoftScore getScore() { return score; } - public void setScore(HardSoftScore score) { + public void setScore(HardMediumSoftScore score) { this.score = score; } - public SolverStatus getSolverStatus() { - return solverStatus; + @Override + public ConstraintWeightOverrides getConstraintWeightOverrides() { + return constraintWeightOverrides; + } + + public void setConstraintWeightOverrides(ConstraintWeightOverrides constraintWeightOverrides) { + this.constraintWeightOverrides = constraintWeightOverrides; } - public void setSolverStatus(SolverStatus solverStatus) { - this.solverStatus = solverStatus; + @Override + public ConferenceScheduleInputMetrics getInputMetrics() { + return new ConferenceScheduleInputMetrics(talks.size(), speakers.size(), rooms.size(), timeslots.size(), + talkTypes.size()); + } + + @Override + public ConferenceScheduleOutputMetrics getOutputMetrics() { + int scheduledTalks = (int) talks.stream().filter(Talk::isScheduled).count(); + int unscheduledTalks = talks.size() - scheduledTalks; + int usedRooms = (int) talks.stream().filter(Talk::isScheduled).map(Talk::getRoom).distinct().count(); + int usedTimeslots = (int) talks.stream().filter(Talk::isScheduled).map(Talk::getTimeslot).distinct().count(); + return new ConferenceScheduleOutputMetrics(scheduledTalks, unscheduledTalks, usedRooms, usedTimeslots); } @Override diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/Room.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/Room.java index 7a7661c795..32169530b2 100644 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/Room.java +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/Room.java @@ -99,10 +99,12 @@ public void setTags(Set tags) { @Override public boolean equals(Object o) { - if (this == o) + if (this == o) { return true; - if (!(o instanceof Room room)) + } + if (!(o instanceof Room room)) { return false; + } return Objects.equals(getId(), room.getId()); } diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/Speaker.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/Speaker.java index 226cbc56a0..90fa88c5ab 100644 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/Speaker.java +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/Speaker.java @@ -5,6 +5,7 @@ import java.util.Objects; import java.util.SequencedSet; +@SuppressWarnings("PMD.ExcessiveParameterList") public class Speaker { private String id; @@ -25,24 +26,30 @@ public Speaker() { } public Speaker(String id, String name) { - this(id, name, new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), + this(id, name, new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), + new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>()); } public Speaker(String name) { - this(name, name, new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), + this(name, name, new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), + new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>()); } public Speaker(String id, String name, SequencedSet undesiredTimeslotTags) { - this(id, name, new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), undesiredTimeslotTags, new LinkedHashSet<>(), new LinkedHashSet<>(), + this(id, name, new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), + undesiredTimeslotTags, new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>()); } - public Speaker(String id, String name, SequencedSet unavailableTimeslots, SequencedSet requiredTimeslotTags, - SequencedSet preferredTimeslotTags, SequencedSet prohibitedTimeslotTags, SequencedSet undesiredTimeslotTags, - SequencedSet requiredRoomTags, SequencedSet preferredRoomTags, SequencedSet prohibitedRoomTags, + public Speaker(String id, String name, SequencedSet unavailableTimeslots, + SequencedSet requiredTimeslotTags, + SequencedSet preferredTimeslotTags, SequencedSet prohibitedTimeslotTags, + SequencedSet undesiredTimeslotTags, + SequencedSet requiredRoomTags, SequencedSet preferredRoomTags, + SequencedSet prohibitedRoomTags, SequencedSet undesiredRoomTags) { this.id = id; this.name = name; @@ -147,8 +154,12 @@ public void setUndesiredRoomTags(SequencedSet undesiredRoomTags) { @Override public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof Speaker speaker)) return false; + if (this == o) { + return true; + } + if (!(o instanceof Speaker speaker)) { + return false; + } return Objects.equals(getId(), speaker.getId()); } diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/Talk.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/Talk.java index 3f9312b23d..47869c6e9f 100644 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/Talk.java +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/Talk.java @@ -15,6 +15,7 @@ import static java.util.Collections.emptyList; @PlanningEntity +@SuppressWarnings("PMD.ExcessiveParameterList") public class Talk { @PlanningId @@ -55,25 +56,32 @@ public Talk(String code, Timeslot timeslot, Room room) { } public Talk(String code, Timeslot timeslot, Room room, List speakers) { - this(code, null, null, speakers, new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), 0, new LinkedHashSet<>(), null, 0, 0); + this(code, null, null, speakers, new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), 0, + new LinkedHashSet<>(), null, 0, 0); this.timeslot = timeslot; this.room = room; } public Talk(String code, String title, TalkType talkType, List speakers, SequencedSet themeTrackTags, - SequencedSet sectorTags, SequencedSet audienceTypes, int audienceLevel, SequencedSet contentTags, + SequencedSet sectorTags, SequencedSet audienceTypes, int audienceLevel, + SequencedSet contentTags, String language, int favoriteCount, int crowdControlRisk) { this(code, title, talkType, speakers, themeTrackTags, sectorTags, audienceTypes, audienceLevel, contentTags, - language, new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), + language, new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), + new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), favoriteCount, crowdControlRisk); } public Talk(String code, String title, TalkType talkType, List speakers, SequencedSet themeTrackTags, - SequencedSet sectorTags, SequencedSet audienceTypes, int audienceLevel, SequencedSet contentTags, + SequencedSet sectorTags, SequencedSet audienceTypes, int audienceLevel, + SequencedSet contentTags, String language, SequencedSet requiredTimeslotTags, SequencedSet preferredTimeslotTags, - SequencedSet prohibitedTimeslotTags, SequencedSet undesiredTimeslotTags, SequencedSet requiredRoomTags, - SequencedSet preferredRoomTags, SequencedSet prohibitedRoomTags, SequencedSet undesiredRoomTags, - SequencedSet mutuallyExclusiveTalksTags, SequencedSet prerequisiteTalks, int favoriteCount, int crowdControlRisk) { + SequencedSet prohibitedTimeslotTags, SequencedSet undesiredTimeslotTags, + SequencedSet requiredRoomTags, + SequencedSet preferredRoomTags, SequencedSet prohibitedRoomTags, + SequencedSet undesiredRoomTags, + SequencedSet mutuallyExclusiveTalksTags, SequencedSet prerequisiteTalks, int favoriteCount, + int crowdControlRisk) { this.code = code; this.title = title; this.talkType = talkType; @@ -450,6 +458,10 @@ public void setUndesiredTimeslotTags(SequencedSet undesiredTimeslotTags) this.undesiredTimeslotTags = undesiredTimeslotTags; } + public boolean isScheduled() { + return timeslot != null && room != null; + } + public SequencedSet getRequiredRoomTags() { return requiredRoomTags; } @@ -532,10 +544,12 @@ public void setRoom(Room room) { @Override public boolean equals(Object o) { - if (this == o) + if (this == o) { return true; - if (!(o instanceof Talk talk)) + } + if (!(o instanceof Talk talk)) { return false; + } return Objects.equals(getCode(), talk.getCode()); } diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/TalkType.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/TalkType.java index 6c6c51a919..f55ae942fb 100644 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/TalkType.java +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/TalkType.java @@ -1,6 +1,5 @@ package org.acme.conferencescheduling.domain; -import java.util.HashSet; import java.util.LinkedHashSet; import java.util.Objects; import java.util.Set; @@ -20,8 +19,8 @@ public TalkType() { public TalkType(String name) { this.name = name; - this.compatibleRooms = new HashSet<>(); - this.compatibleTimeslots = new HashSet<>(); + this.compatibleRooms = new LinkedHashSet<>(); + this.compatibleTimeslots = new LinkedHashSet<>(); } public void addCompatibleTimeslot(Timeslot timeslot) { @@ -58,8 +57,12 @@ public void setCompatibleRooms(Set compatibleRooms) { @Override public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof TalkType talkType)) return false; + if (this == o) { + return true; + } + if (!(o instanceof TalkType talkType)) { + return false; + } return Objects.equals(getName(), talkType.getName()); } diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/Timeslot.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/Timeslot.java index df9360937b..57f99f1676 100644 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/Timeslot.java +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/Timeslot.java @@ -36,18 +36,18 @@ public Timeslot(String id, LocalDateTime startDateTime, LocalDateTime endDateTim } public boolean overlapsTime(Timeslot other) { - if (this == other) { + if (this.equals(other)) { return true; } return startDateTime.isBefore(other.endDateTime) && other.startDateTime.isBefore(endDateTime); } public int getOverlapInMinutes(Timeslot other) { - if (this == other) { + if (this.equals(other)) { return durationInMinutes; } - LocalDateTime startMaximum = (startDateTime.isBefore(other.startDateTime)) ? other.startDateTime : startDateTime; - LocalDateTime endMinimum = (endDateTime.isBefore(other.endDateTime)) ? endDateTime : other.endDateTime; + LocalDateTime startMaximum = startDateTime.isBefore(other.startDateTime) ? other.startDateTime : startDateTime; + LocalDateTime endMinimum = endDateTime.isBefore(other.endDateTime) ? endDateTime : other.endDateTime; return (int) Duration.between(startMaximum, endMinimum).toMinutes(); } @@ -125,10 +125,12 @@ public void setDurationInMinutes(int durationInMinutes) { @Override public boolean equals(Object o) { - if (this == o) + if (this == o) { return true; - if (!(o instanceof Timeslot timeslot)) + } + if (!(o instanceof Timeslot timeslot)) { return false; + } return Objects.equals(id, timeslot.id); } diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/ConferenceSchedulingJustification.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/ConferenceSchedulingJustification.java new file mode 100644 index 0000000000..7fe2122f33 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/ConferenceSchedulingJustification.java @@ -0,0 +1,13 @@ +package org.acme.conferencescheduling.domain.justification; + +import java.util.Objects; + +import ai.timefold.solver.core.api.score.stream.ConstraintJustification; + +public record ConferenceSchedulingJustification(String description) implements ConstraintJustification { + + public ConferenceSchedulingJustification { + Objects.requireNonNull(description); + } + +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/ConflictTalkJustification.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/ConflictTalkJustification.java similarity index 90% rename from use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/ConflictTalkJustification.java rename to use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/ConflictTalkJustification.java index 92ca1fce4d..adaf34dc5b 100644 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/ConflictTalkJustification.java +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/ConflictTalkJustification.java @@ -1,4 +1,6 @@ -package org.acme.conferencescheduling.solver.justifications; +package org.acme.conferencescheduling.domain.justification; + +import java.util.Objects; import static java.util.stream.Collectors.joining; @@ -11,6 +13,10 @@ public record ConflictTalkJustification(String description) implements ConstraintJustification { + public ConflictTalkJustification { + Objects.requireNonNull(description); + } + public ConflictTalkJustification(String type, Talk talk1, Collection values1, Talk talk2, Collection values2) { this("Two talks [%s, %s] of same %s [%s] at same time.".formatted(talk1.getCode(), talk2.getCode(), type, diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/DiversityTalkJustification.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/DiversityTalkJustification.java similarity index 83% rename from use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/DiversityTalkJustification.java rename to use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/DiversityTalkJustification.java index 7431cd5eda..1bb36f78c9 100644 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/DiversityTalkJustification.java +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/DiversityTalkJustification.java @@ -1,4 +1,6 @@ -package org.acme.conferencescheduling.solver.justifications; +package org.acme.conferencescheduling.domain.justification; + +import java.util.Objects; import static java.util.stream.Collectors.joining; @@ -10,6 +12,10 @@ public record DiversityTalkJustification(String description) implements ConstraintJustification { + public DiversityTalkJustification { + Objects.requireNonNull(description); + } + public DiversityTalkJustification(String type, Talk talk, Collection values, Talk talk2, Collection values2) { this("Talks [%s, %s] match %s [%s] at same time.".formatted(talk.getCode(), talk2.getCode(), type, diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/PreferredTagsJustification.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/PreferredTagsJustification.java similarity index 83% rename from use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/PreferredTagsJustification.java rename to use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/PreferredTagsJustification.java index b43e4b9f33..f50ad5b333 100644 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/PreferredTagsJustification.java +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/PreferredTagsJustification.java @@ -1,4 +1,6 @@ -package org.acme.conferencescheduling.solver.justifications; +package org.acme.conferencescheduling.domain.justification; + +import java.util.Objects; import static java.util.stream.Collectors.joining; @@ -11,6 +13,10 @@ public record PreferredTagsJustification(String description) implements ConstraintJustification { + public PreferredTagsJustification { + Objects.requireNonNull(description); + } + public PreferredTagsJustification(String type, Talk talk, Collection expectedTags, Collection actualTags) { this("Missing preferred %s tags [%s] for talk %s." .formatted(type, @@ -19,7 +25,7 @@ public PreferredTagsJustification(String type, Talk talk, Collection exp } public PreferredTagsJustification(String type, Collection speakers, Collection expectedTags, - Collection actualTags) { + Collection actualTags) { this("Missing preferred %s tags [%s] for speakers [%s].".formatted(type, expectedTags.stream().filter(t -> !actualTags.contains(t)).collect(joining(", ")), speakers.stream().map(Speaker::getName).collect(joining(", ")))); diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/ProhibitedTagsJustification.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/ProhibitedTagsJustification.java similarity index 79% rename from use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/ProhibitedTagsJustification.java rename to use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/ProhibitedTagsJustification.java index 7bcdbba20e..7e243659ab 100644 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/ProhibitedTagsJustification.java +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/ProhibitedTagsJustification.java @@ -1,4 +1,6 @@ -package org.acme.conferencescheduling.solver.justifications; +package org.acme.conferencescheduling.domain.justification; + +import java.util.Objects; import static java.util.stream.Collectors.joining; @@ -11,6 +13,10 @@ public record ProhibitedTagsJustification(String description) implements ConstraintJustification { + public ProhibitedTagsJustification { + Objects.requireNonNull(description); + } + public ProhibitedTagsJustification(String type, Talk talk, Collection prohibitedTags, Collection actualTags) { this("Talk %s has prohibited %s tags [%s]".formatted(talk.getCode(), type, @@ -19,7 +25,8 @@ public ProhibitedTagsJustification(String type, Talk talk, Collection pr public ProhibitedTagsJustification(String type, Collection speakers, Collection prohibitedTags, Collection actualTags) { - this("Speakers [%s] have prohibited %s tags [%s]".formatted(speakers.stream().map(Speaker::getName).collect(joining(", ")), + this("Speakers [%s] have prohibited %s tags [%s]".formatted( + speakers.stream().map(Speaker::getName).collect(joining(", ")), type, prohibitedTags.stream().filter(actualTags::contains).collect(joining(", ")))); } } diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/RequiredTagsJustification.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/RequiredTagsJustification.java similarity index 86% rename from use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/RequiredTagsJustification.java rename to use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/RequiredTagsJustification.java index c199ba9fec..3740ecc43d 100644 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/RequiredTagsJustification.java +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/RequiredTagsJustification.java @@ -1,4 +1,6 @@ -package org.acme.conferencescheduling.solver.justifications; +package org.acme.conferencescheduling.domain.justification; + +import java.util.Objects; import static java.util.stream.Collectors.joining; @@ -11,6 +13,10 @@ public record RequiredTagsJustification(String description) implements ConstraintJustification { + public RequiredTagsJustification { + Objects.requireNonNull(description); + } + public RequiredTagsJustification(String type, Talk talk, Collection expectedTags, Collection actualTags) { this("Missing required %s tags [%s] for talk %s." .formatted(type, diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/UnavailableTimeslotJustification.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/UnavailableTimeslotJustification.java similarity index 86% rename from use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/UnavailableTimeslotJustification.java rename to use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/UnavailableTimeslotJustification.java index 284226c244..8e5c7c3b12 100644 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/UnavailableTimeslotJustification.java +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/UnavailableTimeslotJustification.java @@ -1,4 +1,6 @@ -package org.acme.conferencescheduling.solver.justifications; +package org.acme.conferencescheduling.domain.justification; + +import java.util.Objects; import static java.util.stream.Collectors.joining; @@ -10,6 +12,10 @@ public record UnavailableTimeslotJustification(String description) implements ConstraintJustification { + public UnavailableTimeslotJustification { + Objects.requireNonNull(description); + } + public UnavailableTimeslotJustification(Talk talk) { this("The timeslot %s of Talk %s has been marked as unavailable for room %s [%s].".formatted(talk.getTimeslot().getId(), talk.getCode(), talk.getRoom().getId(), diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/UndesiredTagsJustification.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/UndesiredTagsJustification.java similarity index 72% rename from use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/UndesiredTagsJustification.java rename to use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/UndesiredTagsJustification.java index cd66527978..417f453f38 100644 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/UndesiredTagsJustification.java +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/domain/justification/UndesiredTagsJustification.java @@ -1,4 +1,6 @@ -package org.acme.conferencescheduling.solver.justifications; +package org.acme.conferencescheduling.domain.justification; + +import java.util.Objects; import static java.util.stream.Collectors.joining; @@ -11,15 +13,20 @@ public record UndesiredTagsJustification(String description) implements ConstraintJustification { + public UndesiredTagsJustification { + Objects.requireNonNull(description); + } + public UndesiredTagsJustification(String type, Talk talk, Collection undesiredTags, - Collection actualTags) { + Collection actualTags) { this("Talk %s has undesired %s tags [%s]".formatted(talk.getCode(), type, undesiredTags.stream().filter(actualTags::contains).collect(joining(", ")))); } public UndesiredTagsJustification(String type, Collection speakers, Collection undesiredTags, - Collection actualTags) { - this("Speakers [%s] have undesired %s tags [%s]".formatted(speakers.stream().map(Speaker::getName).collect(joining(", ")), + Collection actualTags) { + this("Speakers [%s] have undesired %s tags [%s]".formatted( + speakers.stream().map(Speaker::getName).collect(joining(", ")), type, undesiredTags.stream().filter(actualTags::contains).collect(joining(", ")))); } } diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleConfigOverrides.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleConfigOverrides.java new file mode 100644 index 0000000000..59ee8d6949 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleConfigOverrides.java @@ -0,0 +1,307 @@ +package org.acme.conferencescheduling.dto; + +import ai.timefold.solver.service.definition.api.ModelConfigOverrides; +import ai.timefold.solver.service.definition.api.domain.ConstraintReference; + +import org.acme.conferencescheduling.domain.ConferenceConstraintProperties; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@Schema(description = "Soft constraint weights. Set a weight to 0 to disable the corresponding constraint. " + + "A weight left unset (null) is not overridden here, so the value from the configuration profile " + + "(or the constraint's default) applies. This makes it possible to override some weights via the " + + "input while leaving others to the configuration profile.") +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ConferenceScheduleConfigOverrides( + @ConstraintReference(ConferenceConstraintProperties.THEME_TRACK_CONFLICT) @Schema( + description = "Soft weight of the themeTrackConflict constraint.") Long themeTrackConflictWeight, + @ConstraintReference(ConferenceConstraintProperties.THEME_TRACK_ROOM_STABILITY) @Schema( + description = "Soft weight of the themeTrackRoomStability constraint.") Long themeTrackRoomStabilityWeight, + @ConstraintReference(ConferenceConstraintProperties.SECTOR_CONFLICT) @Schema( + description = "Soft weight of the sectorConflict constraint.") Long sectorConflictWeight, + @ConstraintReference(ConferenceConstraintProperties.AUDIENCE_TYPE_DIVERSITY) @Schema( + description = "Soft weight of the audienceTypeDiversity constraint.") Long audienceTypeDiversityWeight, + @ConstraintReference(ConferenceConstraintProperties.AUDIENCE_TYPE_THEME_TRACK_CONFLICT) @Schema( + description = "Soft weight of the audienceTypeThemeTrackConflict constraint.") Long audienceTypeThemeTrackConflictWeight, + @ConstraintReference(ConferenceConstraintProperties.AUDIENCE_LEVEL_DIVERSITY) @Schema( + description = "Soft weight of the audienceLevelDiversity constraint.") Long audienceLevelDiversityWeight, + @ConstraintReference(ConferenceConstraintProperties.CONTENT_AUDIENCE_LEVEL_FLOW_VIOLATION) @Schema( + description = "Soft weight of the contentAudienceLevelFlowViolation constraint.") Long contentAudienceLevelFlowViolationWeight, + @ConstraintReference(ConferenceConstraintProperties.CONTENT_CONFLICT) @Schema( + description = "Soft weight of the contentConflict constraint.") Long contentConflictWeight, + @ConstraintReference(ConferenceConstraintProperties.LANGUAGE_DIVERSITY) @Schema( + description = "Soft weight of the languageDiversity constraint.") Long languageDiversityWeight, + @ConstraintReference(ConferenceConstraintProperties.SAME_DAY_TALKS) @Schema( + description = "Soft weight of the sameDayTalks constraint.") Long sameDayTalksWeight, + @ConstraintReference(ConferenceConstraintProperties.POPULAR_TALKS) @Schema( + description = "Soft weight of the popularTalks constraint.") Long popularTalksWeight, + @ConstraintReference(ConferenceConstraintProperties.SPEAKER_PREFERRED_TIMESLOT_TAGS) @Schema( + description = "Soft weight of the speakerPreferredTimeslotTags constraint.") Long speakerPreferredTimeslotTagsWeight, + @ConstraintReference(ConferenceConstraintProperties.SPEAKER_UNDESIRED_TIMESLOT_TAGS) @Schema( + description = "Soft weight of the speakerUndesiredTimeslotTags constraint.") Long speakerUndesiredTimeslotTagsWeight, + @ConstraintReference(ConferenceConstraintProperties.TALK_PREFERRED_TIMESLOT_TAGS) @Schema( + description = "Soft weight of the talkPreferredTimeslotTags constraint.") Long talkPreferredTimeslotTagsWeight, + @ConstraintReference(ConferenceConstraintProperties.TALK_UNDESIRED_TIMESLOT_TAGS) @Schema( + description = "Soft weight of the talkUndesiredTimeslotTags constraint.") Long talkUndesiredTimeslotTagsWeight, + @ConstraintReference(ConferenceConstraintProperties.SPEAKER_PREFERRED_ROOM_TAGS) @Schema( + description = "Soft weight of the speakerPreferredRoomTags constraint.") Long speakerPreferredRoomTagsWeight, + @ConstraintReference(ConferenceConstraintProperties.SPEAKER_UNDESIRED_ROOM_TAGS) @Schema( + description = "Soft weight of the speakerUndesiredRoomTags constraint.") Long speakerUndesiredRoomTagsWeight, + @ConstraintReference(ConferenceConstraintProperties.TALK_PREFERRED_ROOM_TAGS) @Schema( + description = "Soft weight of the talkPreferredRoomTags constraint.") Long talkPreferredRoomTagsWeight, + @ConstraintReference(ConferenceConstraintProperties.TALK_UNDESIRED_ROOM_TAGS) @Schema( + description = "Soft weight of the talkUndesiredRoomTags constraint.") Long talkUndesiredRoomTagsWeight, + @ConstraintReference(ConferenceConstraintProperties.SPEAKER_MAKESPAN) @Schema( + description = "Soft weight of the speakerMakespan constraint.") Long speakerMakespanWeight) + implements + ModelConfigOverrides { + + public ConferenceScheduleConfigOverrides { + themeTrackConflictWeight = + themeTrackConflictWeight != null && themeTrackConflictWeight < 0L ? 0L : themeTrackConflictWeight; + themeTrackRoomStabilityWeight = themeTrackRoomStabilityWeight != null && themeTrackRoomStabilityWeight < 0L ? 0L + : themeTrackRoomStabilityWeight; + sectorConflictWeight = sectorConflictWeight != null && sectorConflictWeight < 0L ? 0L : sectorConflictWeight; + audienceTypeDiversityWeight = + audienceTypeDiversityWeight != null && audienceTypeDiversityWeight < 0L ? 0L : audienceTypeDiversityWeight; + audienceTypeThemeTrackConflictWeight = + audienceTypeThemeTrackConflictWeight != null && audienceTypeThemeTrackConflictWeight < 0L ? 0L + : audienceTypeThemeTrackConflictWeight; + audienceLevelDiversityWeight = + audienceLevelDiversityWeight != null && audienceLevelDiversityWeight < 0L ? 0L : audienceLevelDiversityWeight; + contentAudienceLevelFlowViolationWeight = + contentAudienceLevelFlowViolationWeight != null && contentAudienceLevelFlowViolationWeight < 0L ? 0L + : contentAudienceLevelFlowViolationWeight; + contentConflictWeight = contentConflictWeight != null && contentConflictWeight < 0L ? 0L : contentConflictWeight; + languageDiversityWeight = + languageDiversityWeight != null && languageDiversityWeight < 0L ? 0L : languageDiversityWeight; + sameDayTalksWeight = sameDayTalksWeight != null && sameDayTalksWeight < 0L ? 0L : sameDayTalksWeight; + popularTalksWeight = popularTalksWeight != null && popularTalksWeight < 0L ? 0L : popularTalksWeight; + speakerPreferredTimeslotTagsWeight = + speakerPreferredTimeslotTagsWeight != null && speakerPreferredTimeslotTagsWeight < 0L ? 0L + : speakerPreferredTimeslotTagsWeight; + speakerUndesiredTimeslotTagsWeight = + speakerUndesiredTimeslotTagsWeight != null && speakerUndesiredTimeslotTagsWeight < 0L ? 0L + : speakerUndesiredTimeslotTagsWeight; + talkPreferredTimeslotTagsWeight = talkPreferredTimeslotTagsWeight != null && talkPreferredTimeslotTagsWeight < 0L ? 0L + : talkPreferredTimeslotTagsWeight; + talkUndesiredTimeslotTagsWeight = talkUndesiredTimeslotTagsWeight != null && talkUndesiredTimeslotTagsWeight < 0L ? 0L + : talkUndesiredTimeslotTagsWeight; + speakerPreferredRoomTagsWeight = speakerPreferredRoomTagsWeight != null && speakerPreferredRoomTagsWeight < 0L ? 0L + : speakerPreferredRoomTagsWeight; + speakerUndesiredRoomTagsWeight = speakerUndesiredRoomTagsWeight != null && speakerUndesiredRoomTagsWeight < 0L ? 0L + : speakerUndesiredRoomTagsWeight; + talkPreferredRoomTagsWeight = + talkPreferredRoomTagsWeight != null && talkPreferredRoomTagsWeight < 0L ? 0L : talkPreferredRoomTagsWeight; + talkUndesiredRoomTagsWeight = + talkUndesiredRoomTagsWeight != null && talkUndesiredRoomTagsWeight < 0L ? 0L : talkUndesiredRoomTagsWeight; + speakerMakespanWeight = speakerMakespanWeight != null && speakerMakespanWeight < 0L ? 0L : speakerMakespanWeight; + } + + public ConferenceScheduleConfigOverrides() { + this(1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L, 1L); + } + + public ConferenceScheduleConfigOverrides withThemeTrackConflictWeight(Long themeTrackConflictWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides withThemeTrackRoomStabilityWeight(Long themeTrackRoomStabilityWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides withSectorConflictWeight(Long sectorConflictWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides withAudienceTypeDiversityWeight(Long audienceTypeDiversityWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides + withAudienceTypeThemeTrackConflictWeight(Long audienceTypeThemeTrackConflictWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides withAudienceLevelDiversityWeight(Long audienceLevelDiversityWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides + withContentAudienceLevelFlowViolationWeight(Long contentAudienceLevelFlowViolationWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides withContentConflictWeight(Long contentConflictWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides withLanguageDiversityWeight(Long languageDiversityWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides withSameDayTalksWeight(Long sameDayTalksWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides withPopularTalksWeight(Long popularTalksWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides withSpeakerPreferredTimeslotTagsWeight(Long speakerPreferredTimeslotTagsWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides withSpeakerUndesiredTimeslotTagsWeight(Long speakerUndesiredTimeslotTagsWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides withTalkPreferredTimeslotTagsWeight(Long talkPreferredTimeslotTagsWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides withTalkUndesiredTimeslotTagsWeight(Long talkUndesiredTimeslotTagsWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides withSpeakerPreferredRoomTagsWeight(Long speakerPreferredRoomTagsWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides withSpeakerUndesiredRoomTagsWeight(Long speakerUndesiredRoomTagsWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides withTalkPreferredRoomTagsWeight(Long talkPreferredRoomTagsWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides withTalkUndesiredRoomTagsWeight(Long talkUndesiredRoomTagsWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } + + public ConferenceScheduleConfigOverrides withSpeakerMakespanWeight(Long speakerMakespanWeight) { + return new ConferenceScheduleConfigOverrides(themeTrackConflictWeight, themeTrackRoomStabilityWeight, + sectorConflictWeight, audienceTypeDiversityWeight, audienceTypeThemeTrackConflictWeight, + audienceLevelDiversityWeight, contentAudienceLevelFlowViolationWeight, contentConflictWeight, + languageDiversityWeight, sameDayTalksWeight, popularTalksWeight, speakerPreferredTimeslotTagsWeight, + speakerUndesiredTimeslotTagsWeight, talkPreferredTimeslotTagsWeight, talkUndesiredTimeslotTagsWeight, + speakerPreferredRoomTagsWeight, speakerUndesiredRoomTagsWeight, talkPreferredRoomTagsWeight, + talkUndesiredRoomTagsWeight, speakerMakespanWeight); + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleInput.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleInput.java new file mode 100644 index 0000000000..0a957cb02e --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleInput.java @@ -0,0 +1,52 @@ +package org.acme.conferencescheduling.dto; + +import java.util.List; + +import ai.timefold.solver.service.definition.api.ModelInput; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(description = "The conference scheduling planning problem input.") +public record ConferenceScheduleInput( + @Schema(description = "Name of the conference.") String name, + @Schema(description = "Talk types restricting compatible timeslots and rooms.") List talkTypes, + @Schema(description = "Timeslots a talk can be assigned to.") List timeslots, + @Schema(description = "Rooms a talk can be assigned to.") List rooms, + @Schema(description = "Speakers presenting the talks.") List speakers, + @Schema(description = "Talks that must each be assigned to a timeslot and a room.") List talks) + implements + ModelInput { + + public ConferenceScheduleInput { + name = name == null ? "" : name; + talkTypes = talkTypes == null ? List.of() : List.copyOf(talkTypes); + timeslots = timeslots == null ? List.of() : List.copyOf(timeslots); + rooms = rooms == null ? List.of() : List.copyOf(rooms); + speakers = speakers == null ? List.of() : List.copyOf(speakers); + talks = talks == null ? List.of() : List.copyOf(talks); + } + + public ConferenceScheduleInput withName(String name) { + return new ConferenceScheduleInput(name, talkTypes, timeslots, rooms, speakers, talks); + } + + public ConferenceScheduleInput withTalkTypes(List talkTypes) { + return new ConferenceScheduleInput(name, talkTypes, timeslots, rooms, speakers, talks); + } + + public ConferenceScheduleInput withTimeslots(List timeslots) { + return new ConferenceScheduleInput(name, talkTypes, timeslots, rooms, speakers, talks); + } + + public ConferenceScheduleInput withRooms(List rooms) { + return new ConferenceScheduleInput(name, talkTypes, timeslots, rooms, speakers, talks); + } + + public ConferenceScheduleInput withSpeakers(List speakers) { + return new ConferenceScheduleInput(name, talkTypes, timeslots, rooms, speakers, talks); + } + + public ConferenceScheduleInput withTalks(List talks) { + return new ConferenceScheduleInput(name, talkTypes, timeslots, rooms, speakers, talks); + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleInputMetrics.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleInputMetrics.java new file mode 100644 index 0000000000..8af1b80ba5 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleInputMetrics.java @@ -0,0 +1,77 @@ +package org.acme.conferencescheduling.dto; + +import ai.timefold.solver.service.definition.api.domain.DataFormat; +import ai.timefold.solver.service.definition.api.metrics.ModelInputMetrics; + +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.extensions.Extension; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +import com.fasterxml.jackson.annotation.JsonFormat; + +@Schema(description = "Metrics describing the conference scheduling problem submitted in the input dataset.") +public record ConferenceScheduleInputMetrics( + @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) @Schema(name = INPUT_METRIC_TALKS, title = "Talks", + format = DataFormat.Values.NUMBER, description = "The number of talks submitted in the input dataset.", + type = SchemaType.INTEGER, example = "15", minimum = "0", readOnly = true, + extensions = { + @Extension(name = X_TF_PRIORITY, value = "1"), + @Extension(name = X_TF_EXAMPLE, value = "15") }) int talks, + @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) @Schema(name = INPUT_METRIC_SPEAKERS, title = "Speakers", + format = DataFormat.Values.NUMBER, description = "The number of speakers submitted in the input dataset.", + type = SchemaType.INTEGER, example = "12", minimum = "0", readOnly = true, + extensions = { @Extension(name = X_TF_PRIORITY, value = "2"), + @Extension(name = X_TF_EXAMPLE, value = "12") }) int speakers, + @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) @Schema(name = INPUT_METRIC_ROOMS, title = "Rooms", + format = DataFormat.Values.NUMBER, description = "The number of rooms submitted in the input dataset.", + type = SchemaType.INTEGER, example = "5", minimum = "0", readOnly = true, + extensions = { @Extension(name = X_TF_PRIORITY, value = "3"), + @Extension(name = X_TF_EXAMPLE, value = "5") }) int rooms, + @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) @Schema(name = INPUT_METRIC_TIMESLOTS, title = "Timeslots", + format = DataFormat.Values.NUMBER, description = "The number of timeslots submitted in the input dataset.", + type = SchemaType.INTEGER, example = "6", minimum = "0", readOnly = true, + extensions = { @Extension(name = X_TF_PRIORITY, value = "4"), + @Extension(name = X_TF_EXAMPLE, value = "6") }) int timeslots, + @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) @Schema(name = INPUT_METRIC_TALK_TYPES, title = "Talk types", + format = DataFormat.Values.NUMBER, description = "The number of talk types submitted in the input dataset.", + type = SchemaType.INTEGER, example = "2", minimum = "0", readOnly = true, + extensions = { @Extension(name = X_TF_PRIORITY, value = "5"), + @Extension(name = X_TF_EXAMPLE, value = "2") }) int talkTypes) + implements + ModelInputMetrics{ + + private static final String X_TF_PRIORITY = "x-tf-priority"; + private static final String X_TF_EXAMPLE = "x-tf-example"; + + public static final String INPUT_METRIC_TALKS = "talks"; + public static final String INPUT_METRIC_SPEAKERS = "speakers"; + public static final String INPUT_METRIC_ROOMS = "rooms"; + public static final String INPUT_METRIC_TIMESLOTS = "timeslots"; + public static final String INPUT_METRIC_TALK_TYPES = "talkTypes"; + + public ConferenceScheduleInputMetrics { + if (talks < 0 || speakers < 0 || rooms < 0 || timeslots < 0 || talkTypes < 0) { + throw new IllegalArgumentException("Input metrics must not be negative."); + } + } + + public ConferenceScheduleInputMetrics withTalks(int talks) { + return new ConferenceScheduleInputMetrics(talks, speakers, rooms, timeslots, talkTypes); + } + + public ConferenceScheduleInputMetrics withSpeakers(int speakers) { + return new ConferenceScheduleInputMetrics(talks, speakers, rooms, timeslots, talkTypes); + } + + public ConferenceScheduleInputMetrics withRooms(int rooms) { + return new ConferenceScheduleInputMetrics(talks, speakers, rooms, timeslots, talkTypes); + } + + public ConferenceScheduleInputMetrics withTimeslots(int timeslots) { + return new ConferenceScheduleInputMetrics(talks, speakers, rooms, timeslots, talkTypes); + } + + public ConferenceScheduleInputMetrics withTalkTypes(int talkTypes) { + return new ConferenceScheduleInputMetrics(talks, speakers, rooms, timeslots, talkTypes); + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleOutput.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleOutput.java new file mode 100644 index 0000000000..e041f8e19e --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleOutput.java @@ -0,0 +1,56 @@ +package org.acme.conferencescheduling.dto; + +import java.util.List; + +import ai.timefold.solver.service.definition.api.ModelOutput; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(description = "The conference scheduling planning problem output.") +public record ConferenceScheduleOutput( + @Schema(description = "Name of the conference.") String name, + @Schema(description = "Talk types restricting compatible timeslots and rooms.") List talkTypes, + @Schema(description = "Timeslots a talk can be assigned to.") List timeslots, + @Schema(description = "Rooms a talk can be assigned to.") List rooms, + @Schema(description = "Speakers presenting the talks.") List speakers, + @Schema(description = "Talks with their assigned timeslot and room.") List talks, + @Schema(description = "The score of the solution.") String score) implements ModelOutput { + + public ConferenceScheduleOutput { + name = name == null ? "" : name; + talkTypes = talkTypes == null ? List.of() : List.copyOf(talkTypes); + timeslots = timeslots == null ? List.of() : List.copyOf(timeslots); + rooms = rooms == null ? List.of() : List.copyOf(rooms); + speakers = speakers == null ? List.of() : List.copyOf(speakers); + talks = talks == null ? List.of() : List.copyOf(talks); + score = score == null ? "" : score; + } + + public ConferenceScheduleOutput withName(String name) { + return new ConferenceScheduleOutput(name, talkTypes, timeslots, rooms, speakers, talks, score); + } + + public ConferenceScheduleOutput withTalkTypes(List talkTypes) { + return new ConferenceScheduleOutput(name, talkTypes, timeslots, rooms, speakers, talks, score); + } + + public ConferenceScheduleOutput withTimeslots(List timeslots) { + return new ConferenceScheduleOutput(name, talkTypes, timeslots, rooms, speakers, talks, score); + } + + public ConferenceScheduleOutput withRooms(List rooms) { + return new ConferenceScheduleOutput(name, talkTypes, timeslots, rooms, speakers, talks, score); + } + + public ConferenceScheduleOutput withSpeakers(List speakers) { + return new ConferenceScheduleOutput(name, talkTypes, timeslots, rooms, speakers, talks, score); + } + + public ConferenceScheduleOutput withTalks(List talks) { + return new ConferenceScheduleOutput(name, talkTypes, timeslots, rooms, speakers, talks, score); + } + + public ConferenceScheduleOutput withScore(String score) { + return new ConferenceScheduleOutput(name, talkTypes, timeslots, rooms, speakers, talks, score); + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleOutputMetrics.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleOutputMetrics.java new file mode 100644 index 0000000000..0176fc9b68 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleOutputMetrics.java @@ -0,0 +1,75 @@ +package org.acme.conferencescheduling.dto; + +import ai.timefold.solver.service.definition.api.domain.DataFormat; +import ai.timefold.solver.service.definition.api.metrics.ModelOutputMetrics; + +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.extensions.Extension; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +import com.fasterxml.jackson.annotation.JsonFormat; + +@Schema(description = "Metrics describing the conference scheduling solution produced for this schedule.") +public record ConferenceScheduleOutputMetrics( + @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) @Schema(name = TOTAL_SCHEDULED_TALKS, title = "Scheduled talks", + format = DataFormat.Values.NUMBER, + description = "The number of talks assigned to both a timeslot and a room in this schedule.", + type = SchemaType.INTEGER, example = "15", minimum = "0", readOnly = true, + extensions = { + @Extension(name = X_TF_PRIORITY, value = "1"), + @Extension(name = X_TF_EXAMPLE, value = "15") }) int totalScheduledTalks, + @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) @Schema(name = TOTAL_UNSCHEDULED_TALKS, title = "Unscheduled talks", + format = DataFormat.Values.NUMBER, + description = "The number of talks left without a timeslot or room in this schedule.", + type = SchemaType.INTEGER, example = "0", minimum = "0", readOnly = true, + extensions = { @Extension(name = X_TF_PRIORITY, value = "2"), + @Extension(name = X_TF_EXAMPLE, value = "0") }) int totalUnscheduledTalks, + @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) @Schema(name = TOTAL_USED_ROOMS, title = "Used rooms", + format = DataFormat.Values.NUMBER, + description = "The number of distinct rooms used by at least one talk in this schedule.", + type = SchemaType.INTEGER, example = "5", minimum = "0", readOnly = true, + extensions = { @Extension(name = X_TF_PRIORITY, value = "3"), + @Extension(name = X_TF_EXAMPLE, value = "5") }) int totalUsedRooms, + @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT) @Schema(name = TOTAL_USED_TIMESLOTS, title = "Used timeslots", + format = DataFormat.Values.NUMBER, + description = "The number of distinct timeslots used by at least one talk in this schedule.", + type = SchemaType.INTEGER, example = "6", minimum = "0", readOnly = true, + extensions = { @Extension(name = X_TF_PRIORITY, value = "4"), + @Extension(name = X_TF_EXAMPLE, value = "6") }) int totalUsedTimeslots) + implements + ModelOutputMetrics{ + + private static final String X_TF_PRIORITY = "x-tf-priority"; + private static final String X_TF_EXAMPLE = "x-tf-example"; + + public static final String TOTAL_SCHEDULED_TALKS = "totalScheduledTalks"; + public static final String TOTAL_UNSCHEDULED_TALKS = "totalUnscheduledTalks"; + public static final String TOTAL_USED_ROOMS = "totalUsedRooms"; + public static final String TOTAL_USED_TIMESLOTS = "totalUsedTimeslots"; + + public ConferenceScheduleOutputMetrics { + if (totalScheduledTalks < 0 || totalUnscheduledTalks < 0 || totalUsedRooms < 0 || totalUsedTimeslots < 0) { + throw new IllegalArgumentException("Output metrics must not be negative."); + } + } + + public ConferenceScheduleOutputMetrics withTotalScheduledTalks(int totalScheduledTalks) { + return new ConferenceScheduleOutputMetrics(totalScheduledTalks, totalUnscheduledTalks, totalUsedRooms, + totalUsedTimeslots); + } + + public ConferenceScheduleOutputMetrics withTotalUnscheduledTalks(int totalUnscheduledTalks) { + return new ConferenceScheduleOutputMetrics(totalScheduledTalks, totalUnscheduledTalks, totalUsedRooms, + totalUsedTimeslots); + } + + public ConferenceScheduleOutputMetrics withTotalUsedRooms(int totalUsedRooms) { + return new ConferenceScheduleOutputMetrics(totalScheduledTalks, totalUnscheduledTalks, totalUsedRooms, + totalUsedTimeslots); + } + + public ConferenceScheduleOutputMetrics withTotalUsedTimeslots(int totalUsedTimeslots) { + return new ConferenceScheduleOutputMetrics(totalScheduledTalks, totalUnscheduledTalks, totalUsedRooms, + totalUsedTimeslots); + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleValidationIssue.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleValidationIssue.java new file mode 100644 index 0000000000..2b959b83f9 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleValidationIssue.java @@ -0,0 +1,46 @@ +package org.acme.conferencescheduling.dto; + +import ai.timefold.solver.service.definition.api.validation.IssueCode; +import ai.timefold.solver.service.definition.api.validation.IssueSeverity; +import ai.timefold.solver.service.definition.api.validation.IssueType; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(description = "Validation issues that can be found in a conference scheduling problem.") +@SuppressWarnings("ImmutableEnumChecker") +public enum ConferenceScheduleValidationIssue { + TALK_ID_MISSING(IssueCode.of("TALK_ID_MISSING"), IssueSeverity.ERROR, + "Talk code must not be null or blank."), + DUPLICATE_TALK_ID(IssueCode.of("DUPLICATE_TALK_ID"), IssueSeverity.ERROR, + "Duplicate talk code found."), + SPEAKER_ID_MISSING(IssueCode.of("SPEAKER_ID_MISSING"), IssueSeverity.ERROR, + "Speaker ID must not be null or blank."), + DUPLICATE_SPEAKER_ID(IssueCode.of("DUPLICATE_SPEAKER_ID"), IssueSeverity.ERROR, + "Duplicate speaker ID found."), + ROOM_ID_MISSING(IssueCode.of("ROOM_ID_MISSING"), IssueSeverity.ERROR, + "Room ID must not be null or blank."), + DUPLICATE_ROOM_ID(IssueCode.of("DUPLICATE_ROOM_ID"), IssueSeverity.ERROR, + "Duplicate room ID found."), + TIMESLOT_ID_MISSING(IssueCode.of("TIMESLOT_ID_MISSING"), IssueSeverity.ERROR, + "Timeslot ID must not be null or blank."), + DUPLICATE_TIMESLOT_ID(IssueCode.of("DUPLICATE_TIMESLOT_ID"), IssueSeverity.ERROR, + "Duplicate timeslot ID found."), + NON_EXISTING_TIMESLOT_REFERENCE(IssueCode.of("NON_EXISTING_TIMESLOT_REFERENCE"), IssueSeverity.ERROR, + "Talk references non-existing timeslot."), + NON_EXISTING_ROOM_REFERENCE(IssueCode.of("NON_EXISTING_ROOM_REFERENCE"), IssueSeverity.ERROR, + "Talk references non-existing room."), + NON_EXISTING_SPEAKER_REFERENCE(IssueCode.of("NON_EXISTING_SPEAKER_REFERENCE"), IssueSeverity.ERROR, + "Talk references non-existing speaker."), + NON_EXISTING_TALK_TYPE_REFERENCE(IssueCode.of("NON_EXISTING_TALK_TYPE_REFERENCE"), IssueSeverity.ERROR, + "Talk references non-existing talk type."); + + private final transient IssueType issueType; + + ConferenceScheduleValidationIssue(IssueCode code, IssueSeverity severity, String message) { + this.issueType = new IssueType(code, severity, message); + } + + public IssueType asIssueType() { + return issueType; + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/RoomDTO.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/RoomDTO.java new file mode 100644 index 0000000000..8328c9d225 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/RoomDTO.java @@ -0,0 +1,46 @@ +package org.acme.conferencescheduling.dto; + +import java.util.List; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(description = "A room in which talks can be scheduled.") +public record RoomDTO( + @Schema(description = "Unique identifier of the room.") String id, + @Schema(description = "Display name of the room.") String name, + @Schema(description = "Seating capacity of the room.") int capacity, + @Schema(description = "Names of the talk types compatible with this room.") List talkTypeNames, + @Schema(description = "IDs of the timeslots during which this room is unavailable.") List unavailableTimeslotIds, + @Schema(description = "Tags describing this room.") List tags) { + + public RoomDTO { + name = name == null ? "" : name; + talkTypeNames = talkTypeNames == null ? List.of() : List.copyOf(talkTypeNames); + unavailableTimeslotIds = unavailableTimeslotIds == null ? List.of() : List.copyOf(unavailableTimeslotIds); + tags = tags == null ? List.of() : List.copyOf(tags); + } + + public RoomDTO withId(String id) { + return new RoomDTO(id, name, capacity, talkTypeNames, unavailableTimeslotIds, tags); + } + + public RoomDTO withName(String name) { + return new RoomDTO(id, name, capacity, talkTypeNames, unavailableTimeslotIds, tags); + } + + public RoomDTO withCapacity(int capacity) { + return new RoomDTO(id, name, capacity, talkTypeNames, unavailableTimeslotIds, tags); + } + + public RoomDTO withTalkTypeNames(List talkTypeNames) { + return new RoomDTO(id, name, capacity, talkTypeNames, unavailableTimeslotIds, tags); + } + + public RoomDTO withUnavailableTimeslotIds(List unavailableTimeslotIds) { + return new RoomDTO(id, name, capacity, talkTypeNames, unavailableTimeslotIds, tags); + } + + public RoomDTO withTags(List tags) { + return new RoomDTO(id, name, capacity, talkTypeNames, unavailableTimeslotIds, tags); + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/RoomIdDetail.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/RoomIdDetail.java new file mode 100644 index 0000000000..76fad112cc --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/RoomIdDetail.java @@ -0,0 +1,23 @@ +package org.acme.conferencescheduling.dto; + +import ai.timefold.solver.service.definition.api.validation.IssueMetadata; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(description = "Details about a room ID validation issue.") +public record RoomIdDetail( + @Schema(description = "The ID of the room.") String roomId) implements IssueMetadata { + + public RoomIdDetail { + roomId = roomId == null ? "" : roomId; + } + + public RoomIdDetail withRoomId(String roomId) { + return new RoomIdDetail(roomId); + } + + @Override + public String getType() { + return "RoomId"; + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/SpeakerDTO.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/SpeakerDTO.java new file mode 100644 index 0000000000..8746fb45c4 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/SpeakerDTO.java @@ -0,0 +1,99 @@ +package org.acme.conferencescheduling.dto; + +import java.util.List; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(description = "A speaker who presents one or more talks.") +public record SpeakerDTO( + @Schema(description = "Unique identifier of the speaker.") String id, + @Schema(description = "Display name of the speaker.") String name, + @Schema(description = "IDs of the timeslots during which this speaker is unavailable.") List unavailableTimeslotIds, + @Schema(description = "Timeslot tags required by this speaker.") List requiredTimeslotTags, + @Schema(description = "Timeslot tags preferred by this speaker.") List preferredTimeslotTags, + @Schema(description = "Timeslot tags prohibited by this speaker.") List prohibitedTimeslotTags, + @Schema(description = "Timeslot tags undesired by this speaker.") List undesiredTimeslotTags, + @Schema(description = "Room tags required by this speaker.") List requiredRoomTags, + @Schema(description = "Room tags preferred by this speaker.") List preferredRoomTags, + @Schema(description = "Room tags prohibited by this speaker.") List prohibitedRoomTags, + @Schema(description = "Room tags undesired by this speaker.") List undesiredRoomTags) { + + public SpeakerDTO { + name = name == null ? "" : name; + unavailableTimeslotIds = unavailableTimeslotIds == null ? List.of() : List.copyOf(unavailableTimeslotIds); + requiredTimeslotTags = requiredTimeslotTags == null ? List.of() : List.copyOf(requiredTimeslotTags); + preferredTimeslotTags = preferredTimeslotTags == null ? List.of() : List.copyOf(preferredTimeslotTags); + prohibitedTimeslotTags = prohibitedTimeslotTags == null ? List.of() : List.copyOf(prohibitedTimeslotTags); + undesiredTimeslotTags = undesiredTimeslotTags == null ? List.of() : List.copyOf(undesiredTimeslotTags); + requiredRoomTags = requiredRoomTags == null ? List.of() : List.copyOf(requiredRoomTags); + preferredRoomTags = preferredRoomTags == null ? List.of() : List.copyOf(preferredRoomTags); + prohibitedRoomTags = prohibitedRoomTags == null ? List.of() : List.copyOf(prohibitedRoomTags); + undesiredRoomTags = undesiredRoomTags == null ? List.of() : List.copyOf(undesiredRoomTags); + } + + public SpeakerDTO withId(String id) { + return new SpeakerDTO(id, name, unavailableTimeslotIds, requiredTimeslotTags, preferredTimeslotTags, + prohibitedTimeslotTags, undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, + undesiredRoomTags); + } + + public SpeakerDTO withName(String name) { + return new SpeakerDTO(id, name, unavailableTimeslotIds, requiredTimeslotTags, preferredTimeslotTags, + prohibitedTimeslotTags, undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, + undesiredRoomTags); + } + + public SpeakerDTO withUnavailableTimeslotIds(List unavailableTimeslotIds) { + return new SpeakerDTO(id, name, unavailableTimeslotIds, requiredTimeslotTags, preferredTimeslotTags, + prohibitedTimeslotTags, undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, + undesiredRoomTags); + } + + public SpeakerDTO withRequiredTimeslotTags(List requiredTimeslotTags) { + return new SpeakerDTO(id, name, unavailableTimeslotIds, requiredTimeslotTags, preferredTimeslotTags, + prohibitedTimeslotTags, undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, + undesiredRoomTags); + } + + public SpeakerDTO withPreferredTimeslotTags(List preferredTimeslotTags) { + return new SpeakerDTO(id, name, unavailableTimeslotIds, requiredTimeslotTags, preferredTimeslotTags, + prohibitedTimeslotTags, undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, + undesiredRoomTags); + } + + public SpeakerDTO withProhibitedTimeslotTags(List prohibitedTimeslotTags) { + return new SpeakerDTO(id, name, unavailableTimeslotIds, requiredTimeslotTags, preferredTimeslotTags, + prohibitedTimeslotTags, undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, + undesiredRoomTags); + } + + public SpeakerDTO withUndesiredTimeslotTags(List undesiredTimeslotTags) { + return new SpeakerDTO(id, name, unavailableTimeslotIds, requiredTimeslotTags, preferredTimeslotTags, + prohibitedTimeslotTags, undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, + undesiredRoomTags); + } + + public SpeakerDTO withRequiredRoomTags(List requiredRoomTags) { + return new SpeakerDTO(id, name, unavailableTimeslotIds, requiredTimeslotTags, preferredTimeslotTags, + prohibitedTimeslotTags, undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, + undesiredRoomTags); + } + + public SpeakerDTO withPreferredRoomTags(List preferredRoomTags) { + return new SpeakerDTO(id, name, unavailableTimeslotIds, requiredTimeslotTags, preferredTimeslotTags, + prohibitedTimeslotTags, undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, + undesiredRoomTags); + } + + public SpeakerDTO withProhibitedRoomTags(List prohibitedRoomTags) { + return new SpeakerDTO(id, name, unavailableTimeslotIds, requiredTimeslotTags, preferredTimeslotTags, + prohibitedTimeslotTags, undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, + undesiredRoomTags); + } + + public SpeakerDTO withUndesiredRoomTags(List undesiredRoomTags) { + return new SpeakerDTO(id, name, unavailableTimeslotIds, requiredTimeslotTags, preferredTimeslotTags, + prohibitedTimeslotTags, undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, + undesiredRoomTags); + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/SpeakerIdDetail.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/SpeakerIdDetail.java new file mode 100644 index 0000000000..7cc92d64b9 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/SpeakerIdDetail.java @@ -0,0 +1,23 @@ +package org.acme.conferencescheduling.dto; + +import ai.timefold.solver.service.definition.api.validation.IssueMetadata; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(description = "Details about a speaker ID validation issue.") +public record SpeakerIdDetail( + @Schema(description = "The ID of the speaker.") String speakerId) implements IssueMetadata { + + public SpeakerIdDetail { + speakerId = speakerId == null ? "" : speakerId; + } + + public SpeakerIdDetail withSpeakerId(String speakerId) { + return new SpeakerIdDetail(speakerId); + } + + @Override + public String getType() { + return "SpeakerId"; + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TalkDTO.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TalkDTO.java new file mode 100644 index 0000000000..c00a5cc223 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TalkDTO.java @@ -0,0 +1,229 @@ +package org.acme.conferencescheduling.dto; + +import java.util.List; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(description = "A talk to be assigned to a timeslot and a room.") +public record TalkDTO( + @Schema(description = "code") String code, + @Schema(description = "title") String title, + @Schema(description = "talkTypeName") String talkTypeName, + @Schema(description = "speakerIds") List speakerIds, + @Schema(description = "themeTrackTags") List themeTrackTags, + @Schema(description = "sectorTags") List sectorTags, + @Schema(description = "audienceTypes") List audienceTypes, + @Schema(description = "audienceLevel") int audienceLevel, + @Schema(description = "contentTags") List contentTags, + @Schema(description = "language") String language, + @Schema(description = "requiredTimeslotTags") List requiredTimeslotTags, + @Schema(description = "preferredTimeslotTags") List preferredTimeslotTags, + @Schema(description = "prohibitedTimeslotTags") List prohibitedTimeslotTags, + @Schema(description = "undesiredTimeslotTags") List undesiredTimeslotTags, + @Schema(description = "requiredRoomTags") List requiredRoomTags, + @Schema(description = "preferredRoomTags") List preferredRoomTags, + @Schema(description = "prohibitedRoomTags") List prohibitedRoomTags, + @Schema(description = "undesiredRoomTags") List undesiredRoomTags, + @Schema(description = "mutuallyExclusiveTalksTags") List mutuallyExclusiveTalksTags, + @Schema(description = "prerequisiteTalkCodes") List prerequisiteTalkCodes, + @Schema(description = "favoriteCount") int favoriteCount, + @Schema(description = "crowdControlRisk") int crowdControlRisk, + @Schema(description = "timeslotId") String timeslotId, + @Schema(description = "roomId") String roomId) { + + public TalkDTO { + code = code == null ? "" : code; + title = title == null ? "" : title; + talkTypeName = talkTypeName == null ? "" : talkTypeName; + speakerIds = speakerIds == null ? List.of() : List.copyOf(speakerIds); + themeTrackTags = themeTrackTags == null ? List.of() : List.copyOf(themeTrackTags); + sectorTags = sectorTags == null ? List.of() : List.copyOf(sectorTags); + audienceTypes = audienceTypes == null ? List.of() : List.copyOf(audienceTypes); + contentTags = contentTags == null ? List.of() : List.copyOf(contentTags); + language = language == null ? "" : language; + requiredTimeslotTags = requiredTimeslotTags == null ? List.of() : List.copyOf(requiredTimeslotTags); + preferredTimeslotTags = preferredTimeslotTags == null ? List.of() : List.copyOf(preferredTimeslotTags); + prohibitedTimeslotTags = prohibitedTimeslotTags == null ? List.of() : List.copyOf(prohibitedTimeslotTags); + undesiredTimeslotTags = undesiredTimeslotTags == null ? List.of() : List.copyOf(undesiredTimeslotTags); + requiredRoomTags = requiredRoomTags == null ? List.of() : List.copyOf(requiredRoomTags); + preferredRoomTags = preferredRoomTags == null ? List.of() : List.copyOf(preferredRoomTags); + prohibitedRoomTags = prohibitedRoomTags == null ? List.of() : List.copyOf(prohibitedRoomTags); + undesiredRoomTags = undesiredRoomTags == null ? List.of() : List.copyOf(undesiredRoomTags); + mutuallyExclusiveTalksTags = mutuallyExclusiveTalksTags == null ? List.of() : List.copyOf(mutuallyExclusiveTalksTags); + prerequisiteTalkCodes = prerequisiteTalkCodes == null ? List.of() : List.copyOf(prerequisiteTalkCodes); + timeslotId = normalizeId(timeslotId); + roomId = normalizeId(roomId); + } + + private static String normalizeId(String id) { + return id != null && id.isBlank() ? null : id; + } + + public TalkDTO withCode(String code) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withTitle(String title) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withTalkTypeName(String talkTypeName) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withSpeakerIds(List speakerIds) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withThemeTrackTags(List themeTrackTags) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withSectorTags(List sectorTags) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withAudienceTypes(List audienceTypes) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withAudienceLevel(int audienceLevel) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withContentTags(List contentTags) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withLanguage(String language) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withRequiredTimeslotTags(List requiredTimeslotTags) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withPreferredTimeslotTags(List preferredTimeslotTags) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withProhibitedTimeslotTags(List prohibitedTimeslotTags) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withUndesiredTimeslotTags(List undesiredTimeslotTags) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withRequiredRoomTags(List requiredRoomTags) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withPreferredRoomTags(List preferredRoomTags) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withProhibitedRoomTags(List prohibitedRoomTags) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withUndesiredRoomTags(List undesiredRoomTags) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withMutuallyExclusiveTalksTags(List mutuallyExclusiveTalksTags) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withPrerequisiteTalkCodes(List prerequisiteTalkCodes) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withFavoriteCount(int favoriteCount) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withCrowdControlRisk(int crowdControlRisk) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withTimeslotId(String timeslotId) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } + + public TalkDTO withRoomId(String roomId) { + return new TalkDTO(code, title, talkTypeName, speakerIds, themeTrackTags, sectorTags, audienceTypes, audienceLevel, + contentTags, language, requiredTimeslotTags, preferredTimeslotTags, prohibitedTimeslotTags, + undesiredTimeslotTags, requiredRoomTags, preferredRoomTags, prohibitedRoomTags, undesiredRoomTags, + mutuallyExclusiveTalksTags, prerequisiteTalkCodes, favoriteCount, crowdControlRisk, timeslotId, roomId); + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TalkIdDetail.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TalkIdDetail.java new file mode 100644 index 0000000000..3863327262 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TalkIdDetail.java @@ -0,0 +1,23 @@ +package org.acme.conferencescheduling.dto; + +import ai.timefold.solver.service.definition.api.validation.IssueMetadata; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(description = "Details about a talk ID validation issue.") +public record TalkIdDetail( + @Schema(description = "The ID of the talk.") String talkId) implements IssueMetadata { + + public TalkIdDetail { + talkId = talkId == null ? "" : talkId; + } + + public TalkIdDetail withTalkId(String talkId) { + return new TalkIdDetail(talkId); + } + + @Override + public String getType() { + return "TalkId"; + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TalkTypeDTO.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TalkTypeDTO.java new file mode 100644 index 0000000000..21da844066 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TalkTypeDTO.java @@ -0,0 +1,16 @@ +package org.acme.conferencescheduling.dto; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(description = "A type of talk, e.g. Breakout or Lab, restricting compatible timeslots and rooms.") +public record TalkTypeDTO( + @Schema(description = "Unique name of the talk type.") String name) { + + public TalkTypeDTO { + name = name == null ? "" : name; + } + + public TalkTypeDTO withName(String name) { + return new TalkTypeDTO(name); + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TimeslotDTO.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TimeslotDTO.java new file mode 100644 index 0000000000..885636552c --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TimeslotDTO.java @@ -0,0 +1,39 @@ +package org.acme.conferencescheduling.dto; + +import java.util.List; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(description = "A timeslot during which talks can be scheduled.") +public record TimeslotDTO( + @Schema(description = "Unique identifier of the timeslot.") String id, + @Schema(description = "Local start date-time in ISO-8601 format.") String startDateTime, + @Schema(description = "Local end date-time in ISO-8601 format.") String endDateTime, + @Schema(description = "Names of the talk types compatible with this timeslot.") List talkTypeNames, + @Schema(description = "Tags describing this timeslot.") List tags) { + + public TimeslotDTO { + talkTypeNames = talkTypeNames == null ? List.of() : List.copyOf(talkTypeNames); + tags = tags == null ? List.of() : List.copyOf(tags); + } + + public TimeslotDTO withId(String id) { + return new TimeslotDTO(id, startDateTime, endDateTime, talkTypeNames, tags); + } + + public TimeslotDTO withStartDateTime(String startDateTime) { + return new TimeslotDTO(id, startDateTime, endDateTime, talkTypeNames, tags); + } + + public TimeslotDTO withEndDateTime(String endDateTime) { + return new TimeslotDTO(id, startDateTime, endDateTime, talkTypeNames, tags); + } + + public TimeslotDTO withTalkTypeNames(List talkTypeNames) { + return new TimeslotDTO(id, startDateTime, endDateTime, talkTypeNames, tags); + } + + public TimeslotDTO withTags(List tags) { + return new TimeslotDTO(id, startDateTime, endDateTime, talkTypeNames, tags); + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TimeslotIdDetail.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TimeslotIdDetail.java new file mode 100644 index 0000000000..873825cc45 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TimeslotIdDetail.java @@ -0,0 +1,23 @@ +package org.acme.conferencescheduling.dto; + +import ai.timefold.solver.service.definition.api.validation.IssueMetadata; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(description = "Details about a timeslot ID validation issue.") +public record TimeslotIdDetail( + @Schema(description = "The ID of the timeslot.") String timeslotId) implements IssueMetadata { + + public TimeslotIdDetail { + timeslotId = timeslotId == null ? "" : timeslotId; + } + + public TimeslotIdDetail withTimeslotId(String timeslotId) { + return new TimeslotIdDetail(timeslotId); + } + + @Override + public String getType() { + return "TimeslotId"; + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/ConferenceScheduleResource.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/ConferenceScheduleResource.java new file mode 100644 index 0000000000..e53cdde052 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/ConferenceScheduleResource.java @@ -0,0 +1,9 @@ +package org.acme.conferencescheduling.rest; + +import jakarta.ws.rs.Path; + +import ai.timefold.solver.service.rest.api.ModelRest; + +@Path("/schedules") +public interface ConferenceScheduleResource extends ModelRest { +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/ConferenceSchedulingDemoResource.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/ConferenceSchedulingDemoResource.java deleted file mode 100644 index 2fc586e06c..0000000000 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/ConferenceSchedulingDemoResource.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.acme.conferencescheduling.rest; - -import jakarta.inject.Inject; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; - -import org.acme.conferencescheduling.domain.ConferenceSchedule; -import org.eclipse.microprofile.openapi.annotations.Operation; -import org.eclipse.microprofile.openapi.annotations.media.Content; -import org.eclipse.microprofile.openapi.annotations.media.Schema; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; -import org.eclipse.microprofile.openapi.annotations.tags.Tag; - -@Tag(name = "Demo data", description = "Timefold-provided demo conference scheduling data.") -@Path("demo-data") -public class ConferenceSchedulingDemoResource { - - private final DemoDataGenerator dataGenerator; - - @Inject - public ConferenceSchedulingDemoResource(DemoDataGenerator dataGenerator) { - this.dataGenerator = dataGenerator; - } - - @APIResponses(value = { - @APIResponse(responseCode = "200", description = "Unsolved demo schedule.", - content = @Content(mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(implementation = ConferenceSchedule.class))) }) - @Operation(summary = "Find an unsolved demo schedule by ID.") - @GET - public Response generate() { - return Response.ok(dataGenerator.generateDemoData()).build(); - } - -} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/ConferenceSchedulingResource.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/ConferenceSchedulingResource.java deleted file mode 100644 index 0a6261960b..0000000000 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/ConferenceSchedulingResource.java +++ /dev/null @@ -1,229 +0,0 @@ -package org.acme.conferencescheduling.rest; - -import java.time.LocalDateTime; -import java.util.Collection; -import java.util.List; -import java.util.Map.Entry; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; - -import ai.timefold.solver.core.api.score.HardSoftScore; -import ai.timefold.solver.core.api.score.analysis.ScoreAnalysis; -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 ai.timefold.solver.core.api.solver.SolverStatus; -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.conferencescheduling.domain.ConferenceSchedule; -import org.acme.conferencescheduling.rest.exception.ConferenceScheduleSolverException; -import org.acme.conferencescheduling.rest.exception.ErrorInfo; -import org.eclipse.microprofile.openapi.annotations.Operation; -import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; -import org.eclipse.microprofile.openapi.annotations.media.Content; -import org.eclipse.microprofile.openapi.annotations.media.Schema; -import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; -import org.eclipse.microprofile.openapi.annotations.tags.Tag; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Tag(name = "Conference Scheduling", - description = "Conference Scheduling service assigning rooms and timeslots for conference talks.") -@Path("schedules") -public class ConferenceSchedulingResource { - - private static final Logger LOGGER = LoggerFactory.getLogger(ConferenceSchedulingResource.class); - private static final int MAX_JOBS_CACHE_SIZE = 2; - - private final SolverManager solverManager; - private final SolutionManager solutionManager; - private final ConcurrentMap jobIdToJob = new ConcurrentHashMap<>(); - - // Workaround to make Quarkus CDI happy. Do not use. - public ConferenceSchedulingResource() { - this.solverManager = null; - this.solutionManager = null; - } - - @Inject - public ConferenceSchedulingResource(SolverManager solverManager, - SolutionManager solutionManager) { - this.solverManager = solverManager; - this.solutionManager = solutionManager; - } - - @Operation(summary = "List the job IDs of all submitted schedules.") - @APIResponses(value = { - @APIResponse(responseCode = "200", description = "List of all job IDs.", - content = @Content(mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(type = SchemaType.ARRAY, implementation = String.class))) }) - @GET - @Produces(MediaType.APPLICATION_JSON) - public Collection list() { - return jobIdToJob.keySet(); - } - - @Operation(summary = "Submit a schedule to start solving as soon as CPU resources are available.") - @APIResponses(value = { - @APIResponse(responseCode = "202", - description = "The job ID. Use that ID to get the solution with the other methods.", - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(implementation = String.class))) }) - @POST - @Consumes({ MediaType.APPLICATION_JSON }) - @Produces(MediaType.TEXT_PLAIN) - public String solve(ConferenceSchedule problem) { - String jobId = UUID.randomUUID().toString(); - jobIdToJob.put(jobId, Job.ofSchedule(problem)); - solverManager.solveBuilder() - .withProblemId(jobId) - .withProblemFinder(id -> jobIdToJob.get(jobId).schedule) - .withBestSolutionEventConsumer(event -> jobIdToJob.put(jobId, Job.ofSchedule(event.solution()))) - .withExceptionHandler((id, exception) -> { - jobIdToJob.put((String) id, Job.ofException(exception)); - LOGGER.error("Failed solving jobId ({}).", id, exception); - }) - .run(); - cleanJobs(); - return jobId; - } - - @Operation(summary = "Submit a schedule to analyze its score.") - @APIResponses(value = { - @APIResponse(responseCode = "202", - description = "Resulting score analysis, optionally without constraint matches.", - content = @Content(mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(implementation = ScoreAnalysis.class))) }) - @PUT - @Consumes({ MediaType.APPLICATION_JSON }) - @Produces(MediaType.APPLICATION_JSON) - @Path("analyze") - public ScoreAnalysis analyze(ConferenceSchedule problem, - @QueryParam("fetchPolicy") ScoreAnalysisFetchPolicy fetchPolicy) { - return fetchPolicy == null ? solutionManager.analyze(problem) : solutionManager.analyze(problem, fetchPolicy); - } - - @Operation( - summary = "Get the solution and score for a given job ID. This is the best solution so far, as it might still be running or not even started.") - @APIResponses(value = { - @APIResponse(responseCode = "200", description = "The best solution of the schedule so far.", - content = @Content(mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(implementation = ConferenceSchedule.class))), - @APIResponse(responseCode = "404", description = "No schedule found.", - content = @Content(mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(implementation = ErrorInfo.class))), - @APIResponse(responseCode = "500", description = "Exception during solving a schedule.", - content = @Content(mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(implementation = ErrorInfo.class))) - }) - @GET - @Produces(MediaType.APPLICATION_JSON) - @Path("{jobId}") - public ConferenceSchedule - getConferenceSchedule( - @Parameter(description = "The job ID returned by the POST method.") @PathParam("jobId") String jobId) { - ConferenceSchedule schedule = getScheduleAndCheckForExceptions(jobId); - SolverStatus solverStatus = solverManager.getSolverStatus(jobId); - schedule.setSolverStatus(solverStatus); - return schedule; - } - - @Operation( - summary = "Get the schedule status and score for a given job ID.") - @APIResponses(value = { - @APIResponse(responseCode = "200", description = "The schedule status and the best score so far.", - content = @Content(mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(implementation = ConferenceSchedule.class))), - @APIResponse(responseCode = "404", description = "No schedule found.", - content = @Content(mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(implementation = ErrorInfo.class))), - @APIResponse(responseCode = "500", description = "Exception during solving a schedule.", - content = @Content(mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(implementation = ErrorInfo.class))) - }) - @GET - @Produces(MediaType.APPLICATION_JSON) - @Path("{jobId}/status") - public ConferenceSchedule getStatus( - @Parameter(description = "The job ID returned by the POST method.") @PathParam("jobId") String jobId) { - ConferenceSchedule schedule = getScheduleAndCheckForExceptions(jobId); - SolverStatus solverStatus = solverManager.getSolverStatus(jobId); - return new ConferenceSchedule(schedule.getName(), schedule.getScore(), solverStatus); - } - - @Operation( - summary = "Terminate solving for a given job ID. Returns the best solution of the schedule so far, as it might still be running or not even started.") - @APIResponses(value = { - @APIResponse(responseCode = "200", description = "The best solution of the schedule so far.", - content = @Content(mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(implementation = ConferenceSchedule.class))), - @APIResponse(responseCode = "404", description = "No schedule found.", - content = @Content(mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(implementation = ErrorInfo.class))), - @APIResponse(responseCode = "500", description = "Exception during solving a schedule.", - content = @Content(mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(implementation = ErrorInfo.class))) - }) - @DELETE - @Produces(MediaType.APPLICATION_JSON) - @Path("{jobId}") - public ConferenceSchedule terminateSolving( - @Parameter(description = "The job ID returned by the POST method.") @PathParam("jobId") String jobId) { - solverManager.terminateEarly(jobId); - return getConferenceSchedule(jobId); - } - - private ConferenceSchedule getScheduleAndCheckForExceptions(String jobId) { - Job job = jobIdToJob.get(jobId); - if (job == null) { - throw new ConferenceScheduleSolverException(jobId, Response.Status.NOT_FOUND, "No schedule found."); - } - if (job.exception != null) { - throw new ConferenceScheduleSolverException(jobId, job.exception); - } - return job.schedule; - } - - /** - * The method retains only the records of the last MAX_JOBS_CACHE_SIZE completed jobs by removing the oldest ones. - */ - private void cleanJobs() { - if (jobIdToJob.size() <= MAX_JOBS_CACHE_SIZE) { - return; - } - List jobsToRemove = jobIdToJob.entrySet().stream() - .filter(e -> getStatus(e.getKey()).getSolverStatus() != SolverStatus.NOT_SOLVING) - .filter(e -> jobIdToJob.get(e.getKey()).schedule() != null) - .sorted((j1, j2) -> jobIdToJob.get(j1.getKey()).createdAt().compareTo(jobIdToJob.get(j2.getKey()).createdAt())) - .map(Entry::getKey) - .toList(); - if (jobsToRemove.size() > MAX_JOBS_CACHE_SIZE) { - for (int i = 0; i < jobsToRemove.size() - MAX_JOBS_CACHE_SIZE; i++) { - jobIdToJob.remove(jobsToRemove.get(i)); - } - } - } - - private record Job(ConferenceSchedule schedule, LocalDateTime createdAt, Throwable exception) { - - static Job ofSchedule(ConferenceSchedule schedule) { - return new Job(schedule, LocalDateTime.now(), null); - } - - static Job ofException(Throwable error) { - return new Job(null, LocalDateTime.now(), error); - } - } -} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/DemoDataGenerator.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/DemoDataGenerator.java deleted file mode 100644 index c0310ad107..0000000000 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/DemoDataGenerator.java +++ /dev/null @@ -1,214 +0,0 @@ -package org.acme.conferencescheduling.rest; - -import static java.util.Collections.emptySet; -import static java.util.stream.Collectors.toSet; - -import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Optional; -import java.util.Random; -import java.util.SequencedSet; -import java.util.Set; -import java.util.stream.Collectors; - -import jakarta.enterprise.context.ApplicationScoped; - -import org.acme.conferencescheduling.domain.ConferenceConstraintProperties; -import org.acme.conferencescheduling.domain.ConferenceSchedule; -import org.acme.conferencescheduling.domain.Room; -import org.acme.conferencescheduling.domain.Speaker; -import org.acme.conferencescheduling.domain.Talk; -import org.acme.conferencescheduling.domain.TalkType; -import org.acme.conferencescheduling.domain.Timeslot; - -@ApplicationScoped -public class DemoDataGenerator { - - // Talk types - private static final String BREAKOUT_TALK_TAG = "Breakout"; - private static final String LAB_TALK_TAG = "Lab"; - // Tags - private static final String AFTER_LUNCH_TAG = "After lunch"; - private static final String RECORDED_TAG = "Recorded"; - private static final String LARGE_TAG = "Large"; - // Theme tags - private static final List THEME_TAGS = List.of("Optimization", "AI", "Cloud"); - // Sector tags - private static final List SECTOR_TAGS = List.of("Green", "Blue", "Orange"); - // Audience tags - private static final List AUDIENCE_TAGS = List.of("Programmers", "Analysts", "Managers"); - // Content tags - private static final List CONTENT_TAGS = List.of("Timefold", "Constraints", "Metaheuristics", "Kubernetes"); - - private static final Set TALK_TYPES = buildSet(List.of( - new TalkType(LAB_TALK_TAG), - new TalkType(BREAKOUT_TALK_TAG))); - - @SafeVarargs - private static SequencedSet sequencedSet(T... values) { - return new LinkedHashSet<>(Set.of(values)); - } - - public ConferenceSchedule generateDemoData() { - Random random = new Random(0); - Set speakers = generateSpeakers(); - ConferenceSchedule schedule = new ConferenceSchedule("Conference", TALK_TYPES, generateTimeslots(), generateRooms(), - speakers, generateTalks(speakers, random)); - schedule.setConstraintProperties(new ConferenceConstraintProperties()); - return schedule; - } - - private Set generateTimeslots() { - return buildSet(List.of( - new Timeslot("T1", LocalDateTime.now().withHour(10).withMinute(15).withSecond(0).withNano(0), - LocalDateTime.now().withHour(12).withMinute(15).withSecond(0).withNano(0), - sequencedSet(getTalkType(LAB_TALK_TAG)), - emptySet()), - new Timeslot("T2", LocalDateTime.now().withHour(10).withMinute(15).withSecond(0).withNano(0), - LocalDateTime.now().withHour(11).withMinute(0).withSecond(0).withNano(0), - sequencedSet(getTalkType(BREAKOUT_TALK_TAG)), - emptySet()), - new Timeslot("T3", LocalDateTime.now().withHour(11).withMinute(30).withSecond(0).withNano(0), - LocalDateTime.now().withHour(12).withMinute(15).withSecond(0).withNano(0), - sequencedSet(getTalkType(BREAKOUT_TALK_TAG)), - emptySet()), - new Timeslot("T4", LocalDateTime.now().withHour(13).withMinute(0).withSecond(0).withNano(0), - LocalDateTime.now().withHour(15).withMinute(0).withSecond(0).withNano(0), - sequencedSet(getTalkType(LAB_TALK_TAG)), - sequencedSet(AFTER_LUNCH_TAG)), - new Timeslot("T5", LocalDateTime.now().withHour(15).withMinute(30).withSecond(0).withNano(0), - LocalDateTime.now().withHour(16).withMinute(15).withSecond(0).withNano(0), - sequencedSet(getTalkType(BREAKOUT_TALK_TAG)), - emptySet()), - new Timeslot("T6", LocalDateTime.now().withHour(16).withMinute(30).withSecond(0).withNano(0), - LocalDateTime.now().withHour(17).withMinute(15).withSecond(0).withNano(0), - sequencedSet(getTalkType(BREAKOUT_TALK_TAG)), - emptySet()))); - } - - private Set generateRooms() { - return buildSet(List.of( - new Room("R1", "Room A", 60, sequencedSet(getTalkType(BREAKOUT_TALK_TAG)), sequencedSet(RECORDED_TAG)), - new Room("R2", "Room B", 240, sequencedSet(getTalkType(BREAKOUT_TALK_TAG)), emptySet()), - new Room("R3", "Room C", 630, sequencedSet(getTalkType(BREAKOUT_TALK_TAG)), sequencedSet(RECORDED_TAG, LARGE_TAG)), - new Room("R4", "Room D", 70, sequencedSet(getTalkType(BREAKOUT_TALK_TAG)), sequencedSet(RECORDED_TAG)), - new Room("R5", "Room E (LAB)", 490, sequencedSet(getTalkType(LAB_TALK_TAG)), sequencedSet(RECORDED_TAG)))); - } - - private Set generateSpeakers() { - return buildSet(List.of( - new Speaker("1", "Amy Cole"), - new Speaker("2", "Beth Fox"), - new Speaker("3", "Carl Green"), - new Speaker("4", "Dan Jones"), - new Speaker("5", "Elsa King"), - new Speaker("6", "Flo Li"), - new Speaker("7", "Gus Poe"), - new Speaker("8", "Hugo Rye"), - new Speaker("9", "Ivy Smith"), - new Speaker("10", "Jay Watt"), - new Speaker("11", "Amy Fox"), - new Speaker("12", "Beth Green", sequencedSet(AFTER_LUNCH_TAG)))); - } - - private Set generateTalks(Set speakers, Random random) { - Set talks = new LinkedHashSet<>(); - talks.add(new Talk("S01", "Talk One", getTalkType(LAB_TALK_TAG), - getSpeakers(speakers, "Amy Cole", "Beth Fox"), sequencedSet(getRandomTheme(random)), sequencedSet(getRandomSector(random)), - sequencedSet(getRandomAudience(random)), 2, sequencedSet(getRandomContent(random)), "en", 551, 1)); - talks.add(new Talk("S02", "Talk Two", getTalkType(LAB_TALK_TAG), - getSpeakers(speakers, "Carl Green"), sequencedSet(getRandomTheme(random)), sequencedSet(getRandomSector(random)), - sequencedSet(getRandomAudience(random)), 3, sequencedSet(getRandomContent(random)), "en", 528, 0)); - talks.stream().filter(t -> t.getCode().equals("S01")).findFirst() - .ifPresent(t -> t.setUndesiredRoomTags(sequencedSet(RECORDED_TAG))); - talks.add(new Talk("S03", "Talk Three", getTalkType(BREAKOUT_TALK_TAG), - getSpeakers(speakers, "Dan Jones"), sequencedSet(getRandomTheme(random)), sequencedSet(getRandomSector(random)), - sequencedSet(getRandomAudience(random)), 3, sequencedSet(getRandomContent(random)), "en", 497, 0)); - talks.add(new Talk("S04", "Talk Four", getTalkType(BREAKOUT_TALK_TAG), - getSpeakers(speakers, "Elsa King", "Flo Li"), sequencedSet(getRandomTheme(random)), sequencedSet(getRandomSector(random)), - sequencedSet(getRandomAudience(random)), 1, sequencedSet(getRandomContent(random)), "en", 560, 0)); - talks.add(new Talk("S05", "Talk Five", getTalkType(BREAKOUT_TALK_TAG), - getSpeakers(speakers, "Gus Poe", "Hugo Rye"), sequencedSet(getRandomTheme(random)), sequencedSet(getRandomSector(random)), - sequencedSet(getRandomAudience(random)), 1, sequencedSet(getRandomContent(random)), "en", 957, 0)); - talks.add(new Talk("S06", "Talk Six", getTalkType(BREAKOUT_TALK_TAG), - getSpeakers(speakers, "Ivy Smith"), sequencedSet(getRandomTheme(random)), sequencedSet(getRandomSector(random)), - sequencedSet(getRandomAudience(random)), 1, sequencedSet(getRandomContent(random)), "en", 957, 0)); - talks.stream().filter(t -> t.getCode().equals("S05")).findFirst() - .ifPresent( - t -> t.setPrerequisiteTalks(talks.stream().filter(t2 -> t2.getCode().equals("S02")).collect(Collectors.toCollection(LinkedHashSet::new)))); - talks.add(new Talk("S07", "Talk Seven", getTalkType(BREAKOUT_TALK_TAG), - getSpeakers(speakers, "Jay Watt"), sequencedSet(getRandomTheme(random)), sequencedSet(getRandomSector(random)), - sequencedSet(getRandomAudience(random)), 3, sequencedSet(getRandomContent(random)), "en", 568, 0)); - talks.add(new Talk("S08", "Talk Eight", getTalkType(BREAKOUT_TALK_TAG), - getSpeakers(speakers, "Amy Fox"), sequencedSet(getRandomTheme(random)), sequencedSet(getRandomSector(random)), - sequencedSet(getRandomAudience(random)), 3, sequencedSet(getRandomContent(random)), "en", 183, 0)); - talks.add(new Talk("S09", "Talk Nine", getTalkType(BREAKOUT_TALK_TAG), - getSpeakers(speakers, "Beth Green", "Amy Cole"), sequencedSet(getRandomTheme(random)), - sequencedSet(getRandomSector(random)), - sequencedSet(getRandomAudience(random)), 3, sequencedSet(getRandomContent(random)), "en", 619, 0)); - talks.add(new Talk("S10", "Talk Ten", getTalkType(BREAKOUT_TALK_TAG), - getSpeakers(speakers, "Beth Fox", "Carl Green"), sequencedSet(getRandomTheme(random)), - sequencedSet(getRandomSector(random)), - sequencedSet(getRandomAudience(random)), 3, sequencedSet(getRandomContent(random)), "en", 603, 1)); - talks.add(new Talk("S11", "Talk Eleven", getTalkType(BREAKOUT_TALK_TAG), - getSpeakers(speakers, "Dan Jones", "Elsa King"), sequencedSet(getRandomTheme(random)), - sequencedSet(getRandomSector(random)), - sequencedSet(getRandomAudience(random)), 1, sequencedSet(getRandomContent(random)), "en", 39, 0)); - talks.add(new Talk("S12", "Talk Twelve", getTalkType(BREAKOUT_TALK_TAG), - getSpeakers(speakers, "Flo Li", "Gus Poe"), sequencedSet(getRandomTheme(random)), sequencedSet(getRandomSector(random)), - sequencedSet(getRandomAudience(random)), 3, sequencedSet(getRandomContent(random)), "en", 977, 0)); - talks.stream().filter(t -> t.getCode().equals("S11")).findFirst() - .ifPresent(t -> t.setMutuallyExclusiveTalksTags(sequencedSet(getRandomContent(random)))); - talks.add(new Talk("S13", "Talk Thirteen", getTalkType(BREAKOUT_TALK_TAG), - getSpeakers(speakers, "Hugo Rye"), sequencedSet(getRandomTheme(random)), sequencedSet(getRandomSector(random)), - sequencedSet(getRandomAudience(random)), 3, sequencedSet(getRandomContent(random)), "en", 494, 0)); - talks.add(new Talk("S14", "Talk Fourteen", getTalkType(BREAKOUT_TALK_TAG), - getSpeakers(speakers, "Ivy Smith"), sequencedSet(getRandomTheme(random)), sequencedSet(getRandomSector(random)), - sequencedSet(getRandomAudience(random)), 3, sequencedSet(getRandomContent(random)), "en", 500, 0)); - talks.add(new Talk("S15", "Talk Fifteen", getTalkType(BREAKOUT_TALK_TAG), - getSpeakers(speakers, "Jay Watt"), sequencedSet(getRandomTheme(random)), sequencedSet(getRandomSector(random)), - sequencedSet(getRandomAudience(random)), 2, sequencedSet(getRandomContent(random)), "en", 658, 0)); - talks.stream().filter(t -> t.getCode().equals("S11")).findFirst() - .ifPresent(t -> t.setRequiredRoomTags(sequencedSet(RECORDED_TAG))); - - return talks; - } - - private TalkType getTalkType(String name) { - return TALK_TYPES.stream().filter(t -> t.getName().equals(name)).findFirst() - .orElseThrow(() -> new IllegalArgumentException("Tag type %s not found.".formatted(name))); - } - - private List getSpeakers(Set speakers, String... names) { - return Arrays.stream(names) - .map(n -> speakers.stream().filter(s -> s.getName().equals(n)).findFirst()) - .filter(Optional::isPresent) - .map(Optional::get) - .toList(); - } - - private String getRandomTheme(Random random) { - return THEME_TAGS.get(random.nextInt(THEME_TAGS.size())); - } - - private String getRandomAudience(Random random) { - return AUDIENCE_TAGS.get(random.nextInt(AUDIENCE_TAGS.size())); - } - - private String getRandomContent(Random random) { - return CONTENT_TAGS.get(random.nextInt(CONTENT_TAGS.size())); - } - - private String getRandomSector(Random random) { - return SECTOR_TAGS.get(random.nextInt(SECTOR_TAGS.size())); - } - - private static Set buildSet(List values) { - var newSet = new LinkedHashSet<>(values.size()); - newSet.addAll(values); - return (Set) Collections.unmodifiableSet(newSet); - } -} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/exception/ConferenceScheduleSolverException.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/exception/ConferenceScheduleSolverException.java deleted file mode 100644 index 793c402582..0000000000 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/exception/ConferenceScheduleSolverException.java +++ /dev/null @@ -1,30 +0,0 @@ -package org.acme.conferencescheduling.rest.exception; - -import jakarta.ws.rs.core.Response; - -public class ConferenceScheduleSolverException extends RuntimeException { - - private final String jobId; - - private final Response.Status status; - - public ConferenceScheduleSolverException(String jobId, Response.Status status, String message) { - super(message); - this.jobId = jobId; - this.status = status; - } - - public ConferenceScheduleSolverException(String jobId, Throwable cause) { - super(cause.getMessage(), cause); - this.jobId = jobId; - this.status = Response.Status.INTERNAL_SERVER_ERROR; - } - - public String getJobId() { - return jobId; - } - - public Response.Status getStatus() { - return status; - } -} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/exception/ConferenceScheduleSolverExceptionMapper.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/exception/ConferenceScheduleSolverExceptionMapper.java deleted file mode 100644 index 819d4d30ff..0000000000 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/exception/ConferenceScheduleSolverExceptionMapper.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.acme.conferencescheduling.rest.exception; - -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.ext.ExceptionMapper; -import jakarta.ws.rs.ext.Provider; - -@Provider -public class ConferenceScheduleSolverExceptionMapper implements ExceptionMapper { - - @Override - public Response toResponse(ConferenceScheduleSolverException exception) { - return Response - .status(exception.getStatus()) - .type(MediaType.APPLICATION_JSON) - .entity(new ErrorInfo(exception.getJobId(), exception.getMessage())) - .build(); - } -} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/exception/ErrorInfo.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/exception/ErrorInfo.java deleted file mode 100644 index 10e1a73c5a..0000000000 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/rest/exception/ErrorInfo.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.acme.conferencescheduling.rest.exception; - -public record ErrorInfo(String jobId, String message) { -} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/service/ConferenceScheduleIssues.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/service/ConferenceScheduleIssues.java new file mode 100644 index 0000000000..3d1ff6f26f --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/service/ConferenceScheduleIssues.java @@ -0,0 +1,161 @@ +package org.acme.conferencescheduling.service; + +import java.util.List; +import java.util.stream.Stream; + +import ai.timefold.solver.service.definition.api.validation.AbstractIssue; +import ai.timefold.solver.service.definition.api.validation.IssueCode; +import ai.timefold.solver.service.definition.api.validation.IssueMetadata; +import ai.timefold.solver.service.definition.api.validation.IssueSeverity; +import ai.timefold.solver.service.definition.api.validation.IssueType; + +import org.acme.conferencescheduling.dto.ConferenceScheduleValidationIssue; +import org.acme.conferencescheduling.dto.RoomIdDetail; +import org.acme.conferencescheduling.dto.SpeakerIdDetail; +import org.acme.conferencescheduling.dto.TalkIdDetail; +import org.acme.conferencescheduling.dto.TimeslotIdDetail; + +@SuppressWarnings("PMD.MissingStaticMethodInNonInstantiatableClass") +public final class ConferenceScheduleIssues { + + private ConferenceScheduleIssues() { + } + + public abstract static class ConferenceScheduleIssue extends AbstractIssue { + protected ConferenceScheduleIssue(IssueCode code, IssueSeverity severity, List metadata) { + super(code, severity, metadata); + } + } + + public static final class TalkIdMissingIssue extends ConferenceScheduleIssue { + private static final IssueType TYPE = ConferenceScheduleValidationIssue.TALK_ID_MISSING.asIssueType(); + + public TalkIdMissingIssue() { + super(TYPE.code(), TYPE.severity(), TYPE.metadata()); + } + } + + public static final class DuplicateTalkIdIssue extends ConferenceScheduleIssue { + private static final IssueType TYPE = ConferenceScheduleValidationIssue.DUPLICATE_TALK_ID.asIssueType(); + + public DuplicateTalkIdIssue() { + super(TYPE.code(), TYPE.severity(), TYPE.metadata()); + } + + DuplicateTalkIdIssue(TalkIdDetail detail) { + super(TYPE.code(), TYPE.severity(), Stream.concat(TYPE.metadata().stream(), Stream.of(detail)).toList()); + } + } + + public static final class SpeakerIdMissingIssue extends ConferenceScheduleIssue { + private static final IssueType TYPE = ConferenceScheduleValidationIssue.SPEAKER_ID_MISSING.asIssueType(); + + public SpeakerIdMissingIssue() { + super(TYPE.code(), TYPE.severity(), TYPE.metadata()); + } + } + + public static final class DuplicateSpeakerIdIssue extends ConferenceScheduleIssue { + private static final IssueType TYPE = ConferenceScheduleValidationIssue.DUPLICATE_SPEAKER_ID.asIssueType(); + + public DuplicateSpeakerIdIssue() { + super(TYPE.code(), TYPE.severity(), TYPE.metadata()); + } + + DuplicateSpeakerIdIssue(SpeakerIdDetail detail) { + super(TYPE.code(), TYPE.severity(), Stream.concat(TYPE.metadata().stream(), Stream.of(detail)).toList()); + } + } + + public static final class RoomIdMissingIssue extends ConferenceScheduleIssue { + private static final IssueType TYPE = ConferenceScheduleValidationIssue.ROOM_ID_MISSING.asIssueType(); + + public RoomIdMissingIssue() { + super(TYPE.code(), TYPE.severity(), TYPE.metadata()); + } + } + + public static final class DuplicateRoomIdIssue extends ConferenceScheduleIssue { + private static final IssueType TYPE = ConferenceScheduleValidationIssue.DUPLICATE_ROOM_ID.asIssueType(); + + public DuplicateRoomIdIssue() { + super(TYPE.code(), TYPE.severity(), TYPE.metadata()); + } + + DuplicateRoomIdIssue(RoomIdDetail detail) { + super(TYPE.code(), TYPE.severity(), Stream.concat(TYPE.metadata().stream(), Stream.of(detail)).toList()); + } + } + + public static final class TimeslotIdMissingIssue extends ConferenceScheduleIssue { + private static final IssueType TYPE = ConferenceScheduleValidationIssue.TIMESLOT_ID_MISSING.asIssueType(); + + public TimeslotIdMissingIssue() { + super(TYPE.code(), TYPE.severity(), TYPE.metadata()); + } + } + + public static final class DuplicateTimeslotIdIssue extends ConferenceScheduleIssue { + private static final IssueType TYPE = ConferenceScheduleValidationIssue.DUPLICATE_TIMESLOT_ID.asIssueType(); + + public DuplicateTimeslotIdIssue() { + super(TYPE.code(), TYPE.severity(), TYPE.metadata()); + } + + DuplicateTimeslotIdIssue(TimeslotIdDetail detail) { + super(TYPE.code(), TYPE.severity(), Stream.concat(TYPE.metadata().stream(), Stream.of(detail)).toList()); + } + } + + public static final class NonExistingTimeslotReferenceIssue extends ConferenceScheduleIssue { + private static final IssueType TYPE = + ConferenceScheduleValidationIssue.NON_EXISTING_TIMESLOT_REFERENCE.asIssueType(); + + public NonExistingTimeslotReferenceIssue() { + super(TYPE.code(), TYPE.severity(), TYPE.metadata()); + } + + NonExistingTimeslotReferenceIssue(TalkIdDetail detail) { + super(TYPE.code(), TYPE.severity(), Stream.concat(TYPE.metadata().stream(), Stream.of(detail)).toList()); + } + } + + public static final class NonExistingRoomReferenceIssue extends ConferenceScheduleIssue { + private static final IssueType TYPE = + ConferenceScheduleValidationIssue.NON_EXISTING_ROOM_REFERENCE.asIssueType(); + + public NonExistingRoomReferenceIssue() { + super(TYPE.code(), TYPE.severity(), TYPE.metadata()); + } + + NonExistingRoomReferenceIssue(TalkIdDetail detail) { + super(TYPE.code(), TYPE.severity(), Stream.concat(TYPE.metadata().stream(), Stream.of(detail)).toList()); + } + } + + public static final class NonExistingSpeakerReferenceIssue extends ConferenceScheduleIssue { + private static final IssueType TYPE = + ConferenceScheduleValidationIssue.NON_EXISTING_SPEAKER_REFERENCE.asIssueType(); + + public NonExistingSpeakerReferenceIssue() { + super(TYPE.code(), TYPE.severity(), TYPE.metadata()); + } + + NonExistingSpeakerReferenceIssue(TalkIdDetail detail) { + super(TYPE.code(), TYPE.severity(), Stream.concat(TYPE.metadata().stream(), Stream.of(detail)).toList()); + } + } + + public static final class NonExistingTalkTypeReferenceIssue extends ConferenceScheduleIssue { + private static final IssueType TYPE = + ConferenceScheduleValidationIssue.NON_EXISTING_TALK_TYPE_REFERENCE.asIssueType(); + + public NonExistingTalkTypeReferenceIssue() { + super(TYPE.code(), TYPE.severity(), TYPE.metadata()); + } + + NonExistingTalkTypeReferenceIssue(TalkIdDetail detail) { + super(TYPE.code(), TYPE.severity(), Stream.concat(TYPE.metadata().stream(), Stream.of(detail)).toList()); + } + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/service/ConferenceScheduleModelConvertor.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/service/ConferenceScheduleModelConvertor.java new file mode 100644 index 0000000000..e7070c3461 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/service/ConferenceScheduleModelConvertor.java @@ -0,0 +1,292 @@ +package org.acme.conferencescheduling.service; + +import static java.util.stream.Collectors.toCollection; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.SequencedSet; +import java.util.Set; +import java.util.stream.Collectors; + +import jakarta.enterprise.context.ApplicationScoped; + +import ai.timefold.solver.core.api.domain.solution.ConstraintWeightOverrides; +import ai.timefold.solver.core.api.score.HardMediumSoftScore; +import ai.timefold.solver.service.definition.api.ModelConvertor; +import ai.timefold.solver.service.definition.api.domain.ModelConfig; + +import org.acme.conferencescheduling.domain.ConferenceConstraintProperties; +import org.acme.conferencescheduling.domain.ConferenceSchedule; +import org.acme.conferencescheduling.domain.Room; +import org.acme.conferencescheduling.domain.Speaker; +import org.acme.conferencescheduling.domain.Talk; +import org.acme.conferencescheduling.domain.TalkType; +import org.acme.conferencescheduling.domain.Timeslot; +import org.acme.conferencescheduling.dto.ConferenceScheduleConfigOverrides; +import org.acme.conferencescheduling.dto.ConferenceScheduleInput; +import org.acme.conferencescheduling.dto.ConferenceScheduleOutput; +import org.acme.conferencescheduling.dto.RoomDTO; +import org.acme.conferencescheduling.dto.SpeakerDTO; +import org.acme.conferencescheduling.dto.TalkDTO; +import org.acme.conferencescheduling.dto.TalkTypeDTO; +import org.acme.conferencescheduling.dto.TimeslotDTO; + +@ApplicationScoped +public class ConferenceScheduleModelConvertor + implements + ModelConvertor { + + @Override + public ConferenceScheduleInput applyOutputToInput(ConferenceScheduleInput modelInput, + ConferenceScheduleOutput modelOutput) { + Map outputTalks = modelOutput.talks().stream() + .collect(Collectors.toMap(TalkDTO::code, talk -> talk)); + List updatedTalks = modelInput.talks().stream() + .map(talk -> { + TalkDTO solved = outputTalks.get(talk.code()); + if (solved == null) { + return talk; + } + return talk.withTimeslotId(solved.timeslotId()).withRoomId(solved.roomId()); + }) + .collect(Collectors.toList()); + return modelInput.withTalks(updatedTalks); + } + + @Override + public ConferenceSchedule toSolverModel(ConferenceScheduleInput modelInput, + ModelConfig modelConfig, + Optional lastModelOutput) { + Map talkTypeMap = modelInput.talkTypes().stream() + .collect(Collectors.toMap(TalkTypeDTO::name, dto -> new TalkType(dto.name()), (a, b) -> a, + java.util.LinkedHashMap::new)); + + Map timeslotMap = new java.util.LinkedHashMap<>(); + Set timeslots = modelInput.timeslots().stream() + .map(dto -> { + Timeslot timeslot = new Timeslot(dto.id(), LocalDateTime.parse(dto.startDateTime()), + LocalDateTime.parse(dto.endDateTime()), talkTypes(dto.talkTypeNames(), talkTypeMap), + new LinkedHashSet<>(dto.tags())); + timeslotMap.put(timeslot.getId(), timeslot); + return timeslot; + }) + .collect(toCollection(LinkedHashSet::new)); + + Map roomMap = new java.util.LinkedHashMap<>(); + Set rooms = modelInput.rooms().stream() + .map(dto -> { + Room room = new Room(dto.id(), dto.name(), dto.capacity(), + talkTypes(dto.talkTypeNames(), talkTypeMap), + timeslotsByIds(dto.unavailableTimeslotIds(), timeslotMap), + new LinkedHashSet<>(dto.tags())); + roomMap.put(room.getId(), room); + return room; + }) + .collect(toCollection(LinkedHashSet::new)); + + Map speakerMap = new java.util.LinkedHashMap<>(); + Set speakers = modelInput.speakers().stream() + .map(dto -> { + Speaker speaker = toSpeaker(dto, timeslotMap); + speakerMap.put(speaker.getId(), speaker); + return speaker; + }) + .collect(toCollection(LinkedHashSet::new)); + + Map talkMap = new java.util.LinkedHashMap<>(); + Set talks = modelInput.talks().stream() + .map(dto -> { + Talk talk = toTalk(dto, talkTypeMap, speakerMap, timeslotMap, roomMap); + talkMap.put(talk.getCode(), talk); + return talk; + }) + .collect(toCollection(LinkedHashSet::new)); + applyPrerequisites(modelInput.talks(), talkMap); + + ConferenceSchedule schedule = new ConferenceSchedule(modelInput.name(), + new LinkedHashSet<>(talkTypeMap.values()), timeslots, rooms, speakers, talks); + schedule.setConstraintProperties(new ConferenceConstraintProperties()); + applyConstraintWeightOverrides(schedule, modelConfig); + applyLastOutput(talkMap, timeslotMap, roomMap, lastModelOutput); + return schedule; + } + + private static Set talkTypes(List names, Map talkTypeMap) { + return names.stream().map(talkTypeMap::get).collect(toCollection(LinkedHashSet::new)); + } + + private static SequencedSet timeslotsByIds(List ids, Map timeslotMap) { + return ids.stream().map(timeslotMap::get).collect(toCollection(LinkedHashSet::new)); + } + + private static Speaker toSpeaker(SpeakerDTO dto, Map timeslotMap) { + return new Speaker(dto.id(), dto.name(), timeslotsByIds(dto.unavailableTimeslotIds(), timeslotMap), + new LinkedHashSet<>(dto.requiredTimeslotTags()), new LinkedHashSet<>(dto.preferredTimeslotTags()), + new LinkedHashSet<>(dto.prohibitedTimeslotTags()), new LinkedHashSet<>(dto.undesiredTimeslotTags()), + new LinkedHashSet<>(dto.requiredRoomTags()), new LinkedHashSet<>(dto.preferredRoomTags()), + new LinkedHashSet<>(dto.prohibitedRoomTags()), new LinkedHashSet<>(dto.undesiredRoomTags())); + } + + private static Talk toTalk(TalkDTO dto, Map talkTypeMap, Map speakerMap, + Map timeslotMap, Map roomMap) { + List speakers = dto.speakerIds().stream().map(speakerMap::get).collect(Collectors.toList()); + Talk talk = new Talk(dto.code(), dto.title(), talkTypeMap.get(dto.talkTypeName()), speakers, + new LinkedHashSet<>(dto.themeTrackTags()), new LinkedHashSet<>(dto.sectorTags()), + new LinkedHashSet<>(dto.audienceTypes()), dto.audienceLevel(), new LinkedHashSet<>(dto.contentTags()), + dto.language(), new LinkedHashSet<>(dto.requiredTimeslotTags()), + new LinkedHashSet<>(dto.preferredTimeslotTags()), new LinkedHashSet<>(dto.prohibitedTimeslotTags()), + new LinkedHashSet<>(dto.undesiredTimeslotTags()), new LinkedHashSet<>(dto.requiredRoomTags()), + new LinkedHashSet<>(dto.preferredRoomTags()), new LinkedHashSet<>(dto.prohibitedRoomTags()), + new LinkedHashSet<>(dto.undesiredRoomTags()), new LinkedHashSet<>(dto.mutuallyExclusiveTalksTags()), + new LinkedHashSet<>(), dto.favoriteCount(), dto.crowdControlRisk()); + if (dto.timeslotId() != null) { + talk.setTimeslot(timeslotMap.get(dto.timeslotId())); + } + if (dto.roomId() != null) { + talk.setRoom(roomMap.get(dto.roomId())); + } + return talk; + } + + private static void applyPrerequisites(List talkDtos, Map talkMap) { + for (TalkDTO dto : talkDtos) { + if (dto.prerequisiteTalkCodes().isEmpty()) { + continue; + } + SequencedSet prerequisites = dto.prerequisiteTalkCodes().stream() + .map(talkMap::get) + .filter(java.util.Objects::nonNull) + .collect(toCollection(LinkedHashSet::new)); + talkMap.get(dto.code()).setPrerequisiteTalks(prerequisites); + } + } + + private static void applyConstraintWeightOverrides(ConferenceSchedule schedule, + ModelConfig modelConfig) { + if (modelConfig == null || modelConfig.overrides() == null) { + return; + } + ConferenceScheduleConfigOverrides overrides = modelConfig.overrides(); + // Only apply weights that are actually set (non-null) in the merged overrides. A null weight means the + // input did not override it, so the configuration profile value (or the constraint's default) is kept. + Map weights = new HashMap<>(); + putIfPresent(weights, ConferenceConstraintProperties.THEME_TRACK_CONFLICT, overrides.themeTrackConflictWeight()); + putIfPresent(weights, ConferenceConstraintProperties.THEME_TRACK_ROOM_STABILITY, + overrides.themeTrackRoomStabilityWeight()); + putIfPresent(weights, ConferenceConstraintProperties.SECTOR_CONFLICT, overrides.sectorConflictWeight()); + putIfPresent(weights, ConferenceConstraintProperties.AUDIENCE_TYPE_DIVERSITY, + overrides.audienceTypeDiversityWeight()); + putIfPresent(weights, ConferenceConstraintProperties.AUDIENCE_TYPE_THEME_TRACK_CONFLICT, + overrides.audienceTypeThemeTrackConflictWeight()); + putIfPresent(weights, ConferenceConstraintProperties.AUDIENCE_LEVEL_DIVERSITY, + overrides.audienceLevelDiversityWeight()); + putIfPresent(weights, ConferenceConstraintProperties.CONTENT_AUDIENCE_LEVEL_FLOW_VIOLATION, + overrides.contentAudienceLevelFlowViolationWeight()); + putIfPresent(weights, ConferenceConstraintProperties.CONTENT_CONFLICT, overrides.contentConflictWeight()); + putIfPresent(weights, ConferenceConstraintProperties.LANGUAGE_DIVERSITY, overrides.languageDiversityWeight()); + putIfPresent(weights, ConferenceConstraintProperties.SAME_DAY_TALKS, overrides.sameDayTalksWeight()); + putIfPresent(weights, ConferenceConstraintProperties.POPULAR_TALKS, overrides.popularTalksWeight()); + putIfPresent(weights, ConferenceConstraintProperties.SPEAKER_PREFERRED_TIMESLOT_TAGS, + overrides.speakerPreferredTimeslotTagsWeight()); + putIfPresent(weights, ConferenceConstraintProperties.SPEAKER_UNDESIRED_TIMESLOT_TAGS, + overrides.speakerUndesiredTimeslotTagsWeight()); + putIfPresent(weights, ConferenceConstraintProperties.TALK_PREFERRED_TIMESLOT_TAGS, + overrides.talkPreferredTimeslotTagsWeight()); + putIfPresent(weights, ConferenceConstraintProperties.TALK_UNDESIRED_TIMESLOT_TAGS, + overrides.talkUndesiredTimeslotTagsWeight()); + putIfPresent(weights, ConferenceConstraintProperties.SPEAKER_PREFERRED_ROOM_TAGS, + overrides.speakerPreferredRoomTagsWeight()); + putIfPresent(weights, ConferenceConstraintProperties.SPEAKER_UNDESIRED_ROOM_TAGS, + overrides.speakerUndesiredRoomTagsWeight()); + putIfPresent(weights, ConferenceConstraintProperties.TALK_PREFERRED_ROOM_TAGS, + overrides.talkPreferredRoomTagsWeight()); + putIfPresent(weights, ConferenceConstraintProperties.TALK_UNDESIRED_ROOM_TAGS, + overrides.talkUndesiredRoomTagsWeight()); + putIfPresent(weights, ConferenceConstraintProperties.SPEAKER_MAKESPAN, overrides.speakerMakespanWeight()); + if (!weights.isEmpty()) { + schedule.setConstraintWeightOverrides(ConstraintWeightOverrides.of(weights)); + } + } + + private static void putIfPresent(Map weights, String constraintName, Long weight) { + if (weight != null) { + weights.put(constraintName, HardMediumSoftScore.ofSoft(weight)); + } + } + + private static void applyLastOutput(Map talkMap, Map timeslotMap, + Map roomMap, Optional lastModelOutput) { + if (lastModelOutput.isEmpty()) { + return; + } + for (TalkDTO solved : lastModelOutput.get().talks()) { + Talk talk = talkMap.get(solved.code()); + if (talk == null) { + continue; + } + if (solved.timeslotId() != null) { + talk.setTimeslot(timeslotMap.get(solved.timeslotId())); + } + if (solved.roomId() != null) { + talk.setRoom(roomMap.get(solved.roomId())); + } + } + } + + @Override + public ConferenceScheduleOutput toModelOutput(ConferenceSchedule solverModel) { + List talkTypes = solverModel.getTalkTypes().stream() + .map(talkType -> new TalkTypeDTO(talkType.getName())).collect(Collectors.toList()); + List timeslots = solverModel.getTimeslots().stream().map(this::toDTO).collect(Collectors.toList()); + List rooms = solverModel.getRooms().stream().map(this::toDTO).collect(Collectors.toList()); + List speakers = solverModel.getSpeakers().stream().map(this::toDTO).collect(Collectors.toList()); + List talks = solverModel.getTalks().stream().map(this::toDTO).collect(Collectors.toList()); + String score = solverModel.getScore() == null ? "" : solverModel.getScore().toString(); + return new ConferenceScheduleOutput(solverModel.getName(), talkTypes, timeslots, rooms, speakers, talks, score); + } + + private TimeslotDTO toDTO(Timeslot timeslot) { + List talkTypeNames = timeslot.getTalkTypes().stream().map(TalkType::getName).collect(Collectors.toList()); + return new TimeslotDTO(timeslot.getId(), timeslot.getStartDateTime().toString(), + timeslot.getEndDateTime().toString(), talkTypeNames, List.copyOf(timeslot.getTags())); + } + + private RoomDTO toDTO(Room room) { + List talkTypeNames = room.getTalkTypes().stream().map(TalkType::getName).collect(Collectors.toList()); + List unavailableTimeslotIds = + room.getUnavailableTimeslots().stream().map(Timeslot::getId).collect(Collectors.toList()); + return new RoomDTO(room.getId(), room.getName(), room.getCapacity(), talkTypeNames, unavailableTimeslotIds, + List.copyOf(room.getTags())); + } + + private SpeakerDTO toDTO(Speaker speaker) { + List unavailableTimeslotIds = + speaker.getUnavailableTimeslots().stream().map(Timeslot::getId).collect(Collectors.toList()); + return new SpeakerDTO(speaker.getId(), speaker.getName(), unavailableTimeslotIds, + List.copyOf(speaker.getRequiredTimeslotTags()), List.copyOf(speaker.getPreferredTimeslotTags()), + List.copyOf(speaker.getProhibitedTimeslotTags()), List.copyOf(speaker.getUndesiredTimeslotTags()), + List.copyOf(speaker.getRequiredRoomTags()), List.copyOf(speaker.getPreferredRoomTags()), + List.copyOf(speaker.getProhibitedRoomTags()), List.copyOf(speaker.getUndesiredRoomTags())); + } + + private TalkDTO toDTO(Talk talk) { + List speakerIds = talk.getSpeakers().stream().map(Speaker::getId).collect(Collectors.toList()); + List prerequisiteCodes = + talk.getPrerequisiteTalks().stream().map(Talk::getCode).collect(Collectors.toList()); + String timeslotId = talk.getTimeslot() == null ? null : talk.getTimeslot().getId(); + String roomId = talk.getRoom() == null ? null : talk.getRoom().getId(); + return new TalkDTO(talk.getCode(), talk.getTitle(), talk.getTalkType().getName(), speakerIds, + List.copyOf(talk.getThemeTrackTags()), List.copyOf(talk.getSectorTags()), + List.copyOf(talk.getAudienceTypes()), talk.getAudienceLevel(), List.copyOf(talk.getContentTags()), + talk.getLanguage(), List.copyOf(talk.getRequiredTimeslotTags()), + List.copyOf(talk.getPreferredTimeslotTags()), List.copyOf(talk.getProhibitedTimeslotTags()), + List.copyOf(talk.getUndesiredTimeslotTags()), List.copyOf(talk.getRequiredRoomTags()), + List.copyOf(talk.getPreferredRoomTags()), List.copyOf(talk.getProhibitedRoomTags()), + List.copyOf(talk.getUndesiredRoomTags()), List.copyOf(talk.getMutuallyExclusiveTalksTags()), + prerequisiteCodes, talk.getFavoriteCount(), talk.getCrowdControlRisk(), timeslotId, roomId); + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/service/ConferenceScheduleValidator.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/service/ConferenceScheduleValidator.java new file mode 100644 index 0000000000..1345b0cf91 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/service/ConferenceScheduleValidator.java @@ -0,0 +1,121 @@ +package org.acme.conferencescheduling.service; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import jakarta.enterprise.context.ApplicationScoped; + +import ai.timefold.solver.service.definition.api.domain.ModelConfig; +import ai.timefold.solver.service.definition.api.validation.ModelValidator; +import ai.timefold.solver.service.definition.api.validation.ValidationBuilder; + +import org.acme.conferencescheduling.dto.ConferenceScheduleConfigOverrides; +import org.acme.conferencescheduling.dto.ConferenceScheduleInput; +import org.acme.conferencescheduling.dto.RoomDTO; +import org.acme.conferencescheduling.dto.RoomIdDetail; +import org.acme.conferencescheduling.dto.SpeakerDTO; +import org.acme.conferencescheduling.dto.SpeakerIdDetail; +import org.acme.conferencescheduling.dto.TalkDTO; +import org.acme.conferencescheduling.dto.TalkIdDetail; +import org.acme.conferencescheduling.dto.TalkTypeDTO; +import org.acme.conferencescheduling.dto.TimeslotDTO; +import org.acme.conferencescheduling.dto.TimeslotIdDetail; +import org.acme.conferencescheduling.service.ConferenceScheduleIssues.DuplicateRoomIdIssue; +import org.acme.conferencescheduling.service.ConferenceScheduleIssues.DuplicateSpeakerIdIssue; +import org.acme.conferencescheduling.service.ConferenceScheduleIssues.DuplicateTalkIdIssue; +import org.acme.conferencescheduling.service.ConferenceScheduleIssues.DuplicateTimeslotIdIssue; +import org.acme.conferencescheduling.service.ConferenceScheduleIssues.NonExistingRoomReferenceIssue; +import org.acme.conferencescheduling.service.ConferenceScheduleIssues.NonExistingSpeakerReferenceIssue; +import org.acme.conferencescheduling.service.ConferenceScheduleIssues.NonExistingTalkTypeReferenceIssue; +import org.acme.conferencescheduling.service.ConferenceScheduleIssues.NonExistingTimeslotReferenceIssue; +import org.acme.conferencescheduling.service.ConferenceScheduleIssues.RoomIdMissingIssue; +import org.acme.conferencescheduling.service.ConferenceScheduleIssues.SpeakerIdMissingIssue; +import org.acme.conferencescheduling.service.ConferenceScheduleIssues.TalkIdMissingIssue; +import org.acme.conferencescheduling.service.ConferenceScheduleIssues.TimeslotIdMissingIssue; + +@ApplicationScoped +@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") +public class ConferenceScheduleValidator + implements ModelValidator { + + @Override + public void validate(ValidationBuilder validationBuilder, ConferenceScheduleInput modelInput, + ModelConfig modelConfig) { + Set talkTypeNames = validateTalkTypes(modelInput.talkTypes()); + Set timeslotIds = validateTimeslots(validationBuilder, modelInput.timeslots()); + Set roomIds = validateRooms(validationBuilder, modelInput.rooms()); + Set speakerIds = validateSpeakers(validationBuilder, modelInput.speakers()); + validateTalks(validationBuilder, modelInput.talks(), timeslotIds, roomIds, speakerIds, talkTypeNames); + } + + private static Set validateTalkTypes(List talkTypes) { + Set names = new HashSet<>(); + for (TalkTypeDTO talkType : talkTypes) { + names.add(talkType.name()); + } + return names; + } + + private static Set validateTimeslots(ValidationBuilder validationBuilder, List timeslots) { + Set timeslotIds = new HashSet<>(); + for (TimeslotDTO timeslot : timeslots) { + if (timeslot.id() == null || timeslot.id().isBlank()) { + validationBuilder.addIssue(new TimeslotIdMissingIssue()); + } else if (!timeslotIds.add(timeslot.id())) { + validationBuilder.addIssue(new DuplicateTimeslotIdIssue(new TimeslotIdDetail(timeslot.id()))); + } + } + return timeslotIds; + } + + private static Set validateRooms(ValidationBuilder validationBuilder, List rooms) { + Set roomIds = new HashSet<>(); + for (RoomDTO room : rooms) { + if (room.id() == null || room.id().isBlank()) { + validationBuilder.addIssue(new RoomIdMissingIssue()); + } else if (!roomIds.add(room.id())) { + validationBuilder.addIssue(new DuplicateRoomIdIssue(new RoomIdDetail(room.id()))); + } + } + return roomIds; + } + + private static Set validateSpeakers(ValidationBuilder validationBuilder, List speakers) { + Set speakerIds = new HashSet<>(); + for (SpeakerDTO speaker : speakers) { + if (speaker.id() == null || speaker.id().isBlank()) { + validationBuilder.addIssue(new SpeakerIdMissingIssue()); + } else if (!speakerIds.add(speaker.id())) { + validationBuilder.addIssue(new DuplicateSpeakerIdIssue(new SpeakerIdDetail(speaker.id()))); + } + } + return speakerIds; + } + + private static void validateTalks(ValidationBuilder validationBuilder, List talks, Set timeslotIds, + Set roomIds, Set speakerIds, Set talkTypeNames) { + Set talkCodes = new HashSet<>(); + for (TalkDTO talk : talks) { + if (talk.code() == null || talk.code().isBlank()) { + validationBuilder.addIssue(new TalkIdMissingIssue()); + } else if (!talkCodes.add(talk.code())) { + validationBuilder.addIssue(new DuplicateTalkIdIssue(new TalkIdDetail(talk.code()))); + } + if (talk.timeslotId() != null && !timeslotIds.contains(talk.timeslotId())) { + validationBuilder.addIssue(new NonExistingTimeslotReferenceIssue(new TalkIdDetail(talk.code()))); + } + if (talk.roomId() != null && !roomIds.contains(talk.roomId())) { + validationBuilder.addIssue(new NonExistingRoomReferenceIssue(new TalkIdDetail(talk.code()))); + } + if (!talk.talkTypeName().isBlank() && !talkTypeNames.contains(talk.talkTypeName())) { + validationBuilder.addIssue(new NonExistingTalkTypeReferenceIssue(new TalkIdDetail(talk.code()))); + } + for (String speakerId : talk.speakerIds()) { + if (!speakerIds.contains(speakerId)) { + validationBuilder.addIssue(new NonExistingSpeakerReferenceIssue(new TalkIdDetail(talk.code()))); + } + } + } + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/ConferenceScheduleConstraintGroup.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/ConferenceScheduleConstraintGroup.java new file mode 100644 index 0000000000..dc5c877fbf --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/ConferenceScheduleConstraintGroup.java @@ -0,0 +1,33 @@ +package org.acme.conferencescheduling.solver; + +import ai.timefold.solver.service.definition.api.description.ConstraintGroupInfo; + +public final class ConferenceScheduleConstraintGroup { + + public static final ConstraintGroupInfo CONFLICT_AVOIDANCE = new ConstraintGroupInfo("conflictAvoidance", + "Conflict avoidance", + "Keep rooms and speakers free of double bookings and respect talk ordering, pauses and crowd control.", + "IconDiamond", + new String[] { ConstraintGroupTag.CONFLICT_FREE_PLANNING.getTag() }); + + public static final ConstraintGroupInfo TAG_REQUIREMENTS = new ConstraintGroupInfo("tagRequirements", + "Tag requirements", + "Honour the hard timeslot and room tags required or prohibited by talks and speakers.", + "IconTag", + new String[] { ConstraintGroupTag.TAG_COMPLIANCE.getTag() }); + + public static final ConstraintGroupInfo PROGRAM_QUALITY = new ConstraintGroupInfo("programQuality", + "Program quality", + "Spread out related talks and diversify each timeslot across themes, sectors, content, audience and language.", + "IconStar", + new String[] { ConstraintGroupTag.PROGRAM_QUALITY.getTag() }); + + public static final ConstraintGroupInfo TAG_PREFERENCES = new ConstraintGroupInfo("tagPreferences", + "Tag preferences", + "Satisfy the soft timeslot and room tag preferences of talks and speakers and keep speaker schedules compact.", + "IconHeart", + new String[] { ConstraintGroupTag.ATTENDEE_AND_SPEAKER_SATISFACTION.getTag() }); + + private ConferenceScheduleConstraintGroup() { + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/ConferenceSchedulingConstraintProvider.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/ConferenceSchedulingConstraintProvider.java index 83cffba48f..f981c32617 100644 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/ConferenceSchedulingConstraintProvider.java +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/ConferenceSchedulingConstraintProvider.java @@ -2,75 +2,29 @@ import java.time.LocalDate; import java.time.temporal.ChronoUnit; -import java.util.Collections; import java.util.List; import java.util.Objects; -import ai.timefold.solver.core.api.score.HardSoftScore; +import ai.timefold.solver.core.api.score.HardMediumSoftScore; 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 java.util.stream.Collectors; +import ai.timefold.solver.core.api.score.stream.ConstraintCollectors; import ai.timefold.solver.core.api.score.stream.Joiners; +import ai.timefold.solver.service.definition.api.description.ConstraintInfo; import org.acme.conferencescheduling.domain.ConferenceConstraintProperties; import org.acme.conferencescheduling.domain.Speaker; import org.acme.conferencescheduling.domain.Talk; -import org.acme.conferencescheduling.solver.justifications.ConferenceSchedulingJustification; -import org.acme.conferencescheduling.solver.justifications.ConflictTalkJustification; -import org.acme.conferencescheduling.solver.justifications.DiversityTalkJustification; -import org.acme.conferencescheduling.solver.justifications.PreferredTagsJustification; -import org.acme.conferencescheduling.solver.justifications.ProhibitedTagsJustification; -import org.acme.conferencescheduling.solver.justifications.RequiredTagsJustification; -import org.acme.conferencescheduling.solver.justifications.UnavailableTimeslotJustification; -import org.acme.conferencescheduling.solver.justifications.UndesiredTagsJustification; - -import static ai.timefold.solver.core.api.score.stream.ConstraintCollectors.compose; -import static ai.timefold.solver.core.api.score.stream.ConstraintCollectors.countBi; -import static ai.timefold.solver.core.api.score.stream.ConstraintCollectors.max; -import static ai.timefold.solver.core.api.score.stream.ConstraintCollectors.min; -import static ai.timefold.solver.core.api.score.stream.Joiners.containedIn; -import static ai.timefold.solver.core.api.score.stream.Joiners.equal; -import static ai.timefold.solver.core.api.score.stream.Joiners.filtering; -import static ai.timefold.solver.core.api.score.stream.Joiners.greaterThan; -import static ai.timefold.solver.core.api.score.stream.Joiners.lessThan; -import static ai.timefold.solver.core.api.score.stream.Joiners.overlapping; -import static java.util.stream.Collectors.joining; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.AUDIENCE_LEVEL_DIVERSITY; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.AUDIENCE_TYPE_DIVERSITY; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.AUDIENCE_TYPE_THEME_TRACK_CONFLICT; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.CONSECUTIVE_TALKS_PAUSE; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.CONTENT_AUDIENCE_LEVEL_FLOW_VIOLATION; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.CONTENT_CONFLICT; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.CROWD_CONTROL; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.LANGUAGE_DIVERSITY; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.POPULAR_TALKS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.ROOM_CONFLICT; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.ROOM_UNAVAILABLE_TIMESLOT; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.SAME_DAY_TALKS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.SECTOR_CONFLICT; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.SPEAKER_CONFLICT; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.SPEAKER_MAKESPAN; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.SPEAKER_PREFERRED_ROOM_TAGS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.SPEAKER_PREFERRED_TIMESLOT_TAGS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.SPEAKER_PROHIBITED_ROOM_TAGS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.SPEAKER_PROHIBITED_TIMESLOT_TAGS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.SPEAKER_REQUIRED_ROOM_TAGS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.SPEAKER_REQUIRED_TIMESLOT_TAGS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.SPEAKER_UNAVAILABLE_TIMESLOT; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.SPEAKER_UNDESIRED_ROOM_TAGS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.SPEAKER_UNDESIRED_TIMESLOT_TAGS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.TALK_MUTUALLY_EXCLUSIVE_TALKS_TAGS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.TALK_PREFERRED_ROOM_TAGS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.TALK_PREFERRED_TIMESLOT_TAGS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.TALK_PREREQUISITE_TALKS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.TALK_PROHIBITED_ROOM_TAGS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.TALK_PROHIBITED_TIMESLOT_TAGS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.TALK_REQUIRED_ROOM_TAGS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.TALK_REQUIRED_TIMESLOT_TAGS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.TALK_UNDESIRED_ROOM_TAGS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.TALK_UNDESIRED_TIMESLOT_TAGS; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.THEME_TRACK_CONFLICT; -import static org.acme.conferencescheduling.domain.ConferenceConstraintProperties.THEME_TRACK_ROOM_STABILITY; +import org.acme.conferencescheduling.domain.justification.ConferenceSchedulingJustification; +import org.acme.conferencescheduling.domain.justification.ConflictTalkJustification; +import org.acme.conferencescheduling.domain.justification.DiversityTalkJustification; +import org.acme.conferencescheduling.domain.justification.PreferredTagsJustification; +import org.acme.conferencescheduling.domain.justification.ProhibitedTagsJustification; +import org.acme.conferencescheduling.domain.justification.RequiredTagsJustification; +import org.acme.conferencescheduling.domain.justification.UnavailableTimeslotJustification; +import org.acme.conferencescheduling.domain.justification.UndesiredTagsJustification; /** * Provides the constraints for the conference scheduling problem. @@ -80,7 +34,11 @@ * That is the case in filtering joiners. * In this case, it is better to reduce the size of the joins even at the expense of duplicating some calculations. * In other words, time saved by caching those calculations is far outweighed by the time spent in unrestricted joins. + *

+ * Every constraint carries a {@link ConstraintInfo} describing it and assigning it to a + * {@link ConferenceScheduleConstraintGroup}, so the Timefold Platform can present the constraints grouped and explained. */ +@SuppressWarnings({ "NarrowCalculation", "PMD.AvoidDuplicateLiterals" }) public class ConferenceSchedulingConstraintProvider implements ConstraintProvider { @Override @@ -132,103 +90,138 @@ public Constraint[] defineConstraints(ConstraintFactory factory) { // Hard constraints // ************************************************************************ + // A talk must not occupy a room during a timeslot in which that room is marked unavailable. Constraint roomUnavailableTimeslot(ConstraintFactory factory) { return factory.forEach(Talk.class) .filter(Talk::hasUnavailableRoom) - .penalize(HardSoftScore.ofHard(100_000), Talk::getDurationInMinutes) + .penalize(HardMediumSoftScore.ofHard(100_000), Talk::getDurationInMinutes) .justifyWith((talk, score) -> new UnavailableTimeslotJustification(talk)) - .asConstraint(ROOM_UNAVAILABLE_TIMESLOT); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.ROOM_UNAVAILABLE_TIMESLOT, + ConferenceConstraintProperties.ROOM_UNAVAILABLE_TIMESLOT, + "A talk must not be scheduled in a room during a timeslot when that room is unavailable.", + ConferenceScheduleConstraintGroup.CONFLICT_AVOIDANCE)); } + // Two talks must not share the same room while their timeslots overlap. Constraint roomConflict(ConstraintFactory factory) { return factory.forEachUniquePair(Talk.class, - equal(Talk::getRoom), - overlapping(t -> t.getTimeslot().getStartDateTime(), t -> t.getTimeslot().getEndDateTime())) - .penalize(HardSoftScore.ofHard(1_000), Talk::overlappingDurationInMinutes) + Joiners.equal(Talk::getRoom), + Joiners.overlapping(t -> t.getTimeslot().getStartDateTime(), t -> t.getTimeslot().getEndDateTime())) + .penalize(HardMediumSoftScore.ofHard(1_000), Talk::overlappingDurationInMinutes) .justifyWith((talk, talk2, score) -> new ConflictTalkJustification("room", talk, List.of(talk.getRoom().getId()), talk2, List.of(talk2.getRoom().getId()))) - .asConstraint(ROOM_CONFLICT); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.ROOM_CONFLICT, + ConferenceConstraintProperties.ROOM_CONFLICT, + "Two talks must not share the same room at overlapping times.", + ConferenceScheduleConstraintGroup.CONFLICT_AVOIDANCE)); } + // A talk must not be placed in a timeslot during which one of its speakers is unavailable. Constraint speakerUnavailableTimeslot(ConstraintFactory factory) { return factory.forEachIncludingUnassigned(Talk.class) .filter(talk -> talk.getTimeslot() != null) .join(Speaker.class, Joiners.containing(Talk::getSpeakers, speaker -> speaker), - containedIn(Talk::getTimeslot, Speaker::getUnavailableTimeslots)) - .penalize(HardSoftScore.ofHard(100), (talk, speaker) -> talk.getDurationInMinutes()) + Joiners.containedIn(Talk::getTimeslot, Speaker::getUnavailableTimeslots)) + .penalize(HardMediumSoftScore.ofHard(100), (talk, speaker) -> talk.getDurationInMinutes()) .justifyWith( (talk, speaker, score) -> new UnavailableTimeslotJustification(talk, speaker)) - .asConstraint(SPEAKER_UNAVAILABLE_TIMESLOT); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.SPEAKER_UNAVAILABLE_TIMESLOT, + ConferenceConstraintProperties.SPEAKER_UNAVAILABLE_TIMESLOT, + "A talk must not be scheduled in a timeslot when one of its speakers is unavailable.", + ConferenceScheduleConstraintGroup.CONFLICT_AVOIDANCE)); } + // A speaker must not be assigned to two talks whose timeslots overlap. Constraint speakerConflict(ConstraintFactory factory) { return factory.forEachUniquePair(Talk.class, - overlapping(t -> t.getTimeslot().getStartDateTime(), t -> t.getTimeslot().getEndDateTime())) + Joiners.overlapping(t -> t.getTimeslot().getStartDateTime(), t -> t.getTimeslot().getEndDateTime())) .join(Speaker.class, Joiners.containing((talk1, talk2) -> talk1.getSpeakers(), speaker -> speaker), Joiners.containing((talk1, talk2) -> talk2.getSpeakers(), speaker -> speaker)) - .penalize(HardSoftScore.ofHard(10), (talk1, talk2, speaker) -> talk2.overlappingDurationInMinutes(talk1)) + .penalize(HardMediumSoftScore.ofHard(10), (talk1, talk2, speaker) -> talk2.overlappingDurationInMinutes(talk1)) .justifyWith((talk, talk2, speaker, score) -> new ConflictTalkJustification(talk, talk2, speaker)) - .asConstraint(SPEAKER_CONFLICT); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.SPEAKER_CONFLICT, + ConferenceConstraintProperties.SPEAKER_CONFLICT, + "A speaker must not be assigned to two talks that overlap in time.", + ConferenceScheduleConstraintGroup.CONFLICT_AVOIDANCE)); } + // A talk must start only after all of its prerequisite talks have finished. Constraint talkPrerequisiteTalks(ConstraintFactory factory) { return factory.forEach(Talk.class) .join(Talk.class, - greaterThan(t -> t.getTimeslot().getEndDateTime(), t -> t.getTimeslot().getStartDateTime()), - containedIn(talk -> talk, Talk::getPrerequisiteTalks)) - .penalize(HardSoftScore.ofHard(10), Talk::combinedDurationInMinutes) + Joiners.greaterThan(t -> t.getTimeslot().getEndDateTime(), t -> t.getTimeslot().getStartDateTime()), + Joiners.containedIn(talk -> talk, Talk::getPrerequisiteTalks)) + .penalize(HardMediumSoftScore.ofHard(10), Talk::combinedDurationInMinutes) .justifyWith( (talk, talk2, score) -> new ConferenceSchedulingJustification( "Talk %s must be scheduled after talk %s.".formatted(talk2.getCode(), talk.getCode()))) - .asConstraint(TALK_PREREQUISITE_TALKS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.TALK_PREREQUISITE_TALKS, + ConferenceConstraintProperties.TALK_PREREQUISITE_TALKS, + "A talk must be scheduled after all of its prerequisite talks have finished.", + ConferenceScheduleConstraintGroup.CONFLICT_AVOIDANCE)); } + // Talks that share a mutually-exclusive tag must not overlap in time. Constraint talkMutuallyExclusiveTalksTags(ConstraintFactory factory) { return factory.forEachUniquePair(Talk.class, - overlapping(t -> t.getTimeslot().getStartDateTime(), t -> t.getTimeslot().getEndDateTime()), - Joiners.containingAnyOf(Talk::getMutuallyExclusiveTalksTags)) - .penalize(HardSoftScore.ofHard(1), (talk1, talk2) -> talk1.overlappingMutuallyExclusiveTalksTagCount(talk2) * - talk1.overlappingDurationInMinutes(talk2)) + Joiners.overlapping(t -> t.getTimeslot().getStartDateTime(), t -> t.getTimeslot().getEndDateTime()), + Joiners.containingAnyOf(Talk::getMutuallyExclusiveTalksTags)) + .penalize(HardMediumSoftScore.ofHard(1), + (talk1, talk2) -> talk1.overlappingMutuallyExclusiveTalksTagCount(talk2) * + talk1.overlappingDurationInMinutes(talk2)) .justifyWith((talk, talk2, score) -> new ConflictTalkJustification("mutually-exclusive-talks tags", talk, talk.getMutuallyExclusiveTalksTags(), talk2, talk2.getMutuallyExclusiveTalksTags())) - .asConstraint(TALK_MUTUALLY_EXCLUSIVE_TALKS_TAGS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.TALK_MUTUALLY_EXCLUSIVE_TALKS_TAGS, + ConferenceConstraintProperties.TALK_MUTUALLY_EXCLUSIVE_TALKS_TAGS, + "Talks sharing a mutually-exclusive tag must not be scheduled at overlapping times.", + ConferenceScheduleConstraintGroup.CONFLICT_AVOIDANCE)); } + // A speaker must get at least the configured minimum pause between two of their consecutive talks. Constraint consecutiveTalksPause(ConstraintFactory factory) { return factory.forEachUniquePair(Talk.class, Joiners.containingAnyOf(Talk::getSpeakers)) .ifExists(ConferenceConstraintProperties.class, - filtering((talk1, talk2, config) -> !talk1.getTimeslot().pauseExists(talk2.getTimeslot(), + Joiners.filtering((talk1, talk2, config) -> !talk1.getTimeslot().pauseExists(talk2.getTimeslot(), config.getMinimumConsecutiveTalksPauseInMinutes()))) - .penalize(HardSoftScore.ofHard(1), Talk::combinedDurationInMinutes) + .penalize(HardMediumSoftScore.ofHard(1), Talk::combinedDurationInMinutes) .justifyWith( (talk, talk2, score) -> new ConferenceSchedulingJustification( "Required minimum consecutive pauses between talks [%s, %s].".formatted(talk.getCode(), talk2.getCode()))) - .asConstraint(CONSECUTIVE_TALKS_PAUSE); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.CONSECUTIVE_TALKS_PAUSE, + ConferenceConstraintProperties.CONSECUTIVE_TALKS_PAUSE, + "A speaker must get the minimum pause between two of their consecutive talks.", + ConferenceScheduleConstraintGroup.CONFLICT_AVOIDANCE)); } + // A talk needing crowd control must be paired with exactly one other crowd-control talk in the same timeslot. Constraint crowdControl(ConstraintFactory factory) { return factory.forEach(Talk.class) .filter(talk -> talk.getCrowdControlRisk() > 0) .join(factory.forEach(Talk.class) .filter(talk -> talk.getCrowdControlRisk() > 0), - equal(Talk::getTimeslot)) + Joiners.equal(Talk::getTimeslot)) .filter((talk1, talk2) -> !Objects.equals(talk1, talk2)) - .groupBy((talk1, talk2) -> talk1, countBi()) + .groupBy((talk1, talk2) -> talk1, ConstraintCollectors.countBi()) .filter((talk, count) -> count != 1) - .penalize(HardSoftScore.ofHard(1), (talk, count) -> talk.getDurationInMinutes()) + .penalize(HardMediumSoftScore.ofHard(1), (talk, count) -> talk.getDurationInMinutes()) .justifyWith((talk, integer, score) -> new ConferenceSchedulingJustification( "Required crowd control for talk %s".formatted(talk.getCode()))) - .asConstraint(CROWD_CONTROL); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.CROWD_CONTROL, + ConferenceConstraintProperties.CROWD_CONTROL, + "A talk that needs crowd control must be paired with exactly one other such talk in the same timeslot.", + ConferenceScheduleConstraintGroup.CONFLICT_AVOIDANCE)); } + // The talk's timeslot must carry every timeslot tag required by its speakers. Constraint speakerRequiredTimeslotTags(ConstraintFactory factory) { return factory.forEach(Talk.class) .expand(Talk::missingSpeakerRequiredTimeslotTagCount) .filter((talk, missingTagCount) -> missingTagCount > 0) - .penalize(HardSoftScore.ofHard(1), (talk, missingTagCount) -> missingTagCount * talk.getDurationInMinutes()) + .penalize(HardMediumSoftScore.ofHard(1), + (talk, missingTagCount) -> missingTagCount * talk.getDurationInMinutes()) .justifyWith( (talk, integer, score) -> new RequiredTagsJustification("timeslot", talk.getSpeakers(), talk.getSpeakers().stream() @@ -236,14 +229,18 @@ Constraint speakerRequiredTimeslotTags(ConstraintFactory factory) { .distinct() .toList(), talk.getTimeslot().getTags())) - .asConstraint(SPEAKER_REQUIRED_TIMESLOT_TAGS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.SPEAKER_REQUIRED_TIMESLOT_TAGS, + ConferenceConstraintProperties.SPEAKER_REQUIRED_TIMESLOT_TAGS, + "The talk's timeslot must carry every timeslot tag required by its speakers.", + ConferenceScheduleConstraintGroup.TAG_REQUIREMENTS)); } + // The talk's timeslot must not carry any timeslot tag prohibited by its speakers. Constraint speakerProhibitedTimeslotTags(ConstraintFactory factory) { return factory.forEach(Talk.class) .expand(Talk::prevailingSpeakerProhibitedTimeslotTagCount) .filter((talk, prohibitedTagCount) -> prohibitedTagCount > 0) - .penalize(HardSoftScore.ofHard(1), + .penalize(HardMediumSoftScore.ofHard(1), (talk, prohibitedTagCount) -> prohibitedTagCount * talk.getDurationInMinutes()) .justifyWith((talk, integer, score) -> new ProhibitedTagsJustification("timeslot", talk.getSpeakers(), talk.getSpeakers().stream() @@ -251,51 +248,69 @@ Constraint speakerProhibitedTimeslotTags(ConstraintFactory factory) { .distinct() .toList(), talk.getTimeslot().getTags())) - .asConstraint(SPEAKER_PROHIBITED_TIMESLOT_TAGS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.SPEAKER_PROHIBITED_TIMESLOT_TAGS, + ConferenceConstraintProperties.SPEAKER_PROHIBITED_TIMESLOT_TAGS, + "The talk's timeslot must not carry any timeslot tag prohibited by its speakers.", + ConferenceScheduleConstraintGroup.TAG_REQUIREMENTS)); } + // The talk's timeslot must carry every timeslot tag the talk itself requires. Constraint talkRequiredTimeslotTags(ConstraintFactory factory) { return factory.forEach(Talk.class) .expand(Talk::missingRequiredTimeslotTagCount) .filter((talk, missingTagCount) -> missingTagCount > 0) - .penalize(HardSoftScore.ofHard(1), (talk, missingTagCount) -> missingTagCount * talk.getDurationInMinutes()) + .penalize(HardMediumSoftScore.ofHard(1), + (talk, missingTagCount) -> missingTagCount * talk.getDurationInMinutes()) .justifyWith((talk, integer, score) -> new RequiredTagsJustification("timeslot", talk, talk.getRequiredTimeslotTags(), talk.getTimeslot().getTags())) - .asConstraint(TALK_REQUIRED_TIMESLOT_TAGS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.TALK_REQUIRED_TIMESLOT_TAGS, + ConferenceConstraintProperties.TALK_REQUIRED_TIMESLOT_TAGS, + "The talk's timeslot must carry every timeslot tag the talk requires.", + ConferenceScheduleConstraintGroup.TAG_REQUIREMENTS)); } + // The talk's timeslot must not carry any timeslot tag the talk itself prohibits. Constraint talkProhibitedTimeslotTags(ConstraintFactory factory) { return factory.forEach(Talk.class) .expand(Talk::prevailingProhibitedTimeslotTagCount) .filter((talk, prohibitedTagCount) -> prohibitedTagCount > 0) - .penalize(HardSoftScore.ofHard(1), + .penalize(HardMediumSoftScore.ofHard(1), (talk, prohibitedTagCount) -> prohibitedTagCount * talk.getDurationInMinutes()) .justifyWith((talk, integer, score) -> new ProhibitedTagsJustification("timeslot", talk, talk.getProhibitedTimeslotTags(), talk.getTimeslot().getTags())) - .asConstraint(TALK_PROHIBITED_TIMESLOT_TAGS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.TALK_PROHIBITED_TIMESLOT_TAGS, + ConferenceConstraintProperties.TALK_PROHIBITED_TIMESLOT_TAGS, + "The talk's timeslot must not carry any timeslot tag the talk prohibits.", + ConferenceScheduleConstraintGroup.TAG_REQUIREMENTS)); } + // The talk's room must carry every room tag required by its speakers. Constraint speakerRequiredRoomTags(ConstraintFactory factory) { return factory.forEach(Talk.class) .expand(Talk::missingSpeakerRequiredRoomTagCount) .filter((talk, missingTagCount) -> missingTagCount > 0) - .penalize(HardSoftScore.ofHard(1), (talk, missingTagCount) -> missingTagCount * talk.getDurationInMinutes()) + .penalize(HardMediumSoftScore.ofHard(1), + (talk, missingTagCount) -> missingTagCount * talk.getDurationInMinutes()) .justifyWith((talk, integer, score) -> new RequiredTagsJustification("room", talk.getSpeakers(), talk.getSpeakers().stream() .flatMap(s -> s.getRequiredRoomTags().stream()) .distinct() .toList(), talk.getRoom().getTags())) - .asConstraint(SPEAKER_REQUIRED_ROOM_TAGS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.SPEAKER_REQUIRED_ROOM_TAGS, + ConferenceConstraintProperties.SPEAKER_REQUIRED_ROOM_TAGS, + "The talk's room must carry every room tag required by its speakers.", + ConferenceScheduleConstraintGroup.TAG_REQUIREMENTS)); } + // The talk's room must not carry any room tag prohibited by its speakers. Constraint speakerProhibitedRoomTags(ConstraintFactory factory) { return factory.forEach(Talk.class) .expand(Talk::prevailingSpeakerProhibitedRoomTagCount) .filter((talk, prohibitedTagCount) -> prohibitedTagCount > 0) - .penalize(HardSoftScore.ofHard(1), + .penalize(HardMediumSoftScore.ofHard(1), (talk, prohibitedTagCount) -> prohibitedTagCount * talk.getDurationInMinutes()) .justifyWith((talk, integer, score) -> new ProhibitedTagsJustification("room", talk.getSpeakers(), talk.getSpeakers().stream() @@ -303,54 +318,71 @@ Constraint speakerProhibitedRoomTags(ConstraintFactory factory) { .distinct() .toList(), talk.getRoom().getTags())) - .asConstraint(SPEAKER_PROHIBITED_ROOM_TAGS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.SPEAKER_PROHIBITED_ROOM_TAGS, + ConferenceConstraintProperties.SPEAKER_PROHIBITED_ROOM_TAGS, + "The talk's room must not carry any room tag prohibited by its speakers.", + ConferenceScheduleConstraintGroup.TAG_REQUIREMENTS)); } + // The talk's room must carry every room tag the talk itself requires. Constraint talkRequiredRoomTags(ConstraintFactory factory) { return factory.forEach(Talk.class) .expand(Talk::missingRequiredRoomTagCount) .filter((talk, missingTagCount) -> missingTagCount > 0) - .penalize(HardSoftScore.ofHard(1), (talk, missingTagCount) -> missingTagCount * talk.getDurationInMinutes()) + .penalize(HardMediumSoftScore.ofHard(1), + (talk, missingTagCount) -> missingTagCount * talk.getDurationInMinutes()) .justifyWith((talk, integer, score) -> new RequiredTagsJustification("room", talk, talk.getRequiredRoomTags(), talk.getRoom().getTags())) - .asConstraint(TALK_REQUIRED_ROOM_TAGS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.TALK_REQUIRED_ROOM_TAGS, + ConferenceConstraintProperties.TALK_REQUIRED_ROOM_TAGS, + "The talk's room must carry every room tag the talk requires.", + ConferenceScheduleConstraintGroup.TAG_REQUIREMENTS)); } + // The talk's room must not carry any room tag the talk itself prohibits. Constraint talkProhibitedRoomTags(ConstraintFactory factory) { return factory.forEach(Talk.class) .expand(Talk::prevailingProhibitedRoomTagCount) .filter((talk, prohibitedTagCount) -> prohibitedTagCount > 0) - .penalize(HardSoftScore.ofHard(1), + .penalize(HardMediumSoftScore.ofHard(1), (talk, prohibitedTagCount) -> prohibitedTagCount * talk.getDurationInMinutes()) .justifyWith((talk, integer, score) -> new ProhibitedTagsJustification("room", talk, talk.getProhibitedRoomTags(), talk.getRoom().getTags())) - .asConstraint(TALK_PROHIBITED_ROOM_TAGS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.TALK_PROHIBITED_ROOM_TAGS, + ConferenceConstraintProperties.TALK_PROHIBITED_ROOM_TAGS, + "The talk's room must not carry any room tag the talk prohibits.", + ConferenceScheduleConstraintGroup.TAG_REQUIREMENTS)); } // ************************************************************************ // Soft constraints // ************************************************************************ + // Talks that share a theme track should not be scheduled at overlapping times. Constraint themeTrackConflict(ConstraintFactory factory) { return factory.forEachUniquePair(Talk.class, - overlapping(t -> t.getTimeslot().getStartDateTime(), t -> t.getTimeslot().getEndDateTime()), - Joiners.containingAnyOf(Talk::getThemeTrackTags)) - .penalize(HardSoftScore.ofSoft(10), (talk1, talk2) -> talk1.overlappingThemeTrackCount(talk2) * + Joiners.overlapping(t -> t.getTimeslot().getStartDateTime(), t -> t.getTimeslot().getEndDateTime()), + Joiners.containingAnyOf(Talk::getThemeTrackTags)) + .penalize(HardMediumSoftScore.ofSoft(10), (talk1, talk2) -> talk1.overlappingThemeTrackCount(talk2) * talk1.overlappingDurationInMinutes(talk2)) .justifyWith( (talk, talk2, score) -> new ConflictTalkJustification("theme", talk, talk.getThemeTrackTags(), talk2, talk2.getThemeTrackTags())) - .asConstraint(THEME_TRACK_CONFLICT); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.THEME_TRACK_CONFLICT, + ConferenceConstraintProperties.THEME_TRACK_CONFLICT, + "Talks sharing a theme track should not overlap in time.", + ConferenceScheduleConstraintGroup.PROGRAM_QUALITY)); } + // Talks sharing a theme track on the same day should stay in the same room. Constraint themeTrackRoomStability(ConstraintFactory factory) { return factory.forEachUniquePair(Talk.class, - equal(talk -> talk.getTimeslot().getStartDateTime().toLocalDate()), - Joiners.containingAnyOf(Talk::getThemeTrackTags)) + Joiners.equal(talk -> talk.getTimeslot().getStartDateTime().toLocalDate()), + Joiners.containingAnyOf(Talk::getThemeTrackTags)) .filter((talk1, talk2) -> !talk1.getRoom().equals(talk2.getRoom())) - .penalize(HardSoftScore.ofSoft(10), (talk1, talk2) -> talk1.overlappingThemeTrackCount(talk2) * + .penalize(HardMediumSoftScore.ofSoft(10), (talk1, talk2) -> talk1.overlappingThemeTrackCount(talk2) * talk1.combinedDurationInMinutes(talk2)) .justifyWith( (talk, talk2, score) -> new ConferenceSchedulingJustification( @@ -358,102 +390,134 @@ Constraint themeTrackRoomStability(ConstraintFactory factory) { .formatted(talk.getCode(), talk2.getCode(), talk.getThemeTrackTags().stream() .filter(t -> talk2.getThemeTrackTags().contains(t)) - .collect(joining(", ")), + .collect(Collectors.joining(", ")), talk.getRoom().getId(), talk2.getRoom().getId()))) - .asConstraint(THEME_TRACK_ROOM_STABILITY); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.THEME_TRACK_ROOM_STABILITY, + ConferenceConstraintProperties.THEME_TRACK_ROOM_STABILITY, + "Talks sharing a theme track on the same day should stay in the same room.", + ConferenceScheduleConstraintGroup.PROGRAM_QUALITY)); } + // Talks that share a sector should not be scheduled at overlapping times. Constraint sectorConflict(ConstraintFactory factory) { return factory.forEachUniquePair(Talk.class, - overlapping(t -> t.getTimeslot().getStartDateTime(), t -> t.getTimeslot().getEndDateTime()), - Joiners.containingAnyOf(Talk::getSectorTags)) - .penalize(HardSoftScore.ofSoft(10), (talk1, talk2) -> talk1.overlappingSectorCount(talk2) + Joiners.overlapping(t -> t.getTimeslot().getStartDateTime(), t -> t.getTimeslot().getEndDateTime()), + Joiners.containingAnyOf(Talk::getSectorTags)) + .penalize(HardMediumSoftScore.ofSoft(10), (talk1, talk2) -> talk1.overlappingSectorCount(talk2) * talk1.overlappingDurationInMinutes(talk2)) .justifyWith((talk, talk2, score) -> new ConflictTalkJustification("sector", talk, talk.getSectorTags(), talk2, talk2.getSectorTags())) - .asConstraint(SECTOR_CONFLICT); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.SECTOR_CONFLICT, + ConferenceConstraintProperties.SECTOR_CONFLICT, + "Talks sharing a sector should not overlap in time.", + ConferenceScheduleConstraintGroup.PROGRAM_QUALITY)); } + // Reward talks in the same timeslot for offering several audience types (a diverse slot). Constraint audienceTypeDiversity(ConstraintFactory factory) { return factory.forEachUniquePair(Talk.class, - equal(Talk::getTimeslot), - Joiners.containingAnyOf(Talk::getAudienceTypes)) - .reward(HardSoftScore.ofSoft(1), (talk1, talk2) -> talk1.overlappingAudienceTypeCount(talk2) + Joiners.equal(Talk::getTimeslot), + Joiners.containingAnyOf(Talk::getAudienceTypes)) + .reward(HardMediumSoftScore.ofSoft(1), (talk1, talk2) -> talk1.overlappingAudienceTypeCount(talk2) * talk1.getTimeslot().getDurationInMinutes()) .justifyWith((talk, talk2, score) -> new DiversityTalkJustification("audience types", talk, talk.getAudienceTypes(), talk2, talk2.getAudienceTypes())) - .asConstraint(AUDIENCE_TYPE_DIVERSITY); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.AUDIENCE_TYPE_DIVERSITY, + ConferenceConstraintProperties.AUDIENCE_TYPE_DIVERSITY, + "Talks in the same timeslot are rewarded for sharing an audience type.", + ConferenceScheduleConstraintGroup.PROGRAM_QUALITY)); } + // Overlapping talks that share both a theme track and an audience type are penalized. Constraint audienceTypeThemeTrackConflict(ConstraintFactory factory) { return factory.forEachUniquePair(Talk.class, - overlapping(t -> t.getTimeslot().getStartDateTime(), t -> t.getTimeslot().getEndDateTime()), - Joiners.containingAnyOf(Talk::getThemeTrackTags), - Joiners.containingAnyOf(Talk::getAudienceTypes)) - .penalize(HardSoftScore.ofSoft(1), (talk1, talk2) -> talk1.overlappingThemeTrackCount(talk2) + Joiners.overlapping(t -> t.getTimeslot().getStartDateTime(), t -> t.getTimeslot().getEndDateTime()), + Joiners.containingAnyOf(Talk::getThemeTrackTags), + Joiners.containingAnyOf(Talk::getAudienceTypes)) + .penalize(HardMediumSoftScore.ofSoft(1), (talk1, talk2) -> talk1.overlappingThemeTrackCount(talk2) * talk1.overlappingAudienceTypeCount(talk2) * talk1.overlappingDurationInMinutes(talk2)) .justifyWith((talk, talk2, score) -> new ConflictTalkJustification("theme", "audience type", talk, talk.getThemeTrackTags(), talk.getAudienceTypes(), talk2, talk2.getThemeTrackTags(), talk2.getAudienceTypes())) - .asConstraint(AUDIENCE_TYPE_THEME_TRACK_CONFLICT); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.AUDIENCE_TYPE_THEME_TRACK_CONFLICT, + ConferenceConstraintProperties.AUDIENCE_TYPE_THEME_TRACK_CONFLICT, + "Overlapping talks that share both a theme track and an audience type should be avoided.", + ConferenceScheduleConstraintGroup.PROGRAM_QUALITY)); } + // Reward talks in the same timeslot for targeting different audience levels. Constraint audienceLevelDiversity(ConstraintFactory factory) { return factory.forEachUniquePair(Talk.class, - equal(Talk::getTimeslot)) + Joiners.equal(Talk::getTimeslot)) .filter((talk1, talk2) -> talk1.getAudienceLevel() != talk2.getAudienceLevel()) - .reward(HardSoftScore.ofSoft(1), (talk1, talk2) -> talk1.getTimeslot().getDurationInMinutes()) + .reward(HardMediumSoftScore.ofSoft(1), (talk1, talk2) -> talk1.getTimeslot().getDurationInMinutes()) .justifyWith((talk, talk2, score) -> new DiversityTalkJustification("audience level", talk, String.valueOf(talk.getAudienceLevel()), talk2, String.valueOf(talk2.getAudienceLevel()))) - .asConstraint(AUDIENCE_LEVEL_DIVERSITY); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.AUDIENCE_LEVEL_DIVERSITY, + ConferenceConstraintProperties.AUDIENCE_LEVEL_DIVERSITY, + "Talks in the same timeslot are rewarded for having different audience levels.", + ConferenceScheduleConstraintGroup.PROGRAM_QUALITY)); } + // A talk on shared content should not precede a lower-audience-level talk on that content (keep the level flow rising). Constraint contentAudienceLevelFlowViolation(ConstraintFactory factory) { return factory.forEach(Talk.class) .join(Talk.class, - lessThan(Talk::getAudienceLevel), - greaterThan(talk1 -> talk1.getTimeslot().getEndDateTime(), + Joiners.lessThan(Talk::getAudienceLevel), + Joiners.greaterThan(talk1 -> talk1.getTimeslot().getEndDateTime(), talk2 -> talk2.getTimeslot().getStartDateTime()), Joiners.containingAnyOf(Talk::getContentTags)) - .penalize(HardSoftScore.ofSoft(10), (talk1, talk2) -> talk1.overlappingContentCount(talk2) + .penalize(HardMediumSoftScore.ofSoft(10), (talk1, talk2) -> talk1.overlappingContentCount(talk2) * talk1.combinedDurationInMinutes(talk2)) .justifyWith((talk, talk2, score) -> new ConferenceSchedulingJustification( "Two talks [%s, %s] with the audience level [%s, %s] and matching content [%s] have a flow violation." .formatted(talk.getCode(), talk2.getCode(), String.valueOf(talk.getAudienceLevel()), String.valueOf(talk2.getAudienceLevel()), talk.getContentTags().stream().filter(c -> talk2.getContentTags().contains(c)) - .collect(joining(", "))))) - .asConstraint(CONTENT_AUDIENCE_LEVEL_FLOW_VIOLATION); + .collect(Collectors.joining(", "))))) + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.CONTENT_AUDIENCE_LEVEL_FLOW_VIOLATION, + ConferenceConstraintProperties.CONTENT_AUDIENCE_LEVEL_FLOW_VIOLATION, + "A talk on shared content should not be scheduled before a lower-audience-level talk on that content.", + ConferenceScheduleConstraintGroup.PROGRAM_QUALITY)); } + // Talks that share content should not be scheduled at overlapping times. Constraint contentConflict(ConstraintFactory factory) { return factory.forEachUniquePair(Talk.class, - overlapping(t -> t.getTimeslot().getStartDateTime(), t -> t.getTimeslot().getEndDateTime()), - Joiners.containingAnyOf(Talk::getContentTags)) - .penalize(HardSoftScore.ofSoft(100), (talk1, talk2) -> talk1.overlappingContentCount(talk2) + Joiners.overlapping(t -> t.getTimeslot().getStartDateTime(), t -> t.getTimeslot().getEndDateTime()), + Joiners.containingAnyOf(Talk::getContentTags)) + .penalize(HardMediumSoftScore.ofSoft(100), (talk1, talk2) -> talk1.overlappingContentCount(talk2) * talk1.overlappingDurationInMinutes(talk2)) .justifyWith( (talk, talk2, score) -> new ConflictTalkJustification("content", talk, talk.getContentTags(), talk2, talk2.getContentTags())) - .asConstraint(CONTENT_CONFLICT); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.CONTENT_CONFLICT, + ConferenceConstraintProperties.CONTENT_CONFLICT, + "Talks sharing content should not overlap in time.", + ConferenceScheduleConstraintGroup.PROGRAM_QUALITY)); } + // Reward talks in the same timeslot for being given in different languages. Constraint languageDiversity(ConstraintFactory factory) { return factory.forEachUniquePair(Talk.class, - equal(Talk::getTimeslot)) + Joiners.equal(Talk::getTimeslot)) .filter((talk1, talk2) -> !talk1.getLanguage().equals(talk2.getLanguage())) - .reward(HardSoftScore.ofSoft(10), (talk1, talk2) -> talk1.getTimeslot().getDurationInMinutes()) + .reward(HardMediumSoftScore.ofSoft(10), (talk1, talk2) -> talk1.getTimeslot().getDurationInMinutes()) .justifyWith((talk, talk2, score) -> new DiversityTalkJustification("language", talk, talk.getLanguage(), talk2, talk2.getLanguage())) - .asConstraint(LANGUAGE_DIVERSITY); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.LANGUAGE_DIVERSITY, + ConferenceConstraintProperties.LANGUAGE_DIVERSITY, + "Talks in the same timeslot are rewarded for using different languages.", + ConferenceScheduleConstraintGroup.PROGRAM_QUALITY)); } + // Talks that share content or a theme track should be scheduled on the same day. Constraint sameDayTalks(ConstraintFactory factory) { return factory.forEachUniquePair(Talk.class) .filter((talk1, talk2) -> !talk1.getTimeslot().isOnSameDayAs(talk2.getTimeslot()) && (talk1.overlappingContentCount(talk2) > 0 || talk1.overlappingThemeTrackCount(talk2) > 0)) - .penalize(HardSoftScore.ofSoft(10), + .penalize(HardMediumSoftScore.ofSoft(10), (talk1, talk2) -> (talk2.overlappingThemeTrackCount(talk1) + talk2.overlappingContentCount(talk1)) * talk1.combinedDurationInMinutes(talk2)) .justifyWith((talk, talk2, score) -> new ConferenceSchedulingJustification( @@ -461,45 +525,58 @@ Constraint sameDayTalks(ConstraintFactory factory) { .formatted( talk.getCode(), talk2.getCode(), talk.getContentTags().stream().filter(c -> talk2.getContentTags().contains(c)) - .collect(joining(", ")), + .collect(Collectors.joining(", ")), talk.getThemeTrackTags().stream().filter(t -> talk2.getThemeTrackTags().contains(t)) - .collect(joining(", "))))) - .asConstraint(SAME_DAY_TALKS); + .collect(Collectors.joining(", "))))) + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.SAME_DAY_TALKS, + ConferenceConstraintProperties.SAME_DAY_TALKS, + "Talks sharing content or a theme track should be scheduled on the same day.", + ConferenceScheduleConstraintGroup.PROGRAM_QUALITY)); } + // A more popular talk should not be placed in a smaller room than a less popular one. Constraint popularTalks(ConstraintFactory factory) { return factory.forEach(Talk.class) .join(Talk.class, - lessThan(Talk::getFavoriteCount), - greaterThan(talk -> talk.getRoom().getCapacity())) - .penalize(HardSoftScore.ofSoft(10), Talk::combinedDurationInMinutes) + Joiners.lessThan(Talk::getFavoriteCount), + Joiners.greaterThan(talk -> talk.getRoom().getCapacity())) + .penalize(HardMediumSoftScore.ofSoft(10), Talk::combinedDurationInMinutes) .justifyWith((talk, talk2, score) -> new ConferenceSchedulingJustification( "Two talks [%s, %s] with popularity [%d, %d] scheduled to rooms [%s, %s] with capacity [%d, %d]." .formatted(talk.getCode(), talk2.getCode(), talk.getFavoriteCount(), talk2.getFavoriteCount(), talk.getRoom().getId(), talk2.getRoom().getId(), talk.getRoom().getCapacity(), talk2.getRoom().getCapacity()))) - .asConstraint(POPULAR_TALKS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.POPULAR_TALKS, + ConferenceConstraintProperties.POPULAR_TALKS, + "A more popular talk should not be placed in a smaller room than a less popular one.", + ConferenceScheduleConstraintGroup.PROGRAM_QUALITY)); } + // The talk's timeslot should carry the timeslot tags preferred by its speakers. Constraint speakerPreferredTimeslotTags(ConstraintFactory factory) { return factory.forEach(Talk.class) .expand(Talk::missingSpeakerPreferredTimeslotTagCount) .filter((talk, missingTagCount) -> missingTagCount > 0) - .penalize(HardSoftScore.ofSoft(20), (talk, missingTagCount) -> missingTagCount * talk.getDurationInMinutes()) + .penalize(HardMediumSoftScore.ofSoft(20), + (talk, missingTagCount) -> missingTagCount * talk.getDurationInMinutes()) .justifyWith((talk, integer, score) -> new PreferredTagsJustification("timeslot", talk.getSpeakers(), talk.getSpeakers().stream() .flatMap(s -> s.getPreferredTimeslotTags().stream()) .distinct() .toList(), talk.getTimeslot().getTags())) - .asConstraint(SPEAKER_PREFERRED_TIMESLOT_TAGS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.SPEAKER_PREFERRED_TIMESLOT_TAGS, + ConferenceConstraintProperties.SPEAKER_PREFERRED_TIMESLOT_TAGS, + "The talk's timeslot should carry the timeslot tags preferred by its speakers.", + ConferenceScheduleConstraintGroup.TAG_PREFERENCES)); } + // The talk's timeslot should avoid the timeslot tags undesired by its speakers. Constraint speakerUndesiredTimeslotTags(ConstraintFactory factory) { return factory.forEach(Talk.class) .expand(Talk::prevailingSpeakerUndesiredTimeslotTagCount) .filter((talk, undesiredTagCount) -> undesiredTagCount > 0) - .penalize(HardSoftScore.ofSoft(20), + .penalize(HardMediumSoftScore.ofSoft(20), (talk, undesiredTagCount) -> undesiredTagCount * talk.getDurationInMinutes()) .justifyWith((talk, integer, score) -> new UndesiredTagsJustification("timeslot", talk.getSpeakers(), talk.getSpeakers().stream() @@ -507,51 +584,69 @@ Constraint speakerUndesiredTimeslotTags(ConstraintFactory factory) { .distinct() .toList(), talk.getTimeslot().getTags())) - .asConstraint(SPEAKER_UNDESIRED_TIMESLOT_TAGS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.SPEAKER_UNDESIRED_TIMESLOT_TAGS, + ConferenceConstraintProperties.SPEAKER_UNDESIRED_TIMESLOT_TAGS, + "The talk's timeslot should avoid the timeslot tags undesired by its speakers.", + ConferenceScheduleConstraintGroup.TAG_PREFERENCES)); } + // The talk's timeslot should carry the timeslot tags the talk itself prefers. Constraint talkPreferredTimeslotTags(ConstraintFactory factory) { return factory.forEach(Talk.class) .expand(Talk::missingPreferredTimeslotTagCount) .filter((talk, missingTagCount) -> missingTagCount > 0) - .penalize(HardSoftScore.ofSoft(20), (talk, missingTagCount) -> missingTagCount * talk.getDurationInMinutes()) + .penalize(HardMediumSoftScore.ofSoft(20), + (talk, missingTagCount) -> missingTagCount * talk.getDurationInMinutes()) .justifyWith((talk, integer, score) -> new PreferredTagsJustification("timeslot", talk, talk.getPreferredTimeslotTags(), talk.getTimeslot().getTags())) - .asConstraint(TALK_PREFERRED_TIMESLOT_TAGS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.TALK_PREFERRED_TIMESLOT_TAGS, + ConferenceConstraintProperties.TALK_PREFERRED_TIMESLOT_TAGS, + "The talk's timeslot should carry the timeslot tags the talk prefers.", + ConferenceScheduleConstraintGroup.TAG_PREFERENCES)); } + // The talk's timeslot should avoid the timeslot tags the talk itself finds undesired. Constraint talkUndesiredTimeslotTags(ConstraintFactory factory) { return factory.forEach(Talk.class) .expand(Talk::prevailingUndesiredTimeslotTagCount) .filter((talk, undesiredTagCount) -> undesiredTagCount > 0) - .penalize(HardSoftScore.ofSoft(20), + .penalize(HardMediumSoftScore.ofSoft(20), (talk, undesiredTagCount) -> undesiredTagCount * talk.getDurationInMinutes()) .justifyWith((talk, integer, score) -> new UndesiredTagsJustification("timeslot", talk, talk.getPreferredTimeslotTags(), talk.getTimeslot().getTags())) - .asConstraint(TALK_UNDESIRED_TIMESLOT_TAGS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.TALK_UNDESIRED_TIMESLOT_TAGS, + ConferenceConstraintProperties.TALK_UNDESIRED_TIMESLOT_TAGS, + "The talk's timeslot should avoid the timeslot tags the talk finds undesired.", + ConferenceScheduleConstraintGroup.TAG_PREFERENCES)); } + // The talk's room should carry the room tags preferred by its speakers. Constraint speakerPreferredRoomTags(ConstraintFactory factory) { return factory.forEach(Talk.class) .expand(Talk::missingSpeakerPreferredRoomTagCount) .filter((talk, missingTagCount) -> missingTagCount > 0) - .penalize(HardSoftScore.ofSoft(20), (talk, missingTagCount) -> missingTagCount * talk.getDurationInMinutes()) + .penalize(HardMediumSoftScore.ofSoft(20), + (talk, missingTagCount) -> missingTagCount * talk.getDurationInMinutes()) .justifyWith((talk, integer, score) -> new PreferredTagsJustification("room", talk.getSpeakers(), talk.getSpeakers().stream() .flatMap(s -> s.getPreferredRoomTags().stream()) .distinct() .toList(), talk.getRoom().getTags())) - .asConstraint(SPEAKER_PREFERRED_ROOM_TAGS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.SPEAKER_PREFERRED_ROOM_TAGS, + ConferenceConstraintProperties.SPEAKER_PREFERRED_ROOM_TAGS, + "The talk's room should carry the room tags preferred by its speakers.", + ConferenceScheduleConstraintGroup.TAG_PREFERENCES)); } + // The talk's room should avoid the room tags undesired by its speakers. Constraint speakerUndesiredRoomTags(ConstraintFactory factory) { return factory.forEach(Talk.class) .expand(Talk::prevailingSpeakerUndesiredRoomTagCount) .filter((talk, undesiredTagCount) -> undesiredTagCount > 0) - .penalize(HardSoftScore.ofSoft(20), + .penalize(HardMediumSoftScore.ofSoft(20), (talk, undesiredTagCount) -> undesiredTagCount * talk.getDurationInMinutes()) .justifyWith((talk, integer, score) -> new UndesiredTagsJustification("room", talk.getSpeakers(), talk.getSpeakers().stream() @@ -559,40 +654,55 @@ Constraint speakerUndesiredRoomTags(ConstraintFactory factory) { .distinct() .toList(), talk.getRoom().getTags())) - .asConstraint(SPEAKER_UNDESIRED_ROOM_TAGS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.SPEAKER_UNDESIRED_ROOM_TAGS, + ConferenceConstraintProperties.SPEAKER_UNDESIRED_ROOM_TAGS, + "The talk's room should avoid the room tags undesired by its speakers.", + ConferenceScheduleConstraintGroup.TAG_PREFERENCES)); } + // The talk's room should carry the room tags the talk itself prefers. Constraint talkPreferredRoomTags(ConstraintFactory factory) { return factory.forEach(Talk.class) .expand(Talk::missingPreferredRoomTagCount) .filter((talk, missingTagCount) -> missingTagCount > 0) - .penalize(HardSoftScore.ofSoft(20), (talk, missingTagCount) -> missingTagCount * talk.getDurationInMinutes()) + .penalize(HardMediumSoftScore.ofSoft(20), + (talk, missingTagCount) -> missingTagCount * talk.getDurationInMinutes()) .justifyWith((talk, integer, score) -> new PreferredTagsJustification("room", talk, talk.getPreferredRoomTags(), talk.getRoom().getTags())) - .asConstraint(TALK_PREFERRED_ROOM_TAGS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.TALK_PREFERRED_ROOM_TAGS, + ConferenceConstraintProperties.TALK_PREFERRED_ROOM_TAGS, + "The talk's room should carry the room tags the talk prefers.", + ConferenceScheduleConstraintGroup.TAG_PREFERENCES)); } + // The talk's room should avoid the room tags the talk itself finds undesired. Constraint talkUndesiredRoomTags(ConstraintFactory factory) { return factory.forEach(Talk.class) .expand(Talk::prevailingUndesiredRoomTagCount) .filter((talk, undesiredTagCount) -> undesiredTagCount > 0) - .penalize(HardSoftScore.ofSoft(20), + .penalize(HardMediumSoftScore.ofSoft(20), (talk, undesiredTagCount) -> undesiredTagCount * talk.getDurationInMinutes()) .justifyWith((talk, integer, score) -> new UndesiredTagsJustification("room", talk, talk.getUndesiredRoomTags(), talk.getRoom().getTags())) - .asConstraint(TALK_UNDESIRED_ROOM_TAGS); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.TALK_UNDESIRED_ROOM_TAGS, + ConferenceConstraintProperties.TALK_UNDESIRED_ROOM_TAGS, + "The talk's room should avoid the room tags the talk finds undesired.", + ConferenceScheduleConstraintGroup.TAG_PREFERENCES)); } + // A speaker's talks should be packed into as few days as possible (minimize their makespan). Constraint speakerMakespan(ConstraintFactory factory) { return factory.forEach(Speaker.class) .join(Talk.class, - containedIn(speaker -> speaker, Talk::getSpeakers)) + Joiners.containedIn(speaker -> speaker, Talk::getSpeakers)) .groupBy((speaker, talk) -> speaker, - compose( - min((Speaker speaker, Talk talk) -> talk, talk -> talk.getTimeslot().getStartDateTime()), - max((Speaker speaker, Talk talk) -> talk, talk -> talk.getTimeslot().getStartDateTime()), + ConstraintCollectors.compose( + ConstraintCollectors.min((Speaker speaker, Talk talk) -> talk, + talk -> talk.getTimeslot().getStartDateTime()), + ConstraintCollectors.max((Speaker speaker, Talk talk) -> talk, + talk -> talk.getTimeslot().getStartDateTime()), (firstTalk, lastTalk) -> { LocalDate firstDate = firstTalk.getTimeslot().getStartDateTime().toLocalDate(); LocalDate lastDate = lastTalk.getTimeslot().getStartDateTime().toLocalDate(); @@ -600,11 +710,14 @@ Constraint speakerMakespan(ConstraintFactory factory) { })) .filter((speaker, daysBetweenTalks) -> daysBetweenTalks > 1) // Each such day counts for 8 hours. - .penalize(HardSoftScore.ofSoft(20), (speaker, daysBetweenTalks) -> (daysBetweenTalks - 1) * 8 * 60) + .penalize(HardMediumSoftScore.ofSoft(20), (speaker, daysBetweenTalks) -> (daysBetweenTalks - 1) * 8 * 60) .justifyWith( (speaker, integer, score) -> new ConferenceSchedulingJustification( "Required makespan for speaker %s".formatted(speaker.getName()))) - .asConstraint(SPEAKER_MAKESPAN); + .asConstraint(new ConstraintInfo(ConferenceConstraintProperties.SPEAKER_MAKESPAN, + ConferenceConstraintProperties.SPEAKER_MAKESPAN, + "A speaker's talks should be packed into as few days as possible.", + ConferenceScheduleConstraintGroup.TAG_PREFERENCES)); } } diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/ConstraintGroupTag.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/ConstraintGroupTag.java new file mode 100644 index 0000000000..a4eb8b08cb --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/ConstraintGroupTag.java @@ -0,0 +1,18 @@ +package org.acme.conferencescheduling.solver; + +public enum ConstraintGroupTag { + CONFLICT_FREE_PLANNING("conflict-free planning"), + TAG_COMPLIANCE("tag compliance"), + PROGRAM_QUALITY("program quality"), + ATTENDEE_AND_SPEAKER_SATISFACTION("attendee and speaker satisfaction"); + + private final String tag; + + ConstraintGroupTag(String tag) { + this.tag = tag; + } + + public String getTag() { + return tag; + } +} diff --git a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/ConferenceSchedulingJustification.java b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/ConferenceSchedulingJustification.java deleted file mode 100644 index 8be7b53d5c..0000000000 --- a/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/solver/justifications/ConferenceSchedulingJustification.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.acme.conferencescheduling.solver.justifications; - -import ai.timefold.solver.core.api.score.stream.ConstraintJustification; - -public record ConferenceSchedulingJustification(String description) implements ConstraintJustification { - -} diff --git a/use-cases/conference-scheduling/src/main/resources/META-INF/resources/app.js b/use-cases/conference-scheduling/src/main/resources/META-INF/resources/app.js index b133b332ed..5d02445922 100644 --- a/use-cases/conference-scheduling/src/main/resources/META-INF/resources/app.js +++ b/use-cases/conference-scheduling/src/main/resources/META-INF/resources/app.js @@ -1,10 +1,35 @@ +// ── Platform context ── +// When embedded in the Timefold Platform, the iframe URL carries these query params. +// Standalone (local dev), none are present and the app behaves as before. +const PLATFORM = (function () { + const q = new URL(window.location.href).searchParams; + return { + onPlatform: q.has('onPlatform'), + runId: q.get('runId'), + apiUrl: q.has('apiUrl') ? decodeURIComponent(q.get('apiUrl')).replace(/\/+$/, '') : null, + apiKey: q.has('apiKey') ? q.get('apiKey') : null, + }; +})(); + +// Build an API URL: prefix the platform base when embedded, else root-relative (local dev). +function api(path) { + return PLATFORM.apiUrl ? PLATFORM.apiUrl + path : path; +} + +const MODEL_PATH = '/v1/schedules'; +const DEMO_DATA_PATH = '/v1/demo-data/BASIC'; + var autoRefreshIntervalId = null; const timeFormatter = JSJoda.DateTimeFormatter.ofPattern('HH:mm'); -let scheduleId = null; +let jobId = null; let loadedSchedule = null; let viewType = "R"; +// Lookup maps rebuilt on every render (the DTO flattens references to IDs). +let speakerNameById = new Map(); +let roomById = new Map(); + // Color Picker: Based on https://venngage.com/blog/color-blind-friendly-palette/ const BG_COLORS = ["#009E73","#0072B2","#D55E00","#000000","#CC79A7","#E69F00","#F0E442","#F6768E","#C10020","#A6BDD7","#803E75","#007D34","#56B4E9","#999999","#8DD3C7","#FFD92F","#B3DE69","#FB8072","#80B1D3","#B15928","#CAB2D6","#1B9E77","#E7298A","#6A3D9A"]; const FG_COLORS = ["#FFFFFF","#FFFFFF","#FFFFFF","#FFFFFF","#FFFFFF","#000000","#000000","#FFFFFF","#FFFFFF","#000000","#FFFFFF","#FFFFFF","#FFFFFF","#000000","#000000","#000000","#000000","#FFFFFF","#000000","#FFFFFF","#000000","#FFFFFF","#FFFFFF","#FFFFFF"]; @@ -44,37 +69,44 @@ $(document).ready(function () { }); $("#byRoomTab").click(function () { viewType = "R"; - refreshSchedule(); + renderSchedule(loadedSchedule); }); $("#bySpeakerTab").click(function () { viewType = "S"; - refreshSchedule(); + renderSchedule(loadedSchedule); }); $("#byThemeTrackTab").click(function () { viewType = "TH"; - refreshSchedule(); + renderSchedule(loadedSchedule); }); $("#bySectorsTab").click(function () { viewType = "SC"; - refreshSchedule(); + renderSchedule(loadedSchedule); }); $("#byAudienceTypeTab").click(function () { viewType = "AT"; - refreshSchedule(); + renderSchedule(loadedSchedule); }); $("#byAudienceLevelTab").click(function () { viewType = "AL"; - refreshSchedule(); + renderSchedule(loadedSchedule); }); setupAjax(); - refreshSchedule(); + if (PLATFORM.onPlatform) { + document.body.classList.add('on-platform'); + loadPlatformRun(); + } else { + getStatus(); + } }); function setupAjax() { $.ajaxSetup({ headers: { - 'Content-Type': 'application/json', 'Accept': 'application/json,text/plain', // plain text is required by solve() returning UUID of the solver job + 'Content-Type': 'application/json', + 'Accept': 'application/json,text/plain', + ...(PLATFORM.apiKey ? {'X-API-KEY': PLATFORM.apiKey} : {}) } }); @@ -93,28 +125,105 @@ function setupAjax() { }); } -function refreshSchedule() { - let path = "/schedules/" + scheduleId; - if (scheduleId === null) { - path = "/demo-data"; +// ── ModelRest plumbing ── +// demo-data → ModelRequest {modelInput,...}; POST model → metadata {id, solverStatus}; +// GET model/{id} → ModelResponse {metadata:{solverStatus}, modelOutput}. + +function loadPlatformRun() { + if (!PLATFORM.runId) { + showError("No runId provided by platform.", {status: 0, statusText: "missing runId"}); + return; } + jobId = PLATFORM.runId; + $.get(api(`${MODEL_PATH}/${jobId}/model-request`), function (req) { + loadedSchedule = req.modelInput || req; + renderSchedule(loadedSchedule); + getStatus(); + if (autoRefreshIntervalId == null) { + autoRefreshIntervalId = setInterval(getStatus, 2000); + } + }).fail(function (xhr) { + showError("Failed to load run input from platform.", xhr); + }); +} - $.getJSON(path, function (schedule) { - loadedSchedule = schedule; - renderSchedule(schedule); - }) - .fail(function (xhr, ajaxOptions, thrownError) { - showError("Getting the timetable has failed.", xhr); - refreshSolvingButtons(false); +function getStatus() { + if (jobId == null) { + $.get(api(DEMO_DATA_PATH), function (data) { + loadedSchedule = data.modelInput; + renderSchedule(loadedSchedule); + }).fail(function (xhr) { + showError("Getting the demo data has failed.", xhr); + refreshSolvingButtons("SOLVING_COMPLETED"); + }); + } else { + $.get(api(`${MODEL_PATH}/${jobId}`), function (data) { + loadedSchedule = data.modelOutput || loadedSchedule; + renderSchedule(loadedSchedule); + refreshSolvingButtons(data.metadata.solverStatus); + }).fail(function (xhr) { + showError("Getting the schedule has failed.", xhr); + refreshSolvingButtons("SOLVING_COMPLETED"); + }); + } +} + +function solve() { + $.get(api(DEMO_DATA_PATH), function (modelRequest) { + $.post(api(MODEL_PATH), JSON.stringify(modelRequest), function (metadata) { + jobId = metadata.id; + refreshSolvingButtons(metadata.solverStatus || "SOLVING_ACTIVE"); + if (autoRefreshIntervalId == null) { + autoRefreshIntervalId = setInterval(getStatus, 2000); + } + }).fail(function (xhr) { + showError("Start solving failed.", xhr); + refreshSolvingButtons("SOLVING_COMPLETED"); }); + }).fail(function (xhr) { + showError("Start solving failed.", xhr); + refreshSolvingButtons("SOLVING_COMPLETED"); + }); +} + +function stopSolving() { + $.delete(api(`${MODEL_PATH}/${jobId}`), function () { + refreshSolvingButtons("SOLVING_COMPLETED"); + getStatus(); + }).fail(function (xhr) { + showError("Stop solving failed.", xhr); + }); +} + +function isSolving(solverStatus) { + return solverStatus === "SOLVING_ACTIVE" || solverStatus === "SOLVING_SCHEDULED" + || solverStatus === "SOLVING_STARTED"; +} + +function refreshSolvingButtons(solverStatus) { + if (isSolving(solverStatus)) { + $("#solveButton").hide(); + $("#stopSolvingButton").show(); + } else { + $("#solveButton").show(); + $("#stopSolvingButton").hide(); + if (autoRefreshIntervalId != null) { + clearInterval(autoRefreshIntervalId); + autoRefreshIntervalId = null; + } + } } function renderSchedule(schedule) { - refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING"); - $("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score)); + if (schedule == null) { + return; + } + speakerNameById = new Map((schedule.speakers || []).map(s => [s.id, s.name])); + roomById = new Map((schedule.rooms || []).map(r => [r.id, r])); + + $("#score").text("Score: " + (schedule.score == null || schedule.score === "" ? "?" : schedule.score)); $("#info").text(`This dataset has ${schedule.talks.length} talks by ${schedule.speakers.length} speakers which need to be scheduled in ${schedule.timeslots.length} timeslots and ${schedule.rooms.length} rooms.`); - //reset color map resetColorMap(); if (viewType === "R") { @@ -132,6 +241,14 @@ function renderSchedule(schedule) { } } +function talkSpeakerNames(talk) { + return (talk.speakerIds || []).map(id => speakerNameById.get(id) ?? id).join(", "); +} + +function isAssigned(talk) { + return talk.timeslotId != null && talk.roomId != null; +} + function renderScheduleByRoom(schedule) { const scheduleByRoom = $("#scheduleByRoom"); scheduleByRoom.children().remove(); @@ -140,7 +257,7 @@ function renderScheduleByRoom(schedule) { unassignedTalks.children().remove(); const colgroup = $("").appendTo(scheduleByRoom) - colgroup.append(''); //speaker columns + colgroup.append(''); $.each(schedule.rooms, item => { colgroup.append(''); }) @@ -163,8 +280,8 @@ function renderScheduleByRoom(schedule) { const rowByRoom = $("").appendTo(tbodyByRoom); rowByRoom .append($(``) - .append($("").text(` - ${LocalDateTime.parse(timeslot.startDateTime).dayOfWeek().name().charAt(0) + LocalDateTime.parse(timeslot.startDateTime).dayOfWeek().name().slice(1).toLowerCase()} + .append($("").text(` + ${LocalDateTime.parse(timeslot.startDateTime).dayOfWeek().name().charAt(0) + LocalDateTime.parse(timeslot.startDateTime).dayOfWeek().name().slice(1).toLowerCase()} ${LocalDateTime.parse(timeslot.startDateTime).format(timeFormatter)} - ${LocalDateTime.parse(timeslot.endDateTime).format(timeFormatter)} @@ -175,33 +292,27 @@ function renderScheduleByRoom(schedule) { }); $.each(schedule.talks.sort((a, b) => a.code > b.code ? 1 : (a.code < b.code ? -1 : 0)), (index, talk) => { - const color = pickColor(talk.talkType); + const color = pickColor(talk.talkTypeName); const talkElement = $(`

`) .append($(`
`) .append($(`
`).text(`${talk.code}: ${talk.title}`)) .append($(`

`) - .append($(``).text(`by ${talk.speakers.map(s => s.name).join(", ")}`)))); - if (talk.timeslot != null && talk.room != null) { - $(`#timeslot${talk.timeslot.id}room${talk.room.id}`).append(talkElement.clone()); + .append($(``).text(`by ${talkSpeakerNames(talk)}`)))); + if (isAssigned(talk)) { + $(`#timeslot${talk.timeslotId}room${talk.roomId}`).append(talkElement.clone()); } else { unassignedTalks.append($(`

`).append(talkElement)); } }); - if (unassignedTalks.children().length === 0) { - const banner = $(`
`) - .append($(`