diff --git a/.github/workflows/lint_openapi.yml b/.github/workflows/lint_openapi.yml new file mode 100644 index 0000000000..2268858832 --- /dev/null +++ b/.github/workflows/lint_openapi.yml @@ -0,0 +1,49 @@ +name: OpenAPI Lint + +on: + push: + branches: [stable, development, '*.x'] + paths: + - 'use-cases/**' + - '.github/workflows/lint_openapi.yml' + pull_request: + branches: [stable, development, '*.x'] + paths: + - 'use-cases/**' + - '.github/workflows/lint_openapi.yml' + +jobs: + vacuum: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout timefold-quickstarts + uses: actions/checkout@v7 + + # The GitHub token prevents intermittent installation failures caused by + # API rate limiting. It only accesses public repository information. + - name: Install vacuum + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: curl -fsSL https://quobix.com/scripts/install_vacuum.sh | sh + + # Lints the committed OpenAPI spec of every model that ships a vacuum ruleset. + - name: Lint OpenAPI specs + run: | + found=0 + for ruleset in use-cases/*/.vacuum-ruleset.yaml; do + model_dir="$(dirname "$ruleset")" + spec="$model_dir/src/build/openapi.json" + if [ ! -f "$spec" ]; then + echo "::error::$model_dir has a vacuum ruleset but no committed spec at $spec" + exit 1 + fi + found=1 + echo "::group::vacuum lint $spec" + vacuum lint --ruleset "$ruleset" --details --fail-severity error "$spec" + echo "::endgroup::" + done + if [ "$found" -eq 0 ]; then + echo "::error::no .vacuum-ruleset.yaml found under use-cases/" + exit 1 + fi diff --git a/use-cases/conference-scheduling/.vacuum-ruleset.yaml b/use-cases/conference-scheduling/.vacuum-ruleset.yaml new file mode 100644 index 0000000000..8965c18bfe --- /dev/null +++ b/use-cases/conference-scheduling/.vacuum-ruleset.yaml @@ -0,0 +1,108 @@ +# Spectral-compatible vacuum ruleset. +# Enforces that every project-owned component schema and each of its properties +# has a 'description' field. SDK-provided schemas are intentionally excluded. +# All other built-in rules are explicitly disabled so the checks are focused. +# +# SDK schemas are identified by their name prefix and excluded via a JSONPath +# filter expression using search(@property, ...). Any schema whose name does NOT +# start with a known SDK prefix is treated as project-owned and must have +# descriptions. This means the ruleset automatically covers renamed or newly +# added domain classes without requiring any changes here. +# +# Known SDK name prefixes (kept in sync with timefold-sdk-model-parent releases): +# Dataset, Demo, Duration, Empty, Error, Log, Maps, Metadata, Model, +# Offset, Operation, Resources, Run, Score, Solver, Solving, +# ValidationError, ValidationResultSummary +# +# Run in CI: .github/workflows/lint_openapi.yml lints the committed spec of every +# model that ships a .vacuum-ruleset.yaml. +# Run locally: install vacuum (https://quobix.com/vacuum/installing/), then +# vacuum lint --ruleset .vacuum-ruleset.yaml --details --fail-severity error src/build/openapi.json +extends: + - - vacuum:oas + - recommended +rules: + # disable all built-in rules (including the generic component-description) + camel-case-properties: off + component-description: off # replaced below with a scoped custom rule + description-duplication: off + duplicate-paths: off + duplicated-entry-in-enum: off + info-description: off + info-license-spdx: off + migrate-zally-ignore: off + no-$ref-siblings: off + no-ambiguous-paths: off + no-eval-in-markdown: off + no-http-verbs-in-path: off + no-request-body: off + no-script-tags-in-markdown: off + no-unnecessary-combinator: off + nullable-enum-contains-null: off + oas-missing-type: off + oas-schema-check: off + oas2-anyOf: off + oas2-api-host: off + oas2-api-schemes: off + oas2-discriminator: off + oas2-host-not-example: off + oas2-host-trailing-slash: off + oas2-oneOf: off + oas2-operation-formData-consume-check: off + oas2-operation-security-defined: off + oas2-parameter-description: off + oas2-schema: off + oas2-unused-definition: off + oas3-api-servers: off + oas3-example-external-check: off + oas3-missing-example: off + oas3-no-$ref-siblings: off + oas3-operation-security-defined: off + oas3-parameter-description: off + oas3-schema: off + oas3-unused-component: off + oas3-valid-schema-example: off + operation-description: off + operation-operationId: off + operation-operationId-unique: off + operation-operationId-valid-in-url: off + operation-parameters: off + operation-success-response: off + operation-tag-defined: off + operation-tags: off + path-declarations-must-exist: off + path-item-refs: off + path-keys-no-trailing-slash: off + path-not-include-query: off + path-params: off + paths-kebab-case: off + typed-enum: off + + # Circular $ref chains in schemas must be treated as build-breaking errors. + circular-references: error + + # Every project-owned schema must have a top-level 'description'. + # SDK schemas are excluded by rejecting all names that start with a known SDK prefix. + # Any schema whose name does NOT match the prefix pattern is considered project-owned + # and is automatically checked — no changes needed here when classes are renamed or added. + project-schema-description: + description: "Every project-owned component schema must have a top-level 'description'." + howToFix: 'Add a @Schema(description = "...") annotation to the Java class.' + severity: error + given: + - "$.components.schemas[?!search(@property, '^(Dataset|Demo|Duration|Empty|Error|Issue|Log|Maps|Metadata|Model|Offset|Operation|Resources|Run|Score|Solving|Solver|StringDetail|Validation)')]" + then: + field: description + function: truthy + + # Every property of a project-owned schema must also have a 'description'. + # SDK schemas are excluded using the same prefix filter as above. + project-schema-property-description: + description: "Every property in a project-owned schema must have a 'description'." + howToFix: 'Add a @Schema(description = "...") annotation to the field or getter in the Java source.' + severity: error + given: + - "$.components.schemas[?!search(@property, '^(Dataset|Demo|Duration|Empty|Error|Issue|Log|Maps|Metadata|Model|Offset|Operation|Resources|Run|Score|Solving|Solver|StringDetail|Validation)')].properties[*]" + then: + field: description + function: truthy diff --git a/use-cases/conference-scheduling/README.md b/use-cases/conference-scheduling/README.md index ecb742cad6..cc7d5bac9d 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/use-cases/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/pom.xml b/use-cases/conference-scheduling/pom.xml index 50d81fa81b..2575af91db 100644 --- a/use-cases/conference-scheduling/pom.xml +++ b/use-cases/conference-scheduling/pom.xml @@ -1,66 +1,33 @@ - + 4.0.0 - org.acme - conference-scheduling - 1.0-SNAPSHOT + + ai.timefold.solver + timefold-solver-service-parent + 999-SNAPSHOT + + + ai.timefold.model + conferencescheduling + 0.0.1 + 1.0.0-SNAPSHOT + 2.50.0 + 0.8.15 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,154 +49,238 @@ 3.27.7 test - - - io.quarkus - quarkus-web-dependency-locator - - - org.webjars - bootstrap - 5.3.8 - runtime - - - org.webjars - jquery - 3.7.1 - runtime - - - org.webjars - font-awesome - 7.1.0 - runtime - - - org.webjars.npm - js-joda - 1.11.0 - runtime + com.tngtech.archunit + archunit-junit5 + 1.4.2 + test + - 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 + + + + + + maven-resources-plugin + 3.5.0 + + + copy-openapi-json + package + + copy-resources + + + ${project.basedir}/src/build + + + ${project.build.directory}/openapi-schema + + openapi.json + + + + true + + + - - - - - 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 - - - + + 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 - - - + + + + diff --git a/use-cases/conference-scheduling/src/build/openapi.json b/use-cases/conference-scheduling/src/build/openapi.json new file mode 100644 index 0000000000..1d783687e5 --- /dev/null +++ b/use-cases/conference-scheduling/src/build/openapi.json @@ -0,0 +1,2930 @@ +{ + "openapi" : "3.0.3", + "info" : { + "title" : "Conference Scheduling", + "description" : "", + "contact" : { + "name" : "Your Name", + "url" : "https://example.com", + "email" : "info@example.com" + }, + "version" : "v1" + }, + "servers" : [ { + "url" : "http://localhost:8080", + "description" : "Auto generated value" + }, { + "url" : "http://0.0.0.0:8080", + "description" : "Auto generated value" + } ], + "tags" : [ { + "name" : "Demo data", + "description" : "Generated demo data for the model" + } ], + "paths" : { + "/v1/demo-data" : { + "get" : { + "summary" : "List of available demo datasets.", + "tags" : [ "Demo data" ], + "responses" : { + "200" : { + "description" : "List of available demo datasets", + "content" : { + "application/json" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/DemoMetaData" + } + } + } + } + } + } + } + }, + "/v1/demo-data/{demoDataId}" : { + "get" : { + "summary" : "Get the demo dataset with the given identifier.", + "tags" : [ "Demo data" ], + "parameters" : [ { + "description" : "ID of the demo dataset from the list of available datasets", + "required" : true, + "name" : "demoDataId", + "in" : "path", + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "404" : { + "description" : "In case the given demo data does not exist", + "content" : { + "application/json" : { } + } + }, + "200" : { + "description" : "Demo data as a dataset", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ModelRequestConferenceScheduleInputConferenceScheduleConfigOverrides" + } + } + } + } + } + } + }, + "/v1/demo-data/{demoDataId}/input" : { + "get" : { + "summary" : "Get the demo dataset with the given identifier as model input only.", + "tags" : [ "Demo data" ], + "parameters" : [ { + "description" : "ID of the demo dataset from the list of available datasets", + "required" : true, + "name" : "demoDataId", + "in" : "path", + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "404" : { + "description" : "In case the given demo data does not exist", + "content" : { + "application/json" : { } + } + }, + "200" : { + "description" : "Demo data as a dataset", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ConferenceScheduleInput" + } + } + } + } + } + } + }, + "/v1/schedules" : { + "post" : { + "summary" : "Posts a dataset and optionally requests its solving. Unique identifier is returned that can be used for further operations on the dataset.", + "operationId" : "schedule", + "parameters" : [ { + "description" : "Optional name to be given to the dataset, if not provided a name will be generated.", + "name" : "name", + "in" : "query", + "schema" : { + "type" : "string" + } + }, { + "description" : "Operation to execute on the POST request.", + "name" : "operation", + "in" : "query", + "schema" : { + "allOf" : [ { + "$ref" : "#/components/schemas/OperationOnPost" + }, { + "default" : "SOLVE" + } ] + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ModelRequestConferenceScheduleInputConferenceScheduleConfigOverrides" + } + } + }, + "required" : true + }, + "responses" : { + "400" : { + "description" : "In case request given does not meet expectations", + "content" : { + "application/json" : { + "schema" : { + "oneOf" : [ { + "$ref" : "#/components/schemas/ErrorInfo" + }, { + "$ref" : "#/components/schemas/ValidationErrorInfo" + } ] + } + } + } + }, + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "202" : { + "description" : "Successfully accepted request to post a dataset", + "content" : { + "application/json" : { + "schema" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string" + }, + "parentId" : { + "description" : "The id of the parent data set this was created from", + "type" : "string", + "nullable" : true + }, + "originId" : { + "description" : "The id of the origin (root) data set this initially originates from", + "type" : "string", + "nullable" : true + }, + "name" : { + "type" : "string", + "nullable" : true + }, + "submitDateTime" : { + "description" : "The moment the run is submitted", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "startDateTime" : { + "description" : "The moment the run begins initializing", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "activeDateTime" : { + "description" : "The moment the solving phase begins", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "completeDateTime" : { + "description" : "The moment the solving phase concludes", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "shutdownDateTime" : { + "description" : "The moment the post-processing phase finishes", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "solverStatus" : { + "allOf" : [ { + "$ref" : "#/components/schemas/SolvingStatus" + } ], + "nullable" : true + }, + "score" : { + "type" : "string", + "nullable" : true + }, + "tags" : { + "uniqueItems" : true, + "type" : "array", + "items" : { + "type" : "string" + } + }, + "validationResult" : { + "$ref" : "#/components/schemas/LegacyValidationResult" + }, + "failureMessage" : { + "description" : "The message describing the reason of failure, in case of solverStatus=SOLVING_FAILED.", + "type" : "string", + "nullable" : true + } + } + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + }, + "get" : { + "summary" : "List all schedules that are registered in the service (that are to be solved, in progress or solved), only returning it's status, score and id", + "operationId" : "getSchedules", + "deprecated" : true, + "parameters" : [ { + "name" : "page", + "in" : "query", + "schema" : { + "format" : "int32", + "default" : 0, + "minimum" : 0, + "type" : "integer" + } + }, { + "name" : "size", + "in" : "query", + "schema" : { + "format" : "int32", + "default" : 100, + "maximum" : 1000, + "minimum" : 0, + "type" : "integer" + } + } ], + "responses" : { + "404" : { + "description" : "In case request given schedule does not exist", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "200" : { + "description" : "Returns scheduling with given identifier", + "content" : { + "application/json" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/Metadata" + } + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + } + }, + "/v1/schedules/validation-issue-types" : { + "get" : { + "summary" : "Get validation issue types supported by the model", + "operationId" : "getValidationIssueTypes", + "responses" : { + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "200" : { + "description" : "Validation issue types supported by the model", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ValidationIssueTypes" + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + } + }, + "/v1/schedules/validation-issue-types/{code}" : { + "get" : { + "summary" : "Get validation issue type by the code", + "operationId" : "getValidationIssueTypeByCode", + "parameters" : [ { + "description" : "Unique identifier of the validation issue type", + "required" : true, + "name" : "code", + "in" : "path", + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "404" : { + "description" : "In case the issue type of the given code does not exist", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "200" : { + "description" : "Validation issue type of the given code", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ValidationIssueTypes" + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + } + }, + "/v1/schedules/{id}" : { + "delete" : { + "summary" : "Terminate and return schedule with given identifier", + "operationId" : "terminateSchedule", + "parameters" : [ { + "description" : "Unique identifier of the schedule", + "required" : true, + "name" : "id", + "in" : "path", + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "404" : { + "description" : "In case request given schedule does not exist", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "200" : { + "description" : "Returns scheduling with given identifier", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ModelResponseHardMediumSoftScoreConferenceScheduleOutputConferenceScheduleInputMetricsConferenceScheduleOutputMetrics" + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + }, + "post" : { + "summary" : "Request solving a dataset under the given unique identifier.", + "operationId" : "solveDataset", + "parameters" : [ { + "description" : "Unique identifier of the dataset", + "required" : true, + "name" : "id", + "in" : "path", + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "202" : { + "description" : "Successfully accepted request to solve scheduling problem", + "content" : { + "application/json" : { + "schema" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string" + }, + "parentId" : { + "description" : "The id of the parent data set this was created from", + "type" : "string", + "nullable" : true + }, + "originId" : { + "description" : "The id of the origin (root) data set this initially originates from", + "type" : "string", + "nullable" : true + }, + "name" : { + "type" : "string", + "nullable" : true + }, + "submitDateTime" : { + "description" : "The moment the run is submitted", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "startDateTime" : { + "description" : "The moment the run begins initializing", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "activeDateTime" : { + "description" : "The moment the solving phase begins", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "completeDateTime" : { + "description" : "The moment the solving phase concludes", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "shutdownDateTime" : { + "description" : "The moment the post-processing phase finishes", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "solverStatus" : { + "allOf" : [ { + "$ref" : "#/components/schemas/SolvingStatus" + } ], + "nullable" : true + }, + "score" : { + "type" : "string", + "nullable" : true + }, + "tags" : { + "uniqueItems" : true, + "type" : "array", + "items" : { + "type" : "string" + } + }, + "validationResult" : { + "$ref" : "#/components/schemas/LegacyValidationResult" + }, + "failureMessage" : { + "description" : "The message describing the reason of failure, in case of solverStatus=SOLVING_FAILED.", + "type" : "string", + "nullable" : true + } + } + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + }, + "get" : { + "summary" : "Get schedule (might be incomplete as long as the solver is still running) with given identifier", + "operationId" : "getSchedule", + "parameters" : [ { + "description" : "Unique identifier of the schedule", + "required" : true, + "name" : "id", + "in" : "path", + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "404" : { + "description" : "In case request given schedule does not exist", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "200" : { + "description" : "Returns scheduling with given identifier", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ModelResponseHardMediumSoftScoreConferenceScheduleOutputConferenceScheduleInputMetricsConferenceScheduleOutputMetrics" + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + } + }, + "/v1/schedules/{id}/config" : { + "get" : { + "summary" : "Get the configuration used for the schedule", + "operationId" : "getScheduleConfig", + "parameters" : [ { + "description" : "Unique identifier of the schedule", + "required" : true, + "name" : "id", + "in" : "path", + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "404" : { + "description" : "In case request given schedule does not exist", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "200" : { + "description" : "Returns the configuration used for a schedule with the given identifier", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ModelConfiguration2" + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + } + }, + "/v1/schedules/{id}/events" : { + "get" : { + "summary" : "Subscribes to events of given dataset (of type metadata) as long as it has not yet reached final state (completed, failed, incomplete or invalid)", + "operationId" : "getMetadataEvents", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "name" : "status", + "in" : "query", + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/SolvingStatus" + } + } + } ], + "responses" : { + "404" : { + "description" : "In case request given dataset does not exist", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "410" : { + "description" : "In case given dataset is already in final state", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "200" : { + "description" : "Event stream of dataset changes", + "content" : { + "text/event-stream" : { + "schema" : { + "$ref" : "#/components/schemas/Metadata" + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + } + }, + "/v1/schedules/{id}/from-input" : { + "post" : { + "summary" : "Request a problem to be solved, based on a previously solved dataset. A unique identifier is returned that can be used to get the schedule once solved", + "operationId" : "fromInput", + "parameters" : [ { + "description" : "Unique identifier of the schedule", + "required" : true, + "name" : "id", + "in" : "path", + "schema" : { + "type" : "string" + } + }, { + "description" : "Optional name to be given to the dataset, if not provided a name will be generated.", + "name" : "name", + "in" : "query", + "schema" : { + "type" : "string" + } + }, { + "description" : "Operation to execute on the POST request.", + "name" : "operation", + "in" : "query", + "schema" : { + "allOf" : [ { + "$ref" : "#/components/schemas/OperationOnPost" + }, { + "default" : "SOLVE" + } ] + } + }, { + "description" : "Data to use as a source for the operation.", + "name" : "select", + "in" : "query", + "schema" : { + "allOf" : [ { + "$ref" : "#/components/schemas/DatasetSelector" + }, { + "default" : "UNSOLVED" + } ] + } + } ], + "requestBody" : { + "required" : false, + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ModelConfiguration2" + } + } + } + }, + "responses" : { + "400" : { + "description" : "In case request given does not meet expectations", + "content" : { + "application/json" : { + "schema" : { + "oneOf" : [ { + "$ref" : "#/components/schemas/ErrorInfo" + }, { + "$ref" : "#/components/schemas/ValidationErrorInfo" + } ] + } + } + } + }, + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "202" : { + "description" : "Successfully accepted request to solve scheduling problem", + "content" : { + "application/json" : { + "schema" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string" + }, + "parentId" : { + "description" : "The id of the parent data set this was created from", + "type" : "string", + "nullable" : true + }, + "originId" : { + "description" : "The id of the origin (root) data set this initially originates from", + "type" : "string", + "nullable" : true + }, + "name" : { + "type" : "string", + "nullable" : true + }, + "submitDateTime" : { + "description" : "The moment the run is submitted", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "startDateTime" : { + "description" : "The moment the run begins initializing", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "activeDateTime" : { + "description" : "The moment the solving phase begins", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "completeDateTime" : { + "description" : "The moment the solving phase concludes", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "shutdownDateTime" : { + "description" : "The moment the post-processing phase finishes", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "solverStatus" : { + "allOf" : [ { + "$ref" : "#/components/schemas/SolvingStatus" + } ], + "nullable" : true + }, + "score" : { + "type" : "string", + "nullable" : true + }, + "tags" : { + "uniqueItems" : true, + "type" : "array", + "items" : { + "type" : "string" + } + }, + "validationResult" : { + "$ref" : "#/components/schemas/LegacyValidationResult" + }, + "failureMessage" : { + "description" : "The message describing the reason of failure, in case of solverStatus=SOLVING_FAILED.", + "type" : "string", + "nullable" : true + } + } + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + } + }, + "/v1/schedules/{id}/from-patch" : { + "post" : { + "summary" : "Preview: Request a problem to be solved, based on a previous dataset that is patched with given operations. A unique identifier is returned that can be used to get the dataset", + "description" : "Request a problem to be solved, based on a previous dataset that is patched with given operations. This operation is in preview and might be a subject to change. This operation is not be available for everyone (feature-flagged).", + "operationId" : "fromPatch", + "parameters" : [ { + "description" : "Unique identifier of the schedule", + "required" : true, + "name" : "id", + "in" : "path", + "schema" : { + "type" : "string" + } + }, { + "description" : "Optional name to be given to the dataset, if not provided a name will be generated.", + "name" : "name", + "in" : "query", + "schema" : { + "type" : "string" + } + }, { + "description" : "Operation to execute on the POST request.", + "name" : "operation", + "in" : "query", + "schema" : { + "allOf" : [ { + "$ref" : "#/components/schemas/OperationOnPost" + }, { + "default" : "SOLVE" + } ] + } + }, { + "description" : "Data to use as a source for the operation.", + "name" : "select", + "in" : "query", + "schema" : { + "allOf" : [ { + "$ref" : "#/components/schemas/DatasetSelector" + }, { + "default" : "SOLVED" + } ] + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ModelInputPatchRequestConferenceScheduleConfigOverrides" + } + } + }, + "required" : true + }, + "responses" : { + "400" : { + "description" : "In case request given does not meet expectations", + "content" : { + "application/json" : { + "schema" : { + "oneOf" : [ { + "$ref" : "#/components/schemas/ErrorInfo" + }, { + "$ref" : "#/components/schemas/ValidationErrorInfo" + } ] + } + } + } + }, + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "202" : { + "description" : "Successfully patched and accepted request to solve scheduling problem", + "content" : { + "application/json" : { + "schema" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string" + }, + "parentId" : { + "description" : "The id of the parent data set this was created from", + "type" : "string", + "nullable" : true + }, + "originId" : { + "description" : "The id of the origin (root) data set this initially originates from", + "type" : "string", + "nullable" : true + }, + "name" : { + "type" : "string", + "nullable" : true + }, + "submitDateTime" : { + "description" : "The moment the run is submitted", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "startDateTime" : { + "description" : "The moment the run begins initializing", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "activeDateTime" : { + "description" : "The moment the solving phase begins", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "completeDateTime" : { + "description" : "The moment the solving phase concludes", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "shutdownDateTime" : { + "description" : "The moment the post-processing phase finishes", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "solverStatus" : { + "allOf" : [ { + "$ref" : "#/components/schemas/SolvingStatus" + } ], + "nullable" : true + }, + "score" : { + "type" : "string", + "nullable" : true + }, + "tags" : { + "uniqueItems" : true, + "type" : "array", + "items" : { + "type" : "string" + } + }, + "validationResult" : { + "$ref" : "#/components/schemas/LegacyValidationResult" + }, + "failureMessage" : { + "description" : "The message describing the reason of failure, in case of solverStatus=SOLVING_FAILED.", + "type" : "string", + "nullable" : true + } + } + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + } + }, + "/v1/schedules/{id}/input" : { + "get" : { + "summary" : "Get input dataset", + "operationId" : "getScheduleInput", + "parameters" : [ { + "description" : "Unique identifier of the schedule", + "required" : true, + "name" : "id", + "in" : "path", + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "404" : { + "description" : "In case request given schedule does not exist", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "200" : { + "description" : "Returns input dataset for given identifier", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ConferenceScheduleInput" + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + } + }, + "/v1/schedules/{id}/logs" : { + "get" : { + "summary" : "Get logs with given identifier", + "operationId" : "getScheduleLogs", + "parameters" : [ { + "description" : "Unique identifier of the schedule", + "required" : true, + "name" : "id", + "in" : "path", + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "404" : { + "description" : "In case request given schedule does not exist", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "200" : { + "description" : "Returns logs with given identifier", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/LogInfo" + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + } + }, + "/v1/schedules/{id}/metadata" : { + "get" : { + "summary" : "Get schedule status with given identifier", + "operationId" : "getMetadata", + "parameters" : [ { + "description" : "Unique identifier of the schedule", + "required" : true, + "name" : "id", + "in" : "path", + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "404" : { + "description" : "In case request given schedule does not exist", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "200" : { + "description" : "Returns scheduling with given identifier", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Metadata" + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + } + }, + "/v1/schedules/{id}/model-request" : { + "get" : { + "summary" : "Get complete input request for given schedule with given identifier", + "operationId" : "getModelRequest", + "parameters" : [ { + "description" : "Unique identifier of the schedule", + "required" : true, + "name" : "id", + "in" : "path", + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "404" : { + "description" : "In case request given schedule does not exist", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "200" : { + "description" : "Returns scheduling input with given identifier", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ModelRequestConferenceScheduleInputConferenceScheduleConfigOverrides" + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + } + }, + "/v1/schedules/{id}/new-run" : { + "post" : { + "summary" : "(Deprecated endpoint, please use /{id}/from-input instead) Request a problem to be solved, based on a previously solved dataset. A unique identifier is returned that can be used to get the schedule once solved", + "operationId" : "reschedule", + "deprecated" : true, + "parameters" : [ { + "description" : "Unique identifier of the schedule", + "required" : true, + "name" : "id", + "in" : "path", + "schema" : { + "type" : "string" + } + } ], + "requestBody" : { + "required" : false, + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ModelConfiguration2" + } + } + } + }, + "responses" : { + "400" : { + "description" : "In case request given does not meet expectations", + "content" : { + "application/json" : { + "schema" : { + "oneOf" : [ { + "$ref" : "#/components/schemas/ErrorInfo" + }, { + "$ref" : "#/components/schemas/ValidationErrorInfo" + } ] + } + } + } + }, + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "202" : { + "description" : "Successfully accepted request to solve scheduling problem", + "content" : { + "application/json" : { + "schema" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string" + }, + "parentId" : { + "description" : "The id of the parent data set this was created from", + "type" : "string", + "nullable" : true + }, + "originId" : { + "description" : "The id of the origin (root) data set this initially originates from", + "type" : "string", + "nullable" : true + }, + "name" : { + "type" : "string", + "nullable" : true + }, + "submitDateTime" : { + "description" : "The moment the run is submitted", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "startDateTime" : { + "description" : "The moment the run begins initializing", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "activeDateTime" : { + "description" : "The moment the solving phase begins", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "completeDateTime" : { + "description" : "The moment the solving phase concludes", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "shutdownDateTime" : { + "description" : "The moment the post-processing phase finishes", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "solverStatus" : { + "allOf" : [ { + "$ref" : "#/components/schemas/SolvingStatus" + } ], + "nullable" : true + }, + "score" : { + "type" : "string", + "nullable" : true + }, + "tags" : { + "uniqueItems" : true, + "type" : "array", + "items" : { + "type" : "string" + } + }, + "validationResult" : { + "$ref" : "#/components/schemas/LegacyValidationResult" + }, + "failureMessage" : { + "description" : "The message describing the reason of failure, in case of solverStatus=SOLVING_FAILED.", + "type" : "string", + "nullable" : true + } + } + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + } + }, + "/v1/schedules/{id}/run" : { + "get" : { + "summary" : "(Deprecated endpoint, please use /{id}/metadata instead) Get schedule status with given identifier", + "operationId" : "getScheduleStatus", + "deprecated" : true, + "parameters" : [ { + "description" : "Unique identifier of the schedule", + "required" : true, + "name" : "id", + "in" : "path", + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "404" : { + "description" : "In case request given schedule does not exist", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "200" : { + "description" : "Returns scheduling with given identifier", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Metadata" + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + } + }, + "/v1/schedules/{id}/validation-result" : { + "get" : { + "summary" : "Get validation result of a dataset with the given identifier", + "operationId" : "getValidationResult", + "parameters" : [ { + "description" : "Unique identifier of the schedule", + "required" : true, + "name" : "id", + "in" : "path", + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "404" : { + "description" : "In case the given dataset does not exist", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "500" : { + "description" : "In case of processing errors", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ErrorInfo" + } + } + } + }, + "200" : { + "description" : "Validation results of a dataset with the given identifier", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ValidationResultConferenceScheduleIssue" + } + } + } + } + }, + "tags" : [ "Conference Schedule _ Conference Schedule Resource" ] + } + } + }, + "components" : { + "schemas" : { + "ConferenceScheduleConfigOverrides" : { + "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.", + "type" : "object", + "properties" : { + "themeTrackConflictWeight" : { + "format" : "int64", + "description" : "Soft weight of the themeTrackConflict constraint.", + "type" : "integer" + }, + "themeTrackRoomStabilityWeight" : { + "format" : "int64", + "description" : "Soft weight of the themeTrackRoomStability constraint.", + "type" : "integer" + }, + "sectorConflictWeight" : { + "format" : "int64", + "description" : "Soft weight of the sectorConflict constraint.", + "type" : "integer" + }, + "audienceTypeDiversityWeight" : { + "format" : "int64", + "description" : "Soft weight of the audienceTypeDiversity constraint.", + "type" : "integer" + }, + "audienceTypeThemeTrackConflictWeight" : { + "format" : "int64", + "description" : "Soft weight of the audienceTypeThemeTrackConflict constraint.", + "type" : "integer" + }, + "audienceLevelDiversityWeight" : { + "format" : "int64", + "description" : "Soft weight of the audienceLevelDiversity constraint.", + "type" : "integer" + }, + "contentAudienceLevelFlowViolationWeight" : { + "format" : "int64", + "description" : "Soft weight of the contentAudienceLevelFlowViolation constraint.", + "type" : "integer" + }, + "contentConflictWeight" : { + "format" : "int64", + "description" : "Soft weight of the contentConflict constraint.", + "type" : "integer" + }, + "languageDiversityWeight" : { + "format" : "int64", + "description" : "Soft weight of the languageDiversity constraint.", + "type" : "integer" + }, + "sameDayTalksWeight" : { + "format" : "int64", + "description" : "Soft weight of the sameDayTalks constraint.", + "type" : "integer" + }, + "popularTalksWeight" : { + "format" : "int64", + "description" : "Soft weight of the popularTalks constraint.", + "type" : "integer" + }, + "speakerPreferredTimeslotTagsWeight" : { + "format" : "int64", + "description" : "Soft weight of the speakerPreferredTimeslotTags constraint.", + "type" : "integer" + }, + "speakerUndesiredTimeslotTagsWeight" : { + "format" : "int64", + "description" : "Soft weight of the speakerUndesiredTimeslotTags constraint.", + "type" : "integer" + }, + "talkPreferredTimeslotTagsWeight" : { + "format" : "int64", + "description" : "Soft weight of the talkPreferredTimeslotTags constraint.", + "type" : "integer" + }, + "talkUndesiredTimeslotTagsWeight" : { + "format" : "int64", + "description" : "Soft weight of the talkUndesiredTimeslotTags constraint.", + "type" : "integer" + }, + "speakerPreferredRoomTagsWeight" : { + "format" : "int64", + "description" : "Soft weight of the speakerPreferredRoomTags constraint.", + "type" : "integer" + }, + "speakerUndesiredRoomTagsWeight" : { + "format" : "int64", + "description" : "Soft weight of the speakerUndesiredRoomTags constraint.", + "type" : "integer" + }, + "talkPreferredRoomTagsWeight" : { + "format" : "int64", + "description" : "Soft weight of the talkPreferredRoomTags constraint.", + "type" : "integer" + }, + "talkUndesiredRoomTagsWeight" : { + "format" : "int64", + "description" : "Soft weight of the talkUndesiredRoomTags constraint.", + "type" : "integer" + }, + "speakerMakespanWeight" : { + "format" : "int64", + "description" : "Soft weight of the speakerMakespan constraint.", + "type" : "integer" + } + } + }, + "ConferenceScheduleInput" : { + "description" : "The conference scheduling planning problem input.", + "type" : "object", + "properties" : { + "name" : { + "description" : "Name of the conference.", + "type" : "string" + }, + "talkTypes" : { + "description" : "Talk types restricting compatible timeslots and rooms.", + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/TalkTypeDTO" + } + }, + "timeslots" : { + "description" : "Timeslots a talk can be assigned to.", + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/TimeslotDTO" + } + }, + "rooms" : { + "description" : "Rooms a talk can be assigned to.", + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/RoomDTO" + } + }, + "speakers" : { + "description" : "Speakers presenting the talks.", + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/SpeakerDTO" + } + }, + "talks" : { + "description" : "Talks that must each be assigned to a timeslot and a room.", + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/TalkDTO" + } + } + } + }, + "ConferenceScheduleInputMetrics" : { + "description" : "Metrics describing the conference scheduling problem submitted in the input dataset.", + "type" : "object", + "properties" : { + "talks" : { + "format" : "number", + "title" : "Talks", + "description" : "The number of talks submitted in the input dataset.", + "minimum" : 0, + "type" : "integer", + "readOnly" : true, + "example" : 15, + "x-tf-priority" : "1", + "x-tf-example" : "15" + }, + "speakers" : { + "format" : "number", + "title" : "Speakers", + "description" : "The number of speakers submitted in the input dataset.", + "minimum" : 0, + "type" : "integer", + "readOnly" : true, + "example" : 12, + "x-tf-priority" : "2", + "x-tf-example" : "12" + }, + "rooms" : { + "format" : "number", + "title" : "Rooms", + "description" : "The number of rooms submitted in the input dataset.", + "minimum" : 0, + "type" : "integer", + "readOnly" : true, + "example" : 5, + "x-tf-priority" : "3", + "x-tf-example" : "5" + }, + "timeslots" : { + "format" : "number", + "title" : "Timeslots", + "description" : "The number of timeslots submitted in the input dataset.", + "minimum" : 0, + "type" : "integer", + "readOnly" : true, + "example" : 6, + "x-tf-priority" : "4", + "x-tf-example" : "6" + }, + "talkTypes" : { + "format" : "number", + "title" : "Talk types", + "description" : "The number of talk types submitted in the input dataset.", + "minimum" : 0, + "type" : "integer", + "readOnly" : true, + "example" : 2, + "x-tf-priority" : "5", + "x-tf-example" : "2" + } + } + }, + "ConferenceScheduleIssue" : { + "description" : "A dataset validation issue reported for a conference schedule input.", + "required" : [ "code", "severity" ], + "type" : "object", + "properties" : { + "code" : { + "description" : "Issue code referring to an issue type.", + "type" : "string" + }, + "severity" : { + "description" : "Issue severity", + "enum" : [ "ERROR", "WARNING" ], + "type" : "string" + } + } + }, + "ConferenceScheduleOutput" : { + "description" : "The conference scheduling planning problem output.", + "type" : "object", + "properties" : { + "name" : { + "description" : "Name of the conference.", + "type" : "string" + }, + "talkTypes" : { + "description" : "Talk types restricting compatible timeslots and rooms.", + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/TalkTypeDTO" + } + }, + "timeslots" : { + "description" : "Timeslots a talk can be assigned to.", + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/TimeslotDTO" + } + }, + "rooms" : { + "description" : "Rooms a talk can be assigned to.", + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/RoomDTO" + } + }, + "speakers" : { + "description" : "Speakers presenting the talks.", + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/SpeakerDTO" + } + }, + "talks" : { + "description" : "Talks with their assigned timeslot and room.", + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/TalkDTO" + } + }, + "score" : { + "description" : "The score of the solution.", + "type" : "string" + } + } + }, + "ConferenceScheduleOutputMetrics" : { + "description" : "Metrics describing the conference scheduling solution produced for this schedule.", + "type" : "object", + "properties" : { + "totalScheduledTalks" : { + "format" : "number", + "title" : "Scheduled talks", + "description" : "The number of talks assigned to both a timeslot and a room in this schedule.", + "minimum" : 0, + "type" : "integer", + "readOnly" : true, + "example" : 15, + "x-tf-priority" : "1", + "x-tf-example" : "15" + }, + "totalUnscheduledTalks" : { + "format" : "number", + "title" : "Unscheduled talks", + "description" : "The number of talks left without a timeslot or room in this schedule.", + "minimum" : 0, + "type" : "integer", + "readOnly" : true, + "example" : 0, + "x-tf-priority" : "2", + "x-tf-example" : "0" + }, + "totalUsedRooms" : { + "format" : "number", + "title" : "Used rooms", + "description" : "The number of distinct rooms used by at least one talk in this schedule.", + "minimum" : 0, + "type" : "integer", + "readOnly" : true, + "example" : 5, + "x-tf-priority" : "3", + "x-tf-example" : "5" + }, + "totalUsedTimeslots" : { + "format" : "number", + "title" : "Used timeslots", + "description" : "The number of distinct timeslots used by at least one talk in this schedule.", + "minimum" : 0, + "type" : "integer", + "readOnly" : true, + "example" : 6, + "x-tf-priority" : "4", + "x-tf-example" : "6" + } + } + }, + "DatasetSelector" : { + "enum" : [ "UNSOLVED", "SOLVED" ], + "type" : "string" + }, + "DemoDataConfigEntry" : { + "type" : "object", + "properties" : { + "key" : { + "type" : "string" + }, + "value" : { + "type" : "string" + } + } + }, + "DemoMetaData" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string" + }, + "shortDescription" : { + "type" : "string" + }, + "longDescription" : { + "type" : "string" + }, + "tags" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "config" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/DemoDataConfigEntry" + } + } + } + }, + "Duration" : { + "format" : "duration", + "type" : "string", + "example" : "P1D" + }, + "ErrorInfo" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string" + }, + "code" : { + "type" : "string" + }, + "message" : { + "type" : "string" + }, + "details" : { + "type" : "string" + } + } + }, + "IssueCode" : { + "type" : "object", + "properties" : { + "value" : { + "type" : "string" + } + } + }, + "IssueMessage" : { + "type" : "object", + "properties" : { + "type" : { + "description" : "The type of the issue type detail.", + "type" : "string", + "readOnly" : true + }, + "message" : { + "type" : "string" + } + } + }, + "IssueMetadata" : { + "type" : "object", + "properties" : { + "type" : { + "description" : "The type of the issue type detail.", + "type" : "string", + "readOnly" : true + } + }, + "oneOf" : [ { + "$ref" : "#/components/schemas/IssueMessage" + } ] + }, + "IssueSeverity" : { + "enum" : [ "ERROR", "WARNING" ], + "type" : "string" + }, + "IssueType" : { + "required" : [ "code", "severity" ], + "type" : "object", + "properties" : { + "code" : { + "description" : "Unique case-sensitive code of the issue type.", + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/IssueCode" + } ] + }, + "severity" : { + "description" : "Issue severity", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/IssueSeverity" + } ] + }, + "metadata" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/IssueMetadata" + } + } + } + }, + "LegacyValidationResult" : { + "description" : "The result of the validation of the model input", + "type" : "object", + "properties" : { + "summary" : { + "description" : "The summary of the validation. The model input passes the validation if the summary does not contain errors.", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/ValidationStatus" + } ] + }, + "errors" : { + "description" : "The list of errors that occurred during the validation. If the list is empty, the model input is considered valid.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "warnings" : { + "description" : "The list of warnings that occurred during the validation.", + "type" : "array", + "items" : { + "type" : "string" + } + } + }, + "additionalProperties" : false + }, + "LogInfo" : { + "type" : "object", + "properties" : { + "details" : { + "type" : "string" + } + } + }, + "MapsConfiguration" : { + "type" : "object", + "properties" : { + "provider" : { + "type" : "string" + }, + "location" : { + "type" : "string" + }, + "maxDistanceFromRoad" : { + "format" : "double", + "type" : "number" + }, + "transportType" : { + "type" : "string" + } + } + }, + "Metadata" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string" + }, + "parentId" : { + "description" : "The id of the parent data set this was created from", + "type" : "string", + "nullable" : true + }, + "originId" : { + "description" : "The id of the origin (root) data set this initially originates from", + "type" : "string", + "nullable" : true + }, + "name" : { + "type" : "string", + "nullable" : true + }, + "submitDateTime" : { + "description" : "The moment the run is submitted", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "startDateTime" : { + "description" : "The moment the run begins initializing", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "activeDateTime" : { + "description" : "The moment the solving phase begins", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "completeDateTime" : { + "description" : "The moment the solving phase concludes", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "shutdownDateTime" : { + "description" : "The moment the post-processing phase finishes", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "solverStatus" : { + "allOf" : [ { + "$ref" : "#/components/schemas/SolvingStatus" + } ], + "nullable" : true + }, + "score" : { + "type" : "string", + "nullable" : true + }, + "tags" : { + "uniqueItems" : true, + "type" : "array", + "items" : { + "type" : "string" + } + }, + "validationResult" : { + "$ref" : "#/components/schemas/LegacyValidationResult" + }, + "failureMessage" : { + "description" : "The message describing the reason of failure, in case of solverStatus=SOLVING_FAILED.", + "type" : "string", + "nullable" : true + } + } + }, + "MetadataHardMediumSoftScore" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string" + }, + "parentId" : { + "description" : "The id of the parent data set this was created from", + "type" : "string", + "nullable" : true + }, + "originId" : { + "description" : "The id of the origin (root) data set this initially originates from", + "type" : "string", + "nullable" : true + }, + "name" : { + "type" : "string", + "nullable" : true + }, + "submitDateTime" : { + "description" : "The moment the run is submitted", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "startDateTime" : { + "description" : "The moment the run begins initializing", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "activeDateTime" : { + "description" : "The moment the solving phase begins", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "completeDateTime" : { + "description" : "The moment the solving phase concludes", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "shutdownDateTime" : { + "description" : "The moment the post-processing phase finishes", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/OffsetDateTime" + } ], + "nullable" : true + }, + "solverStatus" : { + "allOf" : [ { + "$ref" : "#/components/schemas/SolvingStatus" + } ], + "nullable" : true + }, + "score" : { + "type" : "string", + "nullable" : true + }, + "tags" : { + "uniqueItems" : true, + "type" : "array", + "items" : { + "type" : "string" + } + }, + "validationResult" : { + "$ref" : "#/components/schemas/LegacyValidationResult" + }, + "failureMessage" : { + "description" : "The message describing the reason of failure, in case of solverStatus=SOLVING_FAILED.", + "type" : "string", + "nullable" : true + } + } + }, + "ModelConfigConferenceScheduleConfigOverrides" : { + "type" : "object", + "properties" : { + "overrides" : { + "description" : "The configuration of individual (soft) constraints weights and additional global model configuration attributes.", + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/ConferenceScheduleConfigOverrides" + } ], + "nullable" : true + } + }, + "additionalProperties" : false + }, + "ModelConfigModelConfigOverrides" : { + "type" : "object", + "properties" : { + "overrides" : { + "description" : "The configuration of individual (soft) constraints weights and additional global model configuration attributes.", + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/ModelConfigOverrides" + } ], + "nullable" : true + } + }, + "additionalProperties" : false + }, + "ModelConfigObject" : { + "type" : "object", + "properties" : { + "overrides" : { + "description" : "The configuration of individual (soft) constraints weights and additional global model configuration attributes.", + "type" : "object", + "nullable" : true + } + }, + "additionalProperties" : false + }, + "ModelConfigOverrides" : { + "type" : "object" + }, + "ModelConfiguration1" : { + "type" : "object", + "properties" : { + "run" : { + "description" : "The run configuration.", + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/RunConfiguration" + } ], + "nullable" : true + }, + "model" : { + "description" : "The model configuration. Impacts the quality of solution.", + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/ModelConfigModelConfigOverrides" + } ], + "nullable" : true + }, + "resourcesConfiguration" : { + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/ResourcesConfiguration" + } ], + "readOnly" : true, + "nullable" : true + }, + "mapsConfiguration" : { + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/MapsConfiguration" + } ], + "readOnly" : true, + "nullable" : true + } + }, + "additionalProperties" : false + }, + "ModelConfiguration2" : { + "type" : "object", + "properties" : { + "run" : { + "description" : "The run configuration.", + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/RunConfiguration" + } ], + "nullable" : true + }, + "model" : { + "description" : "The model configuration. Impacts the quality of solution.", + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/ModelConfigConferenceScheduleConfigOverrides" + } ], + "nullable" : true + }, + "resourcesConfiguration" : { + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/ResourcesConfiguration" + } ], + "readOnly" : true, + "nullable" : true + }, + "mapsConfiguration" : { + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/MapsConfiguration" + } ], + "readOnly" : true, + "nullable" : true + } + }, + "additionalProperties" : false + }, + "ModelInput" : { + "type" : "object" + }, + "ModelInputPatch" : { + "required" : [ "op", "path" ], + "type" : "object", + "properties" : { + "op" : { + "description" : "Operation to be applied", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/ModelInputPatchOp" + } ] + }, + "path" : { + "description" : "Path within the dataset structure to be modified. Can use field expression with format [fieldName=value] or index to point to selected objects", + "type" : "string", + "example" : "/employees/[id=Employee123]" + }, + "value" : { + "description" : "Value (object, array or simple value) to be used for add or replace operations. It should not be given for remove operation", + "additionalProperties" : true, + "anyOf" : [ { }, { + "type" : "array", + "items" : { } + } ] + } + } + }, + "ModelInputPatchOp" : { + "enum" : [ "add", "remove", "replace" ], + "type" : "string" + }, + "ModelInputPatchRequestConferenceScheduleConfigOverrides" : { + "required" : [ "patch" ], + "type" : "object", + "properties" : { + "config" : { + "description" : "Optional configuration to be applied when solving the patched dataset.", + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/ModelConfiguration2" + } ] + }, + "patch" : { + "description" : "List of patches to be applied to the original dataset.", + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ModelInputPatch" + } + } + } + }, + "ModelRequestConferenceScheduleInputConferenceScheduleConfigOverrides" : { + "required" : [ "modelInput" ], + "type" : "object", + "properties" : { + "config" : { + "description" : "The configuration of the model request. If not provided, defaults of the model are used.", + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/ModelConfiguration2" + } ], + "nullable" : true + }, + "modelInput" : { + "description" : "The model input to solve.", + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/ConferenceScheduleInput" + } ] + } + }, + "additionalProperties" : false + }, + "ModelResponseHardMediumSoftScoreConferenceScheduleOutputConferenceScheduleInputMetricsConferenceScheduleOutputMetrics" : { + "type" : "object", + "properties" : { + "metadata" : { + "description" : "The model dataset metadata.", + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/MetadataHardMediumSoftScore" + } ], + "nullable" : true + }, + "modelOutput" : { + "description" : "The solution to the requested model input.", + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/ConferenceScheduleOutput" + } ], + "nullable" : true + }, + "inputMetrics" : { + "description" : "Key metrics aggregated from the model input.", + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/ConferenceScheduleInputMetrics" + } ], + "nullable" : true + }, + "kpis" : { + "description" : "Key metrics aggregated from the model output.", + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/ConferenceScheduleOutputMetrics" + } ], + "nullable" : true + }, + "run" : { + "description" : "The model run metadata. Deprecated in favor of \"metadata\"", + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/MetadataHardMediumSoftScore" + } ], + "readOnly" : true, + "nullable" : true, + "deprecated" : true + } + } + }, + "OffsetDateTime" : { + "format" : "date-time", + "type" : "string", + "example" : "2022-03-10T12:15:50-04:00" + }, + "OperationOnPost" : { + "enum" : [ "NONE", "SOLVE" ], + "type" : "string" + }, + "ResourcesConfiguration" : { + "type" : "object", + "properties" : { + "memory" : { + "format" : "double", + "type" : "number" + }, + "labels" : { + "type" : "object", + "additionalProperties" : { + "type" : "string" + } + } + } + }, + "RoomDTO" : { + "description" : "A room in which talks can be scheduled.", + "type" : "object", + "properties" : { + "id" : { + "description" : "Unique identifier of the room.", + "type" : "string" + }, + "name" : { + "description" : "Display name of the room.", + "type" : "string" + }, + "capacity" : { + "format" : "int32", + "description" : "Seating capacity of the room.", + "type" : "integer" + }, + "talkTypeNames" : { + "description" : "Names of the talk types compatible with this room.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "unavailableTimeslotIds" : { + "description" : "IDs of the timeslots during which this room is unavailable.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "tags" : { + "description" : "Tags describing this room.", + "type" : "array", + "items" : { + "type" : "string" + } + } + } + }, + "RunConfiguration" : { + "type" : "object", + "properties" : { + "name" : { + "description" : "Optional name to be given to the dataset. If not provided, the name will be generated.", + "maxLength" : 255, + "minLength" : 0, + "type" : "string", + "nullable" : true + }, + "termination" : { + "$ref" : "#/components/schemas/SolverTerminationConfig" + }, + "maxThreadCount" : { + "format" : "int32", + "description" : "Optional maximum number of threads to be used for solving.", + "minimum" : 1, + "type" : "integer", + "nullable" : true + }, + "tags" : { + "description" : "Optional tags to be assigned to the dataset.", + "maxItems" : 100, + "uniqueItems" : true, + "type" : "array", + "items" : { + "type" : "string" + } + } + }, + "additionalProperties" : false + }, + "ScoreAnalysisConfigModelConfigOverrides" : { + "type" : "object", + "properties" : { + "model" : { + "description" : "Model configuration for the score analysis.", + "type" : "object", + "allOf" : [ { + "$ref" : "#/components/schemas/ModelConfigModelConfigOverrides" + } ], + "nullable" : true + } + }, + "additionalProperties" : false + }, + "SolverTerminationConfig" : { + "type" : "object", + "properties" : { + "spentLimit" : { + "description" : "Maximum duration (ISO 8601 duration format) to keep the solver running (e.g. PT1H).", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/Duration" + } ], + "example" : "PT1H" + }, + "unimprovedSpentLimit" : { + "description" : "Maximum unimproved score duration (ISO 8601 duration format). If the score has not improved during this period (e.g. PT5M), terminate the solver. If no value is provided, the default diminished returns termination will apply. If set, stepCountLimit must be empty. Warning: using this option will disable the default diminished returns termination which is recommended for most use cases.", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/Duration" + } ], + "example" : "PT5M" + }, + "stepCountLimit" : { + "format" : "int32", + "description" : "Maximum solver step count. The solver will stop solving after a pre-determined amount of steps. Use when you require results independently of the hardware resources performance. Use this termination if you want to benchmark your models, not recommended for production use. If set, unimprovedSpentLimit must be empty. Warning: using this option will disable the default diminished returns termination which is recommended for most use cases.", + "type" : "integer", + "example" : 1000 + }, + "slidingWindowDuration" : { + "description" : "Sliding window (ISO 8601 duration format) over which score improvement is measured by the diminished returns termination. Defaults to PT30S when omitted. Only takes effect when diminished returns is active (i.e. unimprovedSpentLimit and stepCountLimit are both empty).", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/Duration" + } ], + "example" : "PT30S" + }, + "minimumImprovementRatio" : { + "format" : "double", + "description" : "Minimum ratio between current and initial improvement before the diminished returns termination kicks in. Must be strictly positive. Defaults to 0.0001 when omitted. Only takes effect when diminished returns is active (i.e. unimprovedSpentLimit and stepCountLimit are both empty).", + "type" : "number", + "example" : 0.0001 + } + }, + "additionalProperties" : false + }, + "SolvingStatus" : { + "enum" : [ "DATASET_CREATED", "DATASET_VALIDATED", "DATASET_INVALID", "DATASET_COMPUTED", "SOLVING_SCHEDULED", "SOLVING_STARTED", "SOLVING_ACTIVE", "SOLVING_COMPLETED", "SOLVING_INCOMPLETE", "SOLVING_FAILED" ], + "type" : "string" + }, + "SpeakerDTO" : { + "description" : "A speaker who presents one or more talks.", + "type" : "object", + "properties" : { + "id" : { + "description" : "Unique identifier of the speaker.", + "type" : "string" + }, + "name" : { + "description" : "Display name of the speaker.", + "type" : "string" + }, + "unavailableTimeslotIds" : { + "description" : "IDs of the timeslots during which this speaker is unavailable.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "requiredTimeslotTags" : { + "description" : "Timeslot tags required by this speaker.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "preferredTimeslotTags" : { + "description" : "Timeslot tags preferred by this speaker.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "prohibitedTimeslotTags" : { + "description" : "Timeslot tags prohibited by this speaker.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "undesiredTimeslotTags" : { + "description" : "Timeslot tags undesired by this speaker.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "requiredRoomTags" : { + "description" : "Room tags required by this speaker.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "preferredRoomTags" : { + "description" : "Room tags preferred by this speaker.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "prohibitedRoomTags" : { + "description" : "Room tags prohibited by this speaker.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "undesiredRoomTags" : { + "description" : "Room tags undesired by this speaker.", + "type" : "array", + "items" : { + "type" : "string" + } + } + } + }, + "TalkDTO" : { + "description" : "A talk to be assigned to a timeslot and a room.", + "type" : "object", + "properties" : { + "code" : { + "description" : "Unique code of the talk.", + "type" : "string" + }, + "title" : { + "description" : "Title of the talk.", + "type" : "string" + }, + "talkTypeName" : { + "description" : "Name of the talk type of this talk.", + "type" : "string" + }, + "speakerIds" : { + "description" : "IDs of the speakers presenting this talk.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "themeTrackTags" : { + "description" : "Theme track tags of this talk.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "sectorTags" : { + "description" : "Sector tags of this talk.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "audienceTypes" : { + "description" : "Audience types of this talk.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "audienceLevel" : { + "format" : "int32", + "description" : "Audience level of this talk, a low number means for beginners.", + "type" : "integer" + }, + "contentTags" : { + "description" : "Content tags of this talk.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "language" : { + "description" : "Language in which this talk is presented.", + "type" : "string" + }, + "requiredTimeslotTags" : { + "description" : "Timeslot tags required by this talk.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "preferredTimeslotTags" : { + "description" : "Timeslot tags preferred by this talk.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "prohibitedTimeslotTags" : { + "description" : "Timeslot tags prohibited by this talk.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "undesiredTimeslotTags" : { + "description" : "Timeslot tags undesired by this talk.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "requiredRoomTags" : { + "description" : "Room tags required by this talk.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "preferredRoomTags" : { + "description" : "Room tags preferred by this talk.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "prohibitedRoomTags" : { + "description" : "Room tags prohibited by this talk.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "undesiredRoomTags" : { + "description" : "Room tags undesired by this talk.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "mutuallyExclusiveTalksTags" : { + "description" : "Tags shared by talks that must not be scheduled at overlapping times.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "prerequisiteTalkCodes" : { + "description" : "Codes of the talks that must be scheduled before this talk.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "favoriteCount" : { + "format" : "int32", + "description" : "Number of attendees who marked this talk as favorite.", + "type" : "integer" + }, + "crowdControlRisk" : { + "format" : "int32", + "description" : "Crowd control risk level of this talk.", + "type" : "integer" + }, + "timeslotId" : { + "description" : "ID of the timeslot this talk is assigned to, or null if unassigned.", + "type" : "string" + }, + "roomId" : { + "description" : "ID of the room this talk is assigned to, or null if unassigned.", + "type" : "string" + } + } + }, + "TalkTypeDTO" : { + "description" : "A type of talk, e.g. Breakout or Lab, restricting compatible timeslots and rooms.", + "type" : "object", + "properties" : { + "name" : { + "description" : "Unique name of the talk type.", + "type" : "string" + } + } + }, + "TimeslotDTO" : { + "description" : "A timeslot during which talks can be scheduled.", + "type" : "object", + "properties" : { + "id" : { + "description" : "Unique identifier of the timeslot.", + "type" : "string" + }, + "startDateTime" : { + "description" : "Local start date-time in ISO-8601 format.", + "type" : "string" + }, + "endDateTime" : { + "description" : "Local end date-time in ISO-8601 format.", + "type" : "string" + }, + "talkTypeNames" : { + "description" : "Names of the talk types compatible with this timeslot.", + "type" : "array", + "items" : { + "type" : "string" + } + }, + "tags" : { + "description" : "Tags describing this timeslot.", + "type" : "array", + "items" : { + "type" : "string" + } + } + } + }, + "ValidationErrorInfo" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string" + }, + "code" : { + "type" : "string" + }, + "message" : { + "type" : "string" + }, + "details" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + } + }, + "ValidationIssueTypes" : { + "required" : [ "issueTypes" ], + "type" : "object", + "properties" : { + "issueTypes" : { + "description" : "List of all supported validation issue types together with their metadata.", + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/IssueType" + } + } + } + }, + "ValidationResultConferenceScheduleIssue" : { + "required" : [ "status" ], + "type" : "object", + "properties" : { + "status" : { + "description" : "Determines if the validated dataset is accepted for further processing", + "type" : "string", + "allOf" : [ { + "$ref" : "#/components/schemas/ValidationStatus" + } ] + }, + "issues" : { + "description" : "Validation issues found", + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ConferenceScheduleIssue" + } + } + } + }, + "ValidationStatus" : { + "enum" : [ "VALIDATION_NOT_SUPPORTED", "OK", "WARNINGS", "ERRORS" ], + "type" : "string" + } + } + } +} \ No newline at end of file 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..3c9aff6b63 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/demo/DemoDataBuilder.java @@ -0,0 +1,126 @@ +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 SpeakerDTO.builder(id, name).build(); + } + + 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(SpeakerDTO.builder("12", "Beth Green").undesiredTimeslotTags(List.of(AFTER_LUNCH)).build()); + return speakers; + } + + private static TalkDTO.Builder talk(int index, String code, String title, String talkType, List speakerIds, + int audienceLevel, int favoriteCount, int crowdControlRisk) { + return TalkDTO.builder(code, title, talkType) + .speakerIds(speakerIds) + .themeTrackTags(List.of(THEME_TAGS.get(index % THEME_TAGS.size()))) + .sectorTags(List.of(SECTOR_TAGS.get(index % SECTOR_TAGS.size()))) + .audienceTypes(List.of(AUDIENCE_TAGS.get(index % AUDIENCE_TAGS.size()))) + .audienceLevel(audienceLevel) + .contentTags(List.of(CONTENT_TAGS.get(index % CONTENT_TAGS.size()))) + .language("en") + .favoriteCount(favoriteCount) + .crowdControlRisk(crowdControlRisk); + } + + private static List buildTalks() { + List talks = new ArrayList<>(); + talks.add(talk(0, "S01", "Talk One", LAB, List.of("1", "2"), 2, 551, 1) + .undesiredRoomTags(List.of(RECORDED)).build()); + talks.add(talk(1, "S02", "Talk Two", LAB, List.of("3"), 3, 528, 0).build()); + talks.add(talk(2, "S03", "Talk Three", BREAKOUT, List.of("4"), 3, 497, 0).build()); + talks.add(talk(3, "S04", "Talk Four", BREAKOUT, List.of("5", "6"), 1, 560, 0).build()); + talks.add(talk(4, "S05", "Talk Five", BREAKOUT, List.of("7", "8"), 1, 957, 0) + .prerequisiteTalkCodes(List.of("S02")).build()); + talks.add(talk(5, "S06", "Talk Six", BREAKOUT, List.of("9"), 1, 957, 0).build()); + talks.add(talk(6, "S07", "Talk Seven", BREAKOUT, List.of("10"), 3, 568, 0).build()); + talks.add(talk(7, "S08", "Talk Eight", BREAKOUT, List.of("11"), 3, 183, 0).build()); + talks.add(talk(8, "S09", "Talk Nine", BREAKOUT, List.of("12", "1"), 3, 619, 0).build()); + talks.add(talk(9, "S10", "Talk Ten", BREAKOUT, List.of("2", "3"), 3, 603, 1).build()); + talks.add(talk(10, "S11", "Talk Eleven", BREAKOUT, List.of("4", "5"), 1, 39, 0) + .mutuallyExclusiveTalksTags(List.of("Constraints")) + .requiredRoomTags(List.of(RECORDED)).build()); + talks.add(talk(11, "S12", "Talk Twelve", BREAKOUT, List.of("6", "7"), 3, 977, 0).build()); + talks.add(talk(12, "S13", "Talk Thirteen", BREAKOUT, List.of("8"), 3, 494, 0).build()); + talks.add(talk(13, "S14", "Talk Fourteen", BREAKOUT, List.of("9"), 3, 500, 0).build()); + talks.add(talk(14, "S15", "Talk Fifteen", BREAKOUT, List.of("10"), 2, 658, 0).build()); + 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..46c78d4fa6 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 @@ -25,36 +25,103 @@ 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<>(), - new LinkedHashSet<>()); + this(builder(id, name)); } public Speaker(String name) { - 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<>(), - 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, - SequencedSet undesiredRoomTags) { - this.id = id; - this.name = name; - this.unavailableTimeslots = unavailableTimeslots; - this.requiredTimeslotTags = requiredTimeslotTags; - this.preferredTimeslotTags = preferredTimeslotTags; - this.prohibitedTimeslotTags = prohibitedTimeslotTags; - this.undesiredTimeslotTags = undesiredTimeslotTags; - this.requiredRoomTags = requiredRoomTags; - this.preferredRoomTags = preferredRoomTags; - this.prohibitedRoomTags = prohibitedRoomTags; - this.undesiredRoomTags = undesiredRoomTags; + this(builder(name, name)); + } + + private Speaker(Builder builder) { + this.id = builder.id; + this.name = builder.name; + this.unavailableTimeslots = builder.unavailableTimeslots; + this.requiredTimeslotTags = builder.requiredTimeslotTags; + this.preferredTimeslotTags = builder.preferredTimeslotTags; + this.prohibitedTimeslotTags = builder.prohibitedTimeslotTags; + this.undesiredTimeslotTags = builder.undesiredTimeslotTags; + this.requiredRoomTags = builder.requiredRoomTags; + this.preferredRoomTags = builder.preferredRoomTags; + this.prohibitedRoomTags = builder.prohibitedRoomTags; + this.undesiredRoomTags = builder.undesiredRoomTags; + } + + public static Builder builder(String id, String name) { + return new Builder(id, name); + } + + // In a fluent builder, methods named after the fields they set are the whole point. + @SuppressWarnings("PMD.AvoidFieldNameMatchingMethodName") + public static final class Builder { + + private final String id; + private final String name; + private SequencedSet unavailableTimeslots = new LinkedHashSet<>(); + private SequencedSet requiredTimeslotTags = new LinkedHashSet<>(); + private SequencedSet preferredTimeslotTags = new LinkedHashSet<>(); + private SequencedSet prohibitedTimeslotTags = new LinkedHashSet<>(); + private SequencedSet undesiredTimeslotTags = new LinkedHashSet<>(); + private SequencedSet requiredRoomTags = new LinkedHashSet<>(); + private SequencedSet preferredRoomTags = new LinkedHashSet<>(); + private SequencedSet prohibitedRoomTags = new LinkedHashSet<>(); + private SequencedSet undesiredRoomTags = new LinkedHashSet<>(); + + private Builder(String id, String name) { + this.id = id; + this.name = name; + } + + // CPD-OFF: builder boilerplate intentionally mirrors the Talk builder. + public Builder unavailableTimeslots(SequencedSet unavailableTimeslots) { + this.unavailableTimeslots = unavailableTimeslots; + return this; + } + + public Builder requiredTimeslotTags(SequencedSet requiredTimeslotTags) { + this.requiredTimeslotTags = requiredTimeslotTags; + return this; + } + + public Builder preferredTimeslotTags(SequencedSet preferredTimeslotTags) { + this.preferredTimeslotTags = preferredTimeslotTags; + return this; + } + + public Builder prohibitedTimeslotTags(SequencedSet prohibitedTimeslotTags) { + this.prohibitedTimeslotTags = prohibitedTimeslotTags; + return this; + } + + public Builder undesiredTimeslotTags(SequencedSet undesiredTimeslotTags) { + this.undesiredTimeslotTags = undesiredTimeslotTags; + return this; + } + + public Builder requiredRoomTags(SequencedSet requiredRoomTags) { + this.requiredRoomTags = requiredRoomTags; + return this; + } + + public Builder preferredRoomTags(SequencedSet preferredRoomTags) { + this.preferredRoomTags = preferredRoomTags; + return this; + } + + public Builder prohibitedRoomTags(SequencedSet prohibitedRoomTags) { + this.prohibitedRoomTags = prohibitedRoomTags; + return this; + } + + public Builder undesiredRoomTags(SequencedSet undesiredRoomTags) { + this.undesiredRoomTags = undesiredRoomTags; + return this; + } + + // CPD-ON + + public Speaker build() { + return new Speaker(this); + } } public String getId() { @@ -147,8 +214,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..d8850c41b4 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 @@ -55,47 +55,179 @@ 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(builder(code).speakers(speakers)); 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, - 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<>(), - new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashSet<>(), favoriteCount, crowdControlRisk); + private Talk(Builder builder) { + this.code = builder.code; + this.title = builder.title; + this.talkType = builder.talkType; + this.speakers = builder.speakers; + this.themeTrackTags = builder.themeTrackTags; + this.sectorTags = builder.sectorTags; + this.audienceTypes = builder.audienceTypes; + this.audienceLevel = builder.audienceLevel; + this.contentTags = builder.contentTags; + this.language = builder.language; + this.requiredTimeslotTags = builder.requiredTimeslotTags; + this.preferredTimeslotTags = builder.preferredTimeslotTags; + this.prohibitedTimeslotTags = builder.prohibitedTimeslotTags; + this.undesiredTimeslotTags = builder.undesiredTimeslotTags; + this.requiredRoomTags = builder.requiredRoomTags; + this.preferredRoomTags = builder.preferredRoomTags; + this.prohibitedRoomTags = builder.prohibitedRoomTags; + this.undesiredRoomTags = builder.undesiredRoomTags; + this.mutuallyExclusiveTalksTags = builder.mutuallyExclusiveTalksTags; + this.prerequisiteTalks = builder.prerequisiteTalks; + this.favoriteCount = builder.favoriteCount; + this.crowdControlRisk = builder.crowdControlRisk; } - public Talk(String code, String title, TalkType talkType, List speakers, SequencedSet themeTrackTags, - 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) { - this.code = code; - this.title = title; - this.talkType = talkType; - this.speakers = speakers; - this.themeTrackTags = themeTrackTags; - this.sectorTags = sectorTags; - this.audienceTypes = audienceTypes; - this.audienceLevel = audienceLevel; - this.contentTags = contentTags; - this.language = language; - this.requiredTimeslotTags = requiredTimeslotTags; - this.preferredTimeslotTags = preferredTimeslotTags; - this.prohibitedTimeslotTags = prohibitedTimeslotTags; - this.undesiredTimeslotTags = undesiredTimeslotTags; - this.requiredRoomTags = requiredRoomTags; - this.preferredRoomTags = preferredRoomTags; - this.prohibitedRoomTags = prohibitedRoomTags; - this.undesiredRoomTags = undesiredRoomTags; - this.mutuallyExclusiveTalksTags = mutuallyExclusiveTalksTags; - this.prerequisiteTalks = prerequisiteTalks; - this.favoriteCount = favoriteCount; - this.crowdControlRisk = crowdControlRisk; + public static Builder builder(String code) { + return new Builder(code); + } + + // In a fluent builder, methods named after the fields they set are the whole point. + @SuppressWarnings("PMD.AvoidFieldNameMatchingMethodName") + public static final class Builder { + + private final String code; + private String title; + private TalkType talkType; + private List speakers = emptyList(); + private SequencedSet themeTrackTags = new LinkedHashSet<>(); + private SequencedSet sectorTags = new LinkedHashSet<>(); + private SequencedSet audienceTypes = new LinkedHashSet<>(); + private int audienceLevel; + private SequencedSet contentTags = new LinkedHashSet<>(); + private String language; + private SequencedSet requiredTimeslotTags = new LinkedHashSet<>(); + private SequencedSet preferredTimeslotTags = new LinkedHashSet<>(); + private SequencedSet prohibitedTimeslotTags = new LinkedHashSet<>(); + private SequencedSet undesiredTimeslotTags = new LinkedHashSet<>(); + private SequencedSet requiredRoomTags = new LinkedHashSet<>(); + private SequencedSet preferredRoomTags = new LinkedHashSet<>(); + private SequencedSet prohibitedRoomTags = new LinkedHashSet<>(); + private SequencedSet undesiredRoomTags = new LinkedHashSet<>(); + private SequencedSet mutuallyExclusiveTalksTags = new LinkedHashSet<>(); + private SequencedSet prerequisiteTalks = new LinkedHashSet<>(); + private int favoriteCount; + private int crowdControlRisk; + + private Builder(String code) { + this.code = code; + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder talkType(TalkType talkType) { + this.talkType = talkType; + return this; + } + + public Builder speakers(List speakers) { + this.speakers = speakers; + return this; + } + + public Builder themeTrackTags(SequencedSet themeTrackTags) { + this.themeTrackTags = themeTrackTags; + return this; + } + + public Builder sectorTags(SequencedSet sectorTags) { + this.sectorTags = sectorTags; + return this; + } + + public Builder audienceTypes(SequencedSet audienceTypes) { + this.audienceTypes = audienceTypes; + return this; + } + + public Builder audienceLevel(int audienceLevel) { + this.audienceLevel = audienceLevel; + return this; + } + + public Builder contentTags(SequencedSet contentTags) { + this.contentTags = contentTags; + return this; + } + + public Builder language(String language) { + this.language = language; + return this; + } + + public Builder requiredTimeslotTags(SequencedSet requiredTimeslotTags) { + this.requiredTimeslotTags = requiredTimeslotTags; + return this; + } + + public Builder preferredTimeslotTags(SequencedSet preferredTimeslotTags) { + this.preferredTimeslotTags = preferredTimeslotTags; + return this; + } + + public Builder prohibitedTimeslotTags(SequencedSet prohibitedTimeslotTags) { + this.prohibitedTimeslotTags = prohibitedTimeslotTags; + return this; + } + + public Builder undesiredTimeslotTags(SequencedSet undesiredTimeslotTags) { + this.undesiredTimeslotTags = undesiredTimeslotTags; + return this; + } + + public Builder requiredRoomTags(SequencedSet requiredRoomTags) { + this.requiredRoomTags = requiredRoomTags; + return this; + } + + public Builder preferredRoomTags(SequencedSet preferredRoomTags) { + this.preferredRoomTags = preferredRoomTags; + return this; + } + + public Builder prohibitedRoomTags(SequencedSet prohibitedRoomTags) { + this.prohibitedRoomTags = prohibitedRoomTags; + return this; + } + + public Builder undesiredRoomTags(SequencedSet undesiredRoomTags) { + this.undesiredRoomTags = undesiredRoomTags; + return this; + } + + public Builder mutuallyExclusiveTalksTags(SequencedSet mutuallyExclusiveTalksTags) { + this.mutuallyExclusiveTalksTags = mutuallyExclusiveTalksTags; + return this; + } + + public Builder prerequisiteTalks(SequencedSet prerequisiteTalks) { + this.prerequisiteTalks = prerequisiteTalks; + return this; + } + + public Builder favoriteCount(int favoriteCount) { + this.favoriteCount = favoriteCount; + return this; + } + + public Builder crowdControlRisk(int crowdControlRisk) { + this.crowdControlRisk = crowdControlRisk; + return this; + } + + public Talk build() { + return new Talk(this); + } } @ValueRangeProvider @@ -450,6 +582,10 @@ public void setUndesiredTimeslotTags(SequencedSet undesiredTimeslotTags) this.undesiredTimeslotTags = undesiredTimeslotTags; } + public boolean isScheduled() { + return timeslot != null && room != null; + } + public SequencedSet getRequiredRoomTags() { return requiredRoomTags; } @@ -532,10 +668,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..8e79e48961 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleConfigOverrides.java @@ -0,0 +1,101 @@ +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 { + + /** + * Creates an empty overrides instance: no weight is overridden, so the configuration profile + * (or each constraint's default) applies. Required by the SDK to generate the default config profile. + */ + public ConferenceScheduleConfigOverrides() { + this(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, + null, null); + } + + public ConferenceScheduleConfigOverrides { + themeTrackConflictWeight = nonNegative(themeTrackConflictWeight); + themeTrackRoomStabilityWeight = nonNegative(themeTrackRoomStabilityWeight); + sectorConflictWeight = nonNegative(sectorConflictWeight); + audienceTypeDiversityWeight = nonNegative(audienceTypeDiversityWeight); + audienceTypeThemeTrackConflictWeight = nonNegative(audienceTypeThemeTrackConflictWeight); + audienceLevelDiversityWeight = nonNegative(audienceLevelDiversityWeight); + contentAudienceLevelFlowViolationWeight = nonNegative(contentAudienceLevelFlowViolationWeight); + contentConflictWeight = nonNegative(contentConflictWeight); + languageDiversityWeight = nonNegative(languageDiversityWeight); + sameDayTalksWeight = nonNegative(sameDayTalksWeight); + popularTalksWeight = nonNegative(popularTalksWeight); + speakerPreferredTimeslotTagsWeight = nonNegative(speakerPreferredTimeslotTagsWeight); + speakerUndesiredTimeslotTagsWeight = nonNegative(speakerUndesiredTimeslotTagsWeight); + talkPreferredTimeslotTagsWeight = nonNegative(talkPreferredTimeslotTagsWeight); + talkUndesiredTimeslotTagsWeight = nonNegative(talkUndesiredTimeslotTagsWeight); + speakerPreferredRoomTagsWeight = nonNegative(speakerPreferredRoomTagsWeight); + speakerUndesiredRoomTagsWeight = nonNegative(speakerUndesiredRoomTagsWeight); + talkPreferredRoomTagsWeight = nonNegative(talkPreferredRoomTagsWeight); + talkUndesiredRoomTagsWeight = nonNegative(talkUndesiredRoomTagsWeight); + speakerMakespanWeight = nonNegative(speakerMakespanWeight); + } + + /** + * Clamps a negative weight to 0 (disabled) and keeps null as "not overridden". + */ + private static Long nonNegative(Long weight) { + if (weight == null) { + return null; + } + return Math.max(weight, 0L); + } +} 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..a190abd01b --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleInput.java @@ -0,0 +1,36 @@ +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 = immutableCopy(talkTypes); + timeslots = immutableCopy(timeslots); + rooms = immutableCopy(rooms); + speakers = immutableCopy(speakers); + talks = immutableCopy(talks); + } + + private static List immutableCopy(List list) { + return list == null ? List.of() : List.copyOf(list); + } + + 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..c822f5ade4 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleInputMetrics.java @@ -0,0 +1,57 @@ +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."); + } + } +} 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..2c4cf3677b --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleOutput.java @@ -0,0 +1,32 @@ +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 = immutableCopy(talkTypes); + timeslots = immutableCopy(timeslots); + rooms = immutableCopy(rooms); + speakers = immutableCopy(speakers); + talks = immutableCopy(talks); + score = score == null ? "" : score; + } + + private static List immutableCopy(List list) { + return list == null ? List.of() : List.copyOf(list); + } +} 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..89b072a327 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/ConferenceScheduleOutputMetrics.java @@ -0,0 +1,55 @@ +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."); + } + } +} 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..7a70dfcc2a --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/RoomDTO.java @@ -0,0 +1,26 @@ +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 = immutableCopy(talkTypeNames); + unavailableTimeslotIds = immutableCopy(unavailableTimeslotIds); + tags = immutableCopy(tags); + } + + private static List immutableCopy(List list) { + return list == null ? List.of() : List.copyOf(list); + } +} 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..456dedad42 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/RoomIdDetail.java @@ -0,0 +1,19 @@ +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; + } + + @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..fae5deed2e --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/SpeakerDTO.java @@ -0,0 +1,117 @@ +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 = immutableCopy(unavailableTimeslotIds); + requiredTimeslotTags = immutableCopy(requiredTimeslotTags); + preferredTimeslotTags = immutableCopy(preferredTimeslotTags); + prohibitedTimeslotTags = immutableCopy(prohibitedTimeslotTags); + undesiredTimeslotTags = immutableCopy(undesiredTimeslotTags); + requiredRoomTags = immutableCopy(requiredRoomTags); + preferredRoomTags = immutableCopy(preferredRoomTags); + prohibitedRoomTags = immutableCopy(prohibitedRoomTags); + undesiredRoomTags = immutableCopy(undesiredRoomTags); + } + + private static List immutableCopy(List list) { + return list == null ? List.of() : List.copyOf(list); + } + + public static Builder builder(String id, String name) { + return new Builder(id, name); + } + + // In a fluent builder, methods named after the fields they set are the whole point. + @SuppressWarnings("PMD.AvoidFieldNameMatchingMethodName") + public static final class Builder { + + private final String id; + private final String name; + private List unavailableTimeslotIds; + private List requiredTimeslotTags; + private List preferredTimeslotTags; + private List prohibitedTimeslotTags; + private List undesiredTimeslotTags; + private List requiredRoomTags; + private List preferredRoomTags; + private List prohibitedRoomTags; + private List undesiredRoomTags; + + private Builder(String id, String name) { + this.id = id; + this.name = name; + } + + // CPD-OFF: builder boilerplate intentionally mirrors the TalkDTO builder. + public Builder unavailableTimeslotIds(List unavailableTimeslotIds) { + this.unavailableTimeslotIds = unavailableTimeslotIds; + return this; + } + + public Builder requiredTimeslotTags(List requiredTimeslotTags) { + this.requiredTimeslotTags = requiredTimeslotTags; + return this; + } + + public Builder preferredTimeslotTags(List preferredTimeslotTags) { + this.preferredTimeslotTags = preferredTimeslotTags; + return this; + } + + public Builder prohibitedTimeslotTags(List prohibitedTimeslotTags) { + this.prohibitedTimeslotTags = prohibitedTimeslotTags; + return this; + } + + public Builder undesiredTimeslotTags(List undesiredTimeslotTags) { + this.undesiredTimeslotTags = undesiredTimeslotTags; + return this; + } + + public Builder requiredRoomTags(List requiredRoomTags) { + this.requiredRoomTags = requiredRoomTags; + return this; + } + + public Builder preferredRoomTags(List preferredRoomTags) { + this.preferredRoomTags = preferredRoomTags; + return this; + } + + public Builder prohibitedRoomTags(List prohibitedRoomTags) { + this.prohibitedRoomTags = prohibitedRoomTags; + return this; + } + + public Builder undesiredRoomTags(List undesiredRoomTags) { + this.undesiredRoomTags = undesiredRoomTags; + return this; + } + + // CPD-ON + + public SpeakerDTO build() { + 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..86d52a6b47 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/SpeakerIdDetail.java @@ -0,0 +1,19 @@ +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; + } + + @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..e7953ed42f --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TalkDTO.java @@ -0,0 +1,255 @@ +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 = "Unique code of the talk.") String code, + @Schema(description = "Title of the talk.") String title, + @Schema(description = "Name of the talk type of this talk.") String talkTypeName, + @Schema(description = "IDs of the speakers presenting this talk.") List speakerIds, + @Schema(description = "Theme track tags of this talk.") List themeTrackTags, + @Schema(description = "Sector tags of this talk.") List sectorTags, + @Schema(description = "Audience types of this talk.") List audienceTypes, + @Schema(description = "Audience level of this talk, a low number means for beginners.") int audienceLevel, + @Schema(description = "Content tags of this talk.") List contentTags, + @Schema(description = "Language in which this talk is presented.") String language, + @Schema(description = "Timeslot tags required by this talk.") List requiredTimeslotTags, + @Schema(description = "Timeslot tags preferred by this talk.") List preferredTimeslotTags, + @Schema(description = "Timeslot tags prohibited by this talk.") List prohibitedTimeslotTags, + @Schema(description = "Timeslot tags undesired by this talk.") List undesiredTimeslotTags, + @Schema(description = "Room tags required by this talk.") List requiredRoomTags, + @Schema(description = "Room tags preferred by this talk.") List preferredRoomTags, + @Schema(description = "Room tags prohibited by this talk.") List prohibitedRoomTags, + @Schema(description = "Room tags undesired by this talk.") List undesiredRoomTags, + @Schema(description = "Tags shared by talks that must not be scheduled at overlapping times.") List mutuallyExclusiveTalksTags, + @Schema(description = "Codes of the talks that must be scheduled before this talk.") List prerequisiteTalkCodes, + @Schema(description = "Number of attendees who marked this talk as favorite.") int favoriteCount, + @Schema(description = "Crowd control risk level of this talk.") int crowdControlRisk, + @Schema(description = "ID of the timeslot this talk is assigned to, or null if unassigned.") String timeslotId, + @Schema(description = "ID of the room this talk is assigned to, or null if unassigned.") String roomId) { + + public TalkDTO { + code = orEmpty(code); + title = orEmpty(title); + talkTypeName = orEmpty(talkTypeName); + speakerIds = immutableCopy(speakerIds); + themeTrackTags = immutableCopy(themeTrackTags); + sectorTags = immutableCopy(sectorTags); + audienceTypes = immutableCopy(audienceTypes); + contentTags = immutableCopy(contentTags); + language = orEmpty(language); + requiredTimeslotTags = immutableCopy(requiredTimeslotTags); + preferredTimeslotTags = immutableCopy(preferredTimeslotTags); + prohibitedTimeslotTags = immutableCopy(prohibitedTimeslotTags); + undesiredTimeslotTags = immutableCopy(undesiredTimeslotTags); + requiredRoomTags = immutableCopy(requiredRoomTags); + preferredRoomTags = immutableCopy(preferredRoomTags); + prohibitedRoomTags = immutableCopy(prohibitedRoomTags); + undesiredRoomTags = immutableCopy(undesiredRoomTags); + mutuallyExclusiveTalksTags = immutableCopy(mutuallyExclusiveTalksTags); + prerequisiteTalkCodes = immutableCopy(prerequisiteTalkCodes); + timeslotId = normalizeId(timeslotId); + roomId = normalizeId(roomId); + } + + private static String orEmpty(String value) { + return value == null ? "" : value; + } + + private static List immutableCopy(List list) { + return list == null ? List.of() : List.copyOf(list); + } + + private static String normalizeId(String id) { + return id != null && id.isBlank() ? null : id; + } + + public TalkDTO withTimeslotId(String timeslotId) { + return toBuilder().timeslotId(timeslotId).build(); + } + + public TalkDTO withRoomId(String roomId) { + return toBuilder().roomId(roomId).build(); + } + + public static Builder builder(String code, String title, String talkTypeName) { + return new Builder(code, title, talkTypeName); + } + + public Builder toBuilder() { + return new Builder(code, title, talkTypeName) + .speakerIds(speakerIds) + .themeTrackTags(themeTrackTags) + .sectorTags(sectorTags) + .audienceTypes(audienceTypes) + .audienceLevel(audienceLevel) + .contentTags(contentTags) + .language(language) + .requiredTimeslotTags(requiredTimeslotTags) + .preferredTimeslotTags(preferredTimeslotTags) + .prohibitedTimeslotTags(prohibitedTimeslotTags) + .undesiredTimeslotTags(undesiredTimeslotTags) + .requiredRoomTags(requiredRoomTags) + .preferredRoomTags(preferredRoomTags) + .prohibitedRoomTags(prohibitedRoomTags) + .undesiredRoomTags(undesiredRoomTags) + .mutuallyExclusiveTalksTags(mutuallyExclusiveTalksTags) + .prerequisiteTalkCodes(prerequisiteTalkCodes) + .favoriteCount(favoriteCount) + .crowdControlRisk(crowdControlRisk) + .timeslotId(timeslotId) + .roomId(roomId); + } + + // In a fluent builder, methods named after the fields they set are the whole point. + @SuppressWarnings("PMD.AvoidFieldNameMatchingMethodName") + public static final class Builder { + + private final String code; + private final String title; + private final String talkTypeName; + private List speakerIds; + private List themeTrackTags; + private List sectorTags; + private List audienceTypes; + private int audienceLevel; + private List contentTags; + private String language; + private List requiredTimeslotTags; + private List preferredTimeslotTags; + private List prohibitedTimeslotTags; + private List undesiredTimeslotTags; + private List requiredRoomTags; + private List preferredRoomTags; + private List prohibitedRoomTags; + private List undesiredRoomTags; + private List mutuallyExclusiveTalksTags; + private List prerequisiteTalkCodes; + private int favoriteCount; + private int crowdControlRisk; + private String timeslotId; + private String roomId; + + private Builder(String code, String title, String talkTypeName) { + this.code = code; + this.title = title; + this.talkTypeName = talkTypeName; + } + + public Builder speakerIds(List speakerIds) { + this.speakerIds = speakerIds; + return this; + } + + public Builder themeTrackTags(List themeTrackTags) { + this.themeTrackTags = themeTrackTags; + return this; + } + + public Builder sectorTags(List sectorTags) { + this.sectorTags = sectorTags; + return this; + } + + public Builder audienceTypes(List audienceTypes) { + this.audienceTypes = audienceTypes; + return this; + } + + public Builder audienceLevel(int audienceLevel) { + this.audienceLevel = audienceLevel; + return this; + } + + public Builder contentTags(List contentTags) { + this.contentTags = contentTags; + return this; + } + + public Builder language(String language) { + this.language = language; + return this; + } + + public Builder requiredTimeslotTags(List requiredTimeslotTags) { + this.requiredTimeslotTags = requiredTimeslotTags; + return this; + } + + public Builder preferredTimeslotTags(List preferredTimeslotTags) { + this.preferredTimeslotTags = preferredTimeslotTags; + return this; + } + + public Builder prohibitedTimeslotTags(List prohibitedTimeslotTags) { + this.prohibitedTimeslotTags = prohibitedTimeslotTags; + return this; + } + + public Builder undesiredTimeslotTags(List undesiredTimeslotTags) { + this.undesiredTimeslotTags = undesiredTimeslotTags; + return this; + } + + public Builder requiredRoomTags(List requiredRoomTags) { + this.requiredRoomTags = requiredRoomTags; + return this; + } + + public Builder preferredRoomTags(List preferredRoomTags) { + this.preferredRoomTags = preferredRoomTags; + return this; + } + + public Builder prohibitedRoomTags(List prohibitedRoomTags) { + this.prohibitedRoomTags = prohibitedRoomTags; + return this; + } + + public Builder undesiredRoomTags(List undesiredRoomTags) { + this.undesiredRoomTags = undesiredRoomTags; + return this; + } + + public Builder mutuallyExclusiveTalksTags(List mutuallyExclusiveTalksTags) { + this.mutuallyExclusiveTalksTags = mutuallyExclusiveTalksTags; + return this; + } + + public Builder prerequisiteTalkCodes(List prerequisiteTalkCodes) { + this.prerequisiteTalkCodes = prerequisiteTalkCodes; + return this; + } + + public Builder favoriteCount(int favoriteCount) { + this.favoriteCount = favoriteCount; + return this; + } + + public Builder crowdControlRisk(int crowdControlRisk) { + this.crowdControlRisk = crowdControlRisk; + return this; + } + + public Builder timeslotId(String timeslotId) { + this.timeslotId = timeslotId; + return this; + } + + public Builder roomId(String roomId) { + this.roomId = roomId; + return this; + } + + public TalkDTO build() { + 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..1721366b50 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TalkIdDetail.java @@ -0,0 +1,19 @@ +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; + } + + @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..35f4d85f2a --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TalkTypeDTO.java @@ -0,0 +1,12 @@ +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; + } +} 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..63b1d26e36 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TimeslotDTO.java @@ -0,0 +1,23 @@ +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 = immutableCopy(talkTypeNames); + tags = immutableCopy(tags); + } + + private static List immutableCopy(List list) { + return list == null ? List.of() : List.copyOf(list); + } +} 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..b5f20535f7 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/dto/TimeslotIdDetail.java @@ -0,0 +1,19 @@ +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; + } + + @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..430529c760 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/service/ConferenceScheduleIssues.java @@ -0,0 +1,163 @@ +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; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@SuppressWarnings("PMD.MissingStaticMethodInNonInstantiatableClass") +public final class ConferenceScheduleIssues { + + private ConferenceScheduleIssues() { + } + + @Schema(description = "A dataset validation issue reported for a conference schedule input.") + 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..4e0ae1c51c --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/service/ConferenceScheduleModelConvertor.java @@ -0,0 +1,326 @@ +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(name -> require(talkTypeMap, name, "talk type")) + .collect(toCollection(LinkedHashSet::new)); + } + + private static SequencedSet timeslotsByIds(List ids, Map timeslotMap) { + return ids.stream().map(id -> require(timeslotMap, id, "timeslot")) + .collect(toCollection(LinkedHashSet::new)); + } + + /** + * Fails fast with an actionable message instead of letting an unknown reference + * turn into a null in the solver model and a delayed NullPointerException. + */ + private static T require(Map map, String key, String kind) { + T value = map.get(key); + if (value == null) { + throw new IllegalArgumentException("Unknown %s '%s'.".formatted(kind, key)); + } + return value; + } + + private static Speaker toSpeaker(SpeakerDTO dto, Map timeslotMap) { + return Speaker.builder(dto.id(), dto.name()) + .unavailableTimeslots(timeslotsByIds(dto.unavailableTimeslotIds(), timeslotMap)) + .requiredTimeslotTags(new LinkedHashSet<>(dto.requiredTimeslotTags())) + .preferredTimeslotTags(new LinkedHashSet<>(dto.preferredTimeslotTags())) + .prohibitedTimeslotTags(new LinkedHashSet<>(dto.prohibitedTimeslotTags())) + .undesiredTimeslotTags(new LinkedHashSet<>(dto.undesiredTimeslotTags())) + .requiredRoomTags(new LinkedHashSet<>(dto.requiredRoomTags())) + .preferredRoomTags(new LinkedHashSet<>(dto.preferredRoomTags())) + .prohibitedRoomTags(new LinkedHashSet<>(dto.prohibitedRoomTags())) + .undesiredRoomTags(new LinkedHashSet<>(dto.undesiredRoomTags())) + .build(); + } + + private static Talk toTalk(TalkDTO dto, Map talkTypeMap, Map speakerMap, + Map timeslotMap, Map roomMap) { + List speakers = dto.speakerIds().stream() + .map(speakerId -> require(speakerMap, speakerId, "speaker")) + .collect(Collectors.toList()); + Talk talk = Talk.builder(dto.code()) + .title(dto.title()) + .talkType(require(talkTypeMap, dto.talkTypeName(), "talk type")) + .speakers(speakers) + .themeTrackTags(new LinkedHashSet<>(dto.themeTrackTags())) + .sectorTags(new LinkedHashSet<>(dto.sectorTags())) + .audienceTypes(new LinkedHashSet<>(dto.audienceTypes())) + .audienceLevel(dto.audienceLevel()) + .contentTags(new LinkedHashSet<>(dto.contentTags())) + .language(dto.language()) + .requiredTimeslotTags(new LinkedHashSet<>(dto.requiredTimeslotTags())) + .preferredTimeslotTags(new LinkedHashSet<>(dto.preferredTimeslotTags())) + .prohibitedTimeslotTags(new LinkedHashSet<>(dto.prohibitedTimeslotTags())) + .undesiredTimeslotTags(new LinkedHashSet<>(dto.undesiredTimeslotTags())) + .requiredRoomTags(new LinkedHashSet<>(dto.requiredRoomTags())) + .preferredRoomTags(new LinkedHashSet<>(dto.preferredRoomTags())) + .prohibitedRoomTags(new LinkedHashSet<>(dto.prohibitedRoomTags())) + .undesiredRoomTags(new LinkedHashSet<>(dto.undesiredRoomTags())) + .mutuallyExclusiveTalksTags(new LinkedHashSet<>(dto.mutuallyExclusiveTalksTags())) + .favoriteCount(dto.favoriteCount()) + .crowdControlRisk(dto.crowdControlRisk()) + .build(); + if (dto.timeslotId() != null) { + talk.setTimeslot(require(timeslotMap, dto.timeslotId(), "timeslot")); + } + if (dto.roomId() != null) { + talk.setRoom(require(roomMap, dto.roomId(), "room")); + } + 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(code -> require(talkMap, code, "prerequisite talk")) + .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..295c41c778 --- /dev/null +++ b/use-cases/conference-scheduling/src/main/java/org/acme/conferencescheduling/service/ConferenceScheduleValidator.java @@ -0,0 +1,122 @@ +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()))); + } + // A blank talk type name is just as invalid as an unknown one: the solver model requires a talk type. + 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..d5af557cfd 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,40 @@ 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 ai.timefold.solver.core.api.score.stream.Joiners; - -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 ai.timefold.solver.service.definition.api.description.ConstraintInfo; 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.containing; +import static ai.timefold.solver.core.api.score.stream.Joiners.containingAnyOf; 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.ConferenceConstraintProperties; +import org.acme.conferencescheduling.domain.Speaker; +import org.acme.conferencescheduling.domain.Talk; +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 +45,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,83 +101,113 @@ 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) + .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), + containing(Talk::getSpeakers, speaker -> speaker), containedIn(Talk::getTimeslot, Speaker::getUnavailableTimeslots)) - .penalize(HardSoftScore.ofHard(100), (talk, speaker) -> talk.getDurationInMinutes()) + .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())) .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)) + containing((talk1, talk2) -> talk1.getSpeakers(), speaker -> speaker), + containing((talk1, talk2) -> talk2.getSpeakers(), speaker -> speaker)) + .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) + .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)) + 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)) + return factory.forEachUniquePair(Talk.class, containingAnyOf(Talk::getSpeakers)) .ifExists(ConferenceConstraintProperties.class, 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) @@ -218,17 +217,22 @@ Constraint crowdControl(ConstraintFactory factory) { .filter((talk1, talk2) -> !Objects.equals(talk1, talk2)) .groupBy((talk1, talk2) -> talk1, 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 +240,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 +259,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 +329,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) * + 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)) + 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( @@ -360,63 +403,83 @@ Constraint themeTrackRoomStability(ConstraintFactory factory) { .filter(t -> talk2.getThemeTrackTags().contains(t)) .collect(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) + 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) + 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) + containingAnyOf(Talk::getThemeTrackTags), + 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)) .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(), talk2 -> talk2.getTimeslot().getStartDateTime()), - Joiners.containingAnyOf(Talk::getContentTags)) - .penalize(HardSoftScore.ofSoft(10), (talk1, talk2) -> talk1.overlappingContentCount(talk2) + containingAnyOf(Talk::getContentTags)) + .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." @@ -424,36 +487,48 @@ Constraint contentAudienceLevelFlowViolation(ConstraintFactory factory) { String.valueOf(talk2.getAudienceLevel()), talk.getContentTags().stream().filter(c -> talk2.getContentTags().contains(c)) .collect(joining(", "))))) - .asConstraint(CONTENT_AUDIENCE_LEVEL_FLOW_VIOLATION); + .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) + 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)) .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( @@ -464,42 +539,55 @@ Constraint sameDayTalks(ConstraintFactory factory) { .collect(joining(", ")), talk.getThemeTrackTags().stream().filter(t -> talk2.getThemeTrackTags().contains(t)) .collect(joining(", "))))) - .asConstraint(SAME_DAY_TALKS); + .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) + .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 +595,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 +665,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)) .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()), + min((Speaker speaker, Talk talk) -> talk, + talk -> talk.getTimeslot().getStartDateTime()), + 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 +721,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..361e73464f 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($(`