From ef2ce0727c381977cbc32013e080592ef3bf8c99 Mon Sep 17 00:00:00 2001 From: popmonkey Date: Sat, 10 Jan 2026 10:05:29 -0800 Subject: [PATCH] maxBusyHours constrains how much work a member is allowed to do before being required to take a rest --- CMakeLists.txt | 1 + README.md | 2 + TOOLS.md | 4 + include/jres_solver/jres_solver.hpp | 2 + src/constraints/max_busy_time.cpp | 97 ++++++++++++ src/constraints/max_busy_time.hpp | 21 +++ src/jres_internal_types.cpp | 1 + src/jres_internal_types.hpp | 1 + src/jres_json_converter.cpp | 1 + src/jres_standard_solver.cpp | 5 + test/CMakeLists.txt | 3 + test/test_max_busy.cpp | 234 ++++++++++++++++++++++++++++ test/test_max_busy_defaults.cpp | 64 ++++++++ test/test_max_busy_mixed_roles.cpp | 76 +++++++++ 14 files changed, 512 insertions(+) create mode 100644 src/constraints/max_busy_time.cpp create mode 100644 src/constraints/max_busy_time.hpp create mode 100644 test/test_max_busy.cpp create mode 100644 test/test_max_busy_defaults.cpp create mode 100644 test/test_max_busy_mixed_roles.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ec3c702..5e40631 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -164,6 +164,7 @@ add_library(jres_solver_lib src/analysis/capacity_analyzer.cpp src/constraints/balancing.cpp src/constraints/minimum_rest.cpp + src/constraints/max_busy_time.cpp ) set_target_properties(jres_solver_lib PROPERTIES OUTPUT_NAME "jres_solver") diff --git a/README.md b/README.md index b93c68f..ec51e72 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ The C-API uses the following structs to pass data to and from the solver. | :--- | :--- | :--- | | `consecutiveStints` | `int` | Hard constraint: Required number of consecutive stints a driver must perform (block size). | | `minimumRestHours` | `int` | Hard constraint: Minimum contiguous rest time required once per race. | +| `maxBusyHours` | `int` | Hard constraint: Maximum total time (driving + spotting) a member can work before a required rest. | | `firstStintDriver` | `const char*` | Hard constraint: The name of the team member who must drive the first stint. | | `teamMembers` | `JresTeamMember*` | A pointer to an array of team members. | | `teamMembers_len` | `int` | The number of team members. | @@ -182,6 +183,7 @@ The `raceDataJson` string passed to `jres_input_from_json` must strictly follow | :--- | :--- | :--- | :--- | | `consecutiveStints` | Integer | No (Default `1`) | Hard constraint: Required number of consecutive stints a driver must perform (block size). | | `minimumRestHours` | Integer | No (Default `0`) | Hard constraint: Minimum contiguous rest time required once per race. | +| `maxBusyHours` | Integer | No (Default `8`) | Hard constraint: Maximum total time (driving + spotting) a member can work before a required rest. | | `firstStintDriver` | String | No | Hard constraint: The name of the team member who must drive the first stint. | | `teamMembers` | Array | Yes | List of drivers and spotters (see below). | | `availability` | Object | Yes | Map of availability constraints (see below). | diff --git a/TOOLS.md b/TOOLS.md index 01a2e14..7683986 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -58,6 +58,10 @@ jres_solver.exe [options] | `-d` | `--diagnose` | Run in **Diagnostic Mode** to explain why a schedule is infeasible. | `false` | | `-h` | `--help` | Print usage instructions. | | +> [!TIP] +> **Constraint Configuration:** +> While some options are available as CLI flags, core schedule constraints such as `maxBusyHours`, `minimumRestHours`, and `consecutiveStints` are defined strictly within the **Input JSON** file. See [README](./README.md#input-json-specification) for details. + ### Spotter Modes #### Integrated Mode (`JRES_SPOTTER_MODE_INTEGRATED`) diff --git a/include/jres_solver/jres_solver.hpp b/include/jres_solver/jres_solver.hpp index 8a0510b..c68955b 100644 --- a/include/jres_solver/jres_solver.hpp +++ b/include/jres_solver/jres_solver.hpp @@ -122,6 +122,8 @@ struct JresSolverInput { int consecutiveStints; /** @brief Minimum rest time in hours required after a shift. */ int minimumRestHours; + /** @brief Maximum busy time in hours (driving or spotting) before a rest is required. */ + int maxBusyHours; /** @brief The name of the team member who must drive the first stint. */ const char* firstStintDriver; /** @brief A pointer to an array of team members. */ diff --git a/src/constraints/max_busy_time.cpp b/src/constraints/max_busy_time.cpp new file mode 100644 index 0000000..087254e --- /dev/null +++ b/src/constraints/max_busy_time.cpp @@ -0,0 +1,97 @@ +#include "max_busy_time.hpp" +#include "Highs.h" +#include +#include + +namespace jres::constraints { + +void apply_max_busy_time_constraints( + Highs &highs, + const jres::internal::SolverInput& input, + const std::vector &participants, + const std::map, int>& driverVars, + const std::map, int>& spotterVars, + bool enforceCombined, + std::map& slackInfo, + const std::vector* fixedSchedule +) +{ + using namespace jres::internal; + + if (input.maxBusyHours <= 0) return; + + // Calculate durations + std::vector stintDurations; + stintDurations.reserve(input.stints.size()); + for (const auto& stint : input.stints) { + auto s = TimeHelpers::stringToTimePoint(stint.startTime); + auto e = TimeHelpers::stringToTimePoint(stint.endTime); + long long ms = std::chrono::duration_cast(e - s).count(); + stintDurations.push_back(static_cast(ms) / 3600000.0); + } + + for (const auto &p : participants) + { + for (size_t s = 0; s < input.stints.size(); ++s) { + double currentDuration = 0.0; + for (size_t e = s; e < input.stints.size(); ++e) { + currentDuration += stintDurations[e]; + + if (currentDuration > input.maxBusyHours) { + // Violation if assigned to ALL stints in [s, e] + // Constraint: Sum(coeff * x[k]) <= (e - s) + + std::map coefficients; + int fixedAssignments = 0; + + for (size_t k = s; k <= e; ++k) { + // Driver + if (fixedSchedule) { + // Sequential Mode: Check fixed schedule + if (k < fixedSchedule->size() && (*fixedSchedule)[k].driver == p.name) { + fixedAssignments++; + } + } else { + // Integrated Mode: Add driver var to constraint + if (driverVars.count({p.name, (int)k})) { + coefficients[driverVars.at({p.name, (int)k})] += 1.0; + } + } + + // Spotter + if (spotterVars.count({p.name, (int)k})) { + if (fixedSchedule || enforceCombined) { + coefficients[spotterVars.at({p.name, (int)k})] += 1.0; + } + } + } + + std::vector idx; + std::vector val; + idx.reserve(coefficients.size()); + val.reserve(coefficients.size()); + + for(const auto& [col, coeff] : coefficients) { + idx.push_back(col); + val.push_back(coeff); + } + + double maxAssignments = static_cast(e - s); + + if (fixedSchedule) { + // Adjust RHS + maxAssignments -= fixedAssignments; + } + + if (!idx.empty() || maxAssignments < 0) { + highs.addRow(-kHighsInf, maxAssignments, (int)idx.size(), idx.data(), val.data()); + } + + break; + } + } + } + } +} + +} // namespace jres::constraints \ No newline at end of file diff --git a/src/constraints/max_busy_time.hpp b/src/constraints/max_busy_time.hpp new file mode 100644 index 0000000..9ef1ece --- /dev/null +++ b/src/constraints/max_busy_time.hpp @@ -0,0 +1,21 @@ +#pragma once +#include "../jres_internal_types.hpp" +#include +#include + +class Highs; + +namespace jres::constraints { + +void apply_max_busy_time_constraints( + Highs &highs, + const jres::internal::SolverInput& input, + const std::vector &participants, + const std::map, int>& driverVars, + const std::map, int>& spotterVars, + bool enforceCombined, + std::map& slackInfo, + const std::vector* fixedSchedule = nullptr +); + +} diff --git a/src/jres_internal_types.cpp b/src/jres_internal_types.cpp index f167e14..92a0eed 100644 --- a/src/jres_internal_types.cpp +++ b/src/jres_internal_types.cpp @@ -67,6 +67,7 @@ SolverInput from_c_input(const JresSolverInput* c_input) { input.consecutiveStints = c_input->consecutiveStints; input.minimumRestHours = c_input->minimumRestHours; + input.maxBusyHours = c_input->maxBusyHours; if (c_input->firstStintDriver) { input.firstStintDriver = c_input->firstStintDriver; } diff --git a/src/jres_internal_types.hpp b/src/jres_internal_types.hpp index 77d30ba..c0430b4 100644 --- a/src/jres_internal_types.hpp +++ b/src/jres_internal_types.hpp @@ -47,6 +47,7 @@ struct SolverInput { int consecutiveStints = 1; int minimumRestHours = 0; + int maxBusyHours = 8; std::string firstStintDriver; std::vector teamMembers; std::map> availability; diff --git a/src/jres_json_converter.cpp b/src/jres_json_converter.cpp index 81d5ee0..b431f05 100644 --- a/src/jres_json_converter.cpp +++ b/src/jres_json_converter.cpp @@ -76,6 +76,7 @@ JRES_SOLVER_API JresSolverInput* jres_input_from_json(const char* jsonData) { // Global Constraints input->consecutiveStints = j.value("consecutiveStints", 1); input->minimumRestHours = j.value("minimumRestHours", 0); + input->maxBusyHours = j.value("maxBusyHours", 8); if (j.contains("firstStintDriver") && !j["firstStintDriver"].is_null()) { input->firstStintDriver = allocate_and_copy(j["firstStintDriver"]); diff --git a/src/jres_standard_solver.cpp b/src/jres_standard_solver.cpp index 448c2e1..fa47091 100644 --- a/src/jres_standard_solver.cpp +++ b/src/jres_standard_solver.cpp @@ -7,6 +7,7 @@ #include "analysis/capacity_analyzer.hpp" #include "constraints/balancing.hpp" #include "constraints/minimum_rest.hpp" +#include "constraints/max_busy_time.hpp" #include #include #include @@ -322,6 +323,7 @@ jres::internal::SolverOutput JresStandardSolver::solve() // Apply Rest Constraints bool enforceCombinedRest = (m_options.spotterMode == JRES_SPOTTER_MODE_INTEGRATED); jres::constraints::apply_minimum_rest_constraints(*m_highs, m_input, m_input.teamMembers, m_driverWorkVars, m_spotterWorkVars, enforceCombinedRest, m_slackInfo); + jres::constraints::apply_max_busy_time_constraints(*m_highs, m_input, m_input.teamMembers, m_driverWorkVars, m_spotterWorkVars, enforceCombinedRest, m_slackInfo); // --- Solve Main Model (Drivers + Spotters if Integrated) --- @@ -462,6 +464,9 @@ jres::internal::SolverOutput JresStandardSolver::solve() } } + // Apply Max Busy Constraints (taking fixed drivers into account) + jres::constraints::apply_max_busy_time_constraints(spotterSolver, m_input, m_input.teamMembers, m_driverWorkVars, m_spotterWorkVars, true, m_slackInfo, &output.schedule); + // Incentivize Spotting Adjacent to Driving (Proximity & Role Coupling) // Calculate Rewards per Block Var std::map spotterRewards; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 0880f5f..8bd67b3 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -36,6 +36,9 @@ add_executable(solver_tests test_rotation_beat.cpp test_spotter_balancing.cpp test_first_stint.cpp + test_max_busy.cpp + test_max_busy_mixed_roles.cpp + test_max_busy_defaults.cpp ) add_dependencies(solver_tests jres_solver_lib) diff --git a/test/test_max_busy.cpp b/test/test_max_busy.cpp new file mode 100644 index 0000000..d4000b1 --- /dev/null +++ b/test/test_max_busy.cpp @@ -0,0 +1,234 @@ +#include "gtest/gtest.h" +#include "jres_solver/jres_solver.hpp" +#include "jres_internal_types.hpp" +#include +#include + +TEST(MaxBusyTest, MaxBusyHoursInfeasible) { + // 3 stints of 1 hour. Total 3h. + // MaxBusy = 2h. + // Driver cannot do S1, S2, S3 continuously. + + std::string json_str = R"({ + "maxBusyHours": 2, + "teamMembers": [ + { "name": "D1", "isDriver": true } + ], + "availability": { + "D1": {} + }, + "stints": [ + { "id": 1, "startTime": "2026-01-01T00:00:00Z", "endTime": "2026-01-01T01:00:00Z" }, + { "id": 2, "startTime": "2026-01-01T01:00:00Z", "endTime": "2026-01-01T02:00:00Z" }, + { "id": 3, "startTime": "2026-01-01T02:00:00Z", "endTime": "2026-01-01T03:00:00Z" } + ] + })"; + + JresSolverOptions options = {}; + options.timeLimit = 10; + options.spotterMode = JRES_SPOTTER_MODE_NONE; + + JresSolverInput* input = jres_input_from_json(json_str.c_str()); + ASSERT_NE(input, nullptr); + + JresSolverOutput* output = solve_race_schedule(input, &options); + + // With only 1 driver, and they can't do all 3 stints, this should be infeasible. + bool feasible = (output->schedule_len == 3); + if (output->diagnosis_len > 0) { + std::string msg = output->diagnosis[0]; + if (msg.find("infeasible") != std::string::npos) feasible = false; + } + + // It might output partial schedule or empty schedule + if (output->schedule_len < 3) feasible = false; + + ASSERT_FALSE(feasible); + + free_jres_solver_output(output); + free_jres_solver_input(input); +} + +TEST(MaxBusyTest, MaxBusyHoursFeasible) { + // 3 stints of 1 hour. + // MaxBusy = 2h. + // 2 Drivers. + // D1: S1, S2. D2: S3. Valid. + + std::string json_str = R"({ + "maxBusyHours": 2, + "teamMembers": [ + { "name": "D1", "isDriver": true }, + { "name": "D2", "isDriver": true } + ], + "availability": { + "D1": {}, "D2": {} + }, + "stints": [ + { "id": 1, "startTime": "2026-01-01T00:00:00Z", "endTime": "2026-01-01T01:00:00Z" }, + { "id": 2, "startTime": "2026-01-01T01:00:00Z", "endTime": "2026-01-01T02:00:00Z" }, + { "id": 3, "startTime": "2026-01-01T02:00:00Z", "endTime": "2026-01-01T03:00:00Z" } + ] + })"; + + JresSolverOptions options = {}; + options.spotterMode = JRES_SPOTTER_MODE_NONE; + + JresSolverInput* input = jres_input_from_json(json_str.c_str()); + JresSolverOutput* output = solve_race_schedule(input, &options); + + ASSERT_NE(output, nullptr); + ASSERT_EQ(output->schedule_len, 3); + + // Verify no one worked > 2h + // Simple check: S1+S2+S3 same driver? + std::string d1 = output->schedule[0].driver; + std::string d2 = output->schedule[1].driver; + std::string d3 = output->schedule[2].driver; + + ASSERT_FALSE(d1 == d2 && d2 == d3); // If all equal, they worked 3h + + free_jres_solver_output(output); + free_jres_solver_input(input); +} + +TEST(MaxBusyTest, MaxBusySpotterIntegrated) { + // 3 stints of 1 hour. Total 3h. + // MaxBusy = 2h. + // 1 Driver (can drive all, because we only care about spotter here) + // 1 Spotter (can spot) + // We need spotters for all stints. + // Spotter cannot do S1, S2, S3 continuously. + + std::string json_str = R"({ + "maxBusyHours": 2, + "teamMembers": [ + { "name": "D1", "isDriver": true, "isSpotter": false }, + { "name": "D2", "isDriver": true, "isSpotter": false }, + { "name": "S1", "isDriver": false, "isSpotter": true } + ], + "availability": { + "D1": {}, "D2": {}, "S1": {} + }, + "stints": [ + { "id": 1, "startTime": "2026-01-01T00:00:00Z", "endTime": "2026-01-01T01:00:00Z" }, + { "id": 2, "startTime": "2026-01-01T01:00:00Z", "endTime": "2026-01-01T02:00:00Z" }, + { "id": 3, "startTime": "2026-01-01T02:00:00Z", "endTime": "2026-01-01T03:00:00Z" } + ] + })"; + + JresSolverOptions options = {}; + options.timeLimit = 10; + options.spotterMode = JRES_SPOTTER_MODE_INTEGRATED; + options.allowNoSpotter = false; + + JresSolverInput* input = jres_input_from_json(json_str.c_str()); + ASSERT_NE(input, nullptr); + + JresSolverOutput* output = solve_race_schedule(input, &options); + + bool feasible = (output->schedule_len == 3); + if (output->diagnosis_len > 0) { + std::string msg = output->diagnosis[0]; + if (msg.find("infeasible") != std::string::npos) feasible = false; + } + + ASSERT_FALSE(feasible); + + free_jres_solver_output(output); + free_jres_solver_input(input); +} + +TEST(MaxBusyTest, MaxBusySequential) { + // 8 stints of 1 hour. MaxBusy = 6. + // Kyle (D/S). + // S2 (S). + // Sequential mode. + + // Kyle drives 0-3 (4h) [Preferred]. + // S2 spots 0-3 (4h) [Preferred]. + // Kyle spots 4-7 (4h) [Only option]. + // S2 unavailable 4-7. + + // Combined Kyle = 8h. > 6h. + + std::string json_str = R"({ + "maxBusyHours": 6, + "teamMembers": [ + { "name": "Kyle", "isDriver": true, "isSpotter": true }, + { "name": "D2", "isDriver": true, "isSpotter": false }, + { "name": "S2", "isDriver": false, "isSpotter": true } + ], + "availability": { + "Kyle": { + "2026-01-01T00:00:00.000Z":"Preferred", "2026-01-01T01:00:00.000Z":"Preferred", + "2026-01-01T02:00:00.000Z":"Preferred", "2026-01-01T03:00:00.000Z":"Preferred" + }, + "D2": {}, + "S2": { + "2026-01-01T00:00:00.000Z":"Preferred", "2026-01-01T01:00:00.000Z":"Preferred", + "2026-01-01T02:00:00.000Z":"Preferred", "2026-01-01T03:00:00.000Z":"Preferred", + "2026-01-01T04:00:00.000Z":"Unavailable", "2026-01-01T05:00:00.000Z":"Unavailable", + "2026-01-01T06:00:00.000Z":"Unavailable", "2026-01-01T07:00:00.000Z":"Unavailable" + } + }, + "stints": [ + { "id": 0, "startTime": "2026-01-01T00:00:00Z", "endTime": "2026-01-01T01:00:00Z" }, + { "id": 1, "startTime": "2026-01-01T01:00:00Z", "endTime": "2026-01-01T02:00:00Z" }, + { "id": 2, "startTime": "2026-01-01T02:00:00Z", "endTime": "2026-01-01T03:00:00Z" }, + { "id": 3, "startTime": "2026-01-01T03:00:00Z", "endTime": "2026-01-01T04:00:00Z" }, + { "id": 4, "startTime": "2026-01-01T04:00:00Z", "endTime": "2026-01-01T05:00:00Z" }, + { "id": 5, "startTime": "2026-01-01T05:00:00Z", "endTime": "2026-01-01T06:00:00Z" }, + { "id": 6, "startTime": "2026-01-01T06:00:00Z", "endTime": "2026-01-01T07:00:00Z" }, + { "id": 7, "startTime": "2026-01-01T07:00:00Z", "endTime": "2026-01-01T08:00:00Z" } + ] + })"; + + // We prefer Kyle for first 4 stints to encourage him driving them. + // D2 can drive rest. + // Kyle is ONLY spotter. + // If Kyle drives 0-3 (4h). + // He must spot 4-7 (4h)? + // Or spot 0-3? (Cannot spot if driving). + // Spot 4-7. + // Total 8h. > 6h. + + JresSolverOptions options = {}; + options.timeLimit = 10; + options.spotterMode = JRES_SPOTTER_MODE_SEQUENTIAL; + options.allowNoSpotter = false; + + JresSolverInput* input = jres_input_from_json(json_str.c_str()); + ASSERT_NE(input, nullptr); + + JresSolverOutput* output = solve_race_schedule(input, &options); + + bool feasible = (output->schedule_len == 8); + // If output->diagnosis says infeasible spotter, then feasible=false. + if (output->diagnosis_len > 0) { + // Check if spotter assignment failed + std::string msg = output->diagnosis[0]; + if (msg.find("Spotter assignment infeasible") != std::string::npos) feasible = false; + } + + // If logic is MISSING, it will return a schedule where Kyle does 8h. + if (feasible) { + // Verify Kyle's workload + int kyleCount = 0; + for(int i=0; i<8; ++i) { + if (std::string(output->schedule[i].driver) == "Kyle") kyleCount++; + if (std::string(output->schedule[i].spotter) == "Kyle") kyleCount++; + } + if (kyleCount > 6) { + // This confirms the bug + std::cout << "Kyle worked " << kyleCount << " hours (Expected failure > 6)" << std::endl; + // We EXPECT this to fail if bug is present. + // Assert FALSE to signal "Yes, we reproduced the bug / we want to ensure it is fixed". + // Since we are writing the test to ensure it IS fixed: + FAIL() << "Sequential solver allowed maxBusy violation."; + } + } + + free_jres_solver_output(output); + free_jres_solver_input(input); +} \ No newline at end of file diff --git a/test/test_max_busy_defaults.cpp b/test/test_max_busy_defaults.cpp new file mode 100644 index 0000000..0de5f0f --- /dev/null +++ b/test/test_max_busy_defaults.cpp @@ -0,0 +1,64 @@ +#include "gtest/gtest.h" +#include "jres_solver/jres_solver.hpp" +#include "jres_internal_types.hpp" +#include +#include +#include + +TEST(MaxBusyDefaults, DefaultValueCheck) { + // 10 Stints of 1 hour. Total 10 hours. + // Default MaxBusy = 8 hours. + // Kyle (D/S). + // Kyle should NOT be able to work all 10 hours. + + // We omit maxBusyHours from JSON. + std::string json_str = R"({ + "teamMembers": [ + { "name": "Kyle", "isDriver": true, "isSpotter": true } + ], + "availability": { + "Kyle": {} + }, + "stints": [ + { "id": 1, "startTime": "2026-01-01T00:00:00Z", "endTime": "2026-01-01T01:00:00Z" }, + { "id": 2, "startTime": "2026-01-01T01:00:00Z", "endTime": "2026-01-01T02:00:00Z" }, + { "id": 3, "startTime": "2026-01-01T02:00:00Z", "endTime": "2026-01-01T03:00:00Z" }, + { "id": 4, "startTime": "2026-01-01T03:00:00Z", "endTime": "2026-01-01T04:00:00Z" }, + { "id": 5, "startTime": "2026-01-01T04:00:00Z", "endTime": "2026-01-01T05:00:00Z" }, + { "id": 6, "startTime": "2026-01-01T05:00:00Z", "endTime": "2026-01-01T06:00:00Z" }, + { "id": 7, "startTime": "2026-01-01T06:00:00Z", "endTime": "2026-01-01T07:00:00Z" }, + { "id": 8, "startTime": "2026-01-01T07:00:00Z", "endTime": "2026-01-01T08:00:00Z" }, + { "id": 9, "startTime": "2026-01-01T08:00:00Z", "endTime": "2026-01-01T09:00:00Z" }, + { "id": 10, "startTime": "2026-01-01T09:00:00Z", "endTime": "2026-01-01T10:00:00Z" } + ] + })"; + + JresSolverOptions options = {}; + options.timeLimit = 5; + options.spotterMode = JRES_SPOTTER_MODE_INTEGRATED; + options.allowNoSpotter = false; + + JresSolverInput* input = jres_input_from_json(json_str.c_str()); + ASSERT_NE(input, nullptr); + + // Verify manually that input->maxBusyHours is 8 (if we can inspect it? It's in the opaque struct) + // Actually we can inspect it because we have the struct definition in header. + // But JresSolverInput definition is in jres_solver.hpp which is included. + EXPECT_EQ(input->maxBusyHours, 8); + + JresSolverOutput* output = solve_race_schedule(input, &options); + + // Should be infeasible (Kyle cannot do 10h > 8h). + bool feasible = (output->schedule_len == 10); + if (output->diagnosis_len > 0) { + std::string msg = output->diagnosis[0]; + if (msg.find("infeasible") != std::string::npos) feasible = false; + } + + if (feasible) { + FAIL() << "Solver found schedule despite default maxBusyHours=8 violation."; + } + + free_jres_solver_output(output); + free_jres_solver_input(input); +} diff --git a/test/test_max_busy_mixed_roles.cpp b/test/test_max_busy_mixed_roles.cpp new file mode 100644 index 0000000..1e3f99c --- /dev/null +++ b/test/test_max_busy_mixed_roles.cpp @@ -0,0 +1,76 @@ +#include "gtest/gtest.h" +#include "jres_solver/jres_solver.hpp" +#include "jres_internal_types.hpp" +#include +#include + +TEST(MaxBusyMixedRoles, AlternatingWorkload) { + // 12 Stints of 1 hour. Total 12 hours. + // MaxBusy = 8 hours. + // Kyle (Driver/Spotter). + // We want to force him to work all stints to see if constraint catches it. + // To force him, we only provide him. + // If constraint works -> Infeasible. + // If constraint fails -> Schedule produced. + + // We set consecutiveStints = 2 to match user pattern roughly. + // Stint 0,1: Block 0. + // Stint 2,3: Block 1. + + std::string json_str = R"({ + "consecutiveStints": 2, + "maxBusyHours": 8, + "teamMembers": [ + { "name": "Kyle", "isDriver": true, "isSpotter": true } + ], + "availability": { + "Kyle": {} + }, + "stints": [ + { "id": 0, "startTime": "2026-01-01T00:00:00Z", "endTime": "2026-01-01T01:00:00Z" }, + { "id": 1, "startTime": "2026-01-01T01:00:00Z", "endTime": "2026-01-01T02:00:00Z" }, + { "id": 2, "startTime": "2026-01-01T02:00:00Z", "endTime": "2026-01-01T03:00:00Z" }, + { "id": 3, "startTime": "2026-01-01T03:00:00Z", "endTime": "2026-01-01T04:00:00Z" }, + { "id": 4, "startTime": "2026-01-01T04:00:00Z", "endTime": "2026-01-01T05:00:00Z" }, + { "id": 5, "startTime": "2026-01-01T05:00:00Z", "endTime": "2026-01-01T06:00:00Z" }, + { "id": 6, "startTime": "2026-01-01T06:00:00Z", "endTime": "2026-01-01T07:00:00Z" }, + { "id": 7, "startTime": "2026-01-01T07:00:00Z", "endTime": "2026-01-01T08:00:00Z" }, + { "id": 8, "startTime": "2026-01-01T08:00:00Z", "endTime": "2026-01-01T09:00:00Z" }, + { "id": 9, "startTime": "2026-01-01T09:00:00Z", "endTime": "2026-01-01T10:00:00Z" }, + { "id": 10, "startTime": "2026-01-01T10:00:00Z", "endTime": "2026-01-01T11:00:00Z" }, + { "id": 11, "startTime": "2026-01-01T11:00:00Z", "endTime": "2026-01-01T12:00:00Z" } + ] + })"; + + JresSolverOptions options = {}; + options.timeLimit = 10; + options.spotterMode = JRES_SPOTTER_MODE_INTEGRATED; + options.allowNoSpotter = false; + + JresSolverInput* input = jres_input_from_json(json_str.c_str()); + ASSERT_NE(input, nullptr); + + JresSolverOutput* output = solve_race_schedule(input, &options); + + // Check feasibility + bool feasible = (output->schedule_len == 12); + if (output->diagnosis_len > 0) { + std::string msg = output->diagnosis[0]; + if (msg.find("infeasible") != std::string::npos) feasible = false; + } + + // We EXPECT it to be infeasible because Kyle cannot work 12h straight with maxBusy=8. + // If feasible is true, the bug is reproduced. + if (feasible) { + // Double check he is actually assigned everything + // (He has to be, he is the only one) + std::cout << "Schedule produced (UNEXPECTED):" << std::endl; + for(int i=0; i<12; ++i) { + std::cout << i << ": " << output->schedule[i].driver << " / " << output->schedule[i].spotter << std::endl; + } + FAIL() << "Solver produced a schedule violating maxBusyHours with alternating roles."; + } + + free_jres_solver_output(output); + free_jres_solver_input(input); +}