From 3b09d78e4db7d32df9b03c026ce322d513476645 Mon Sep 17 00:00:00 2001 From: popmonkey Date: Fri, 9 Jan 2026 16:57:17 -0800 Subject: [PATCH 1/6] remove switching-penalty which is now redundant due to stint blocking --- README.md | 11 ---- TOOLS.md | 1 - cmd/solver/cli.cpp | 2 - include/jres_solver/jres_solver.hpp | 4 +- src/jres_json_converter.cpp | 1 - src/jres_standard_solver.cpp | 25 ---------- test/CMakeLists.txt | 1 - test/test_hard_consecutive_limit.cpp | 3 +- test/test_spotter_balancing.cpp | 2 - test/test_switching_penalty.cpp | 75 ---------------------------- 10 files changed, 2 insertions(+), 123 deletions(-) delete mode 100644 test/test_switching_penalty.cpp diff --git a/README.md b/README.md index 58cd552..24adaa7 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ The `jres_solver` tool supports several optimization parameters: * **General:** `-i` (Input), `-o` (Output), `-t` (Time Limit), `-s` (Spotter Mode) * **Advanced Weights:** - * `--switching-penalty`: Cost for driver swaps (positive disincentivizes switching) * `--role-coupling-weight`: Incentive for role coupling (positive incentivizes coupling) * `--rotation-beat-weight`: Penalty for fairness deviation (positive incentivizes adherence) @@ -98,7 +97,6 @@ These structs are used to represent the availability of team members. | `spotterMode` | `JresSpotterMode` | Type of spotter scheduling to use (`NONE`, `INTEGRATED`, `SEQUENTIAL`). | | `allowNoSpotter` | `bool` | Allow stints to have no spotter assigned. | | `optimalityGap` | `double` | Solver stops when the gap to optimal is less than this (e.g., `0.2`). | -| `switchingPenalty`| `double` | Penalty applied when switching drivers between stints (default: `0.0`). | | `roleCouplingWeight`| `double` | Weight for coupling driver and spotter roles (default: `0.0`). | | `rotationBeatWeight`| `double` | Weight for adhering to a rotation beat or fairness metric (default: `0.0`). | @@ -123,7 +121,6 @@ options.timeLimit = 5; options.spotterMode = JRES_SPOTTER_MODE_INTEGRATED; options.allowNoSpotter = false; options.optimalityGap = 0.2; -options.switchingPenalty = 10.0; // Encourage longer driver shifts // Create input struct from JSON JresSolverInput* input = jres_input_from_json(raceDataJson); @@ -165,14 +162,6 @@ Mixed Integer Programming problems like race scheduling are NP-hard. The solver The solver prioritizes hard constraints (rest times, fuel, availability) first. The optimality gap only affects soft preferences like minimizing consecutive stints. A 20% gap on these preferences is imperceptible in real-world use. -#### Switching Penalty - -The `switchingPenalty` option adds a cost to the optimization objective every time the driver changes between two consecutive stints. - -* **Goal**: To encourage the solver to keep the same driver in the car for multiple stints (e.g., doing a "double stint"), even if it's not strictly required by the `consecutiveStints` constraint. -* **Relationship to `consecutiveStints`**: `consecutiveStints` is a **hard constraint** (the solver *must* schedule blocks of this size). The `switchingPenalty` is a **soft incentive** that applies at the boundaries of these blocks to discourage swapping drivers even when a block ends. -* **Recommended Value**: Start with `10.0`. If the solver still switches drivers too often for your preference, increase it. If it starts violating preferred availability slots just to avoid a switch, decrease it. - ----- ### JSON Helper Functions diff --git a/TOOLS.md b/TOOLS.md index 14e966e..5d807cd 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -53,7 +53,6 @@ jres_solver.exe [options] | `-s` | `--spotter-mode` | Strategy for assigning spotters. Options: `none`, `integrated`, `sequential`. | `none` | | | `--allow-no-spotter` | Allow specific stints to have no spotter assigned (if spotter mode is active). | `false` | | `-g` | `--optimality-gap` | Stop solver when the solution is within this gap of perfection (e.g., `0.2` for 20%). | `0.2` | -| | `--switching-penalty`| Penalty cost for switching drivers (positive disincentivizes switching). | `0.0` | | | `--role-coupling-weight`| Reward weight for coupling roles (positive incentivizes driver->spotter). | `0.0` | | | `--rotation-beat-weight`| Penalty weight for rotation deviation (positive incentivizes adherence). | `0.0` | | `-d` | `--diagnose` | Run in **Diagnostic Mode** to explain why a schedule is infeasible. | `false` | diff --git a/cmd/solver/cli.cpp b/cmd/solver/cli.cpp index 1b70a2c..2e60fe5 100644 --- a/cmd/solver/cli.cpp +++ b/cmd/solver/cli.cpp @@ -43,7 +43,6 @@ int main(int argc, char **argv) ("g,optimality-gap", "Solver stops when the gap to optimal is less than this (e.g., 0.2 for 20%).", cxxopts::value()->default_value("0.2")); options.add_options("Advanced Optimization") - ("switching-penalty", "Penalty cost for switching drivers (positive disincentivizes switching).", cxxopts::value()->default_value("0.0")) ("role-coupling-weight", "Reward weight for coupling roles (positive incentivizes driver->spotter).", cxxopts::value()->default_value("0.0")) ("rotation-beat-weight", "Penalty weight for rotation deviation (positive incentivizes adherence).", cxxopts::value()->default_value("0.0")); @@ -121,7 +120,6 @@ int main(int argc, char **argv) solverOptions.allowNoSpotter = result["allow-no-spotter"].as(); solverOptions.optimalityGap = result["optimality-gap"].as(); - solverOptions.switchingPenalty = result["switching-penalty"].as(); solverOptions.roleCouplingWeight = result["role-coupling-weight"].as(); solverOptions.rotationBeatWeight = result["rotation-beat-weight"].as(); diff --git a/include/jres_solver/jres_solver.hpp b/include/jres_solver/jres_solver.hpp index 0125651..b4dd996 100644 --- a/include/jres_solver/jres_solver.hpp +++ b/include/jres_solver/jres_solver.hpp @@ -60,9 +60,7 @@ struct JresSolverOptions { bool allowNoSpotter; /** @brief The solver will terminate when the gap between the primal and dual objective bound is less than this value. */ double optimalityGap; - /** @brief Penalty applied when switching drivers between stints (default: 0.0). */ - double switchingPenalty; - /** @brief Weight for coupling driver and spotter roles (e.g. proximity or same-person) (default: 0.0). */ + /** @brief Weight for coupling driver and spotter roles (integrated mode only). */ double roleCouplingWeight; /** @brief Weight for adhering to a rotation beat or fairness metric (default: 0.0). */ double rotationBeatWeight; diff --git a/src/jres_json_converter.cpp b/src/jres_json_converter.cpp index b7841f1..31d529a 100644 --- a/src/jres_json_converter.cpp +++ b/src/jres_json_converter.cpp @@ -182,7 +182,6 @@ JRES_SOLVER_API char* jres_output_to_json(const JresSolverOutput* output) { options_json["spotterMode"] = to_string(output->options->spotterMode); options_json["allowNoSpotter"] = output->options->allowNoSpotter; options_json["optimalityGap"] = output->options->optimalityGap; - options_json["switchingPenalty"] = output->options->switchingPenalty; options_json["roleCouplingWeight"] = output->options->roleCouplingWeight; options_json["rotationBeatWeight"] = output->options->rotationBeatWeight; j["options"] = options_json; diff --git a/src/jres_standard_solver.cpp b/src/jres_standard_solver.cpp index e1e0515..72ffc9e 100644 --- a/src/jres_standard_solver.cpp +++ b/src/jres_standard_solver.cpp @@ -225,31 +225,6 @@ jres::internal::SolverOutput JresStandardSolver::solve() m_highs->addRow(1.0, 1.0, (int)indices.size(), indices.data(), values.data()); } - // --- Switching Penalty --- - if (m_options.switchingPenalty > 0.0) { - for (size_t s = 1; s < m_input.stints.size(); ++s) { - int switchVar = m_highs->getNumCol(); - m_highs->addVar(0.0, 1.0); - m_highs->changeColIntegrality(switchVar, HighsVarType::kInteger); - m_highs->changeColCost(switchVar, m_options.switchingPenalty); - - // Constraint: switchVar >= driver_s - driver_{s-1} for each driver - // switchVar - driver_s + driver_{s-1} >= 0 - for (const auto& p : m_driverPool) { - if (m_driverWorkVars.count({p.name, (int)s}) && m_driverWorkVars.count({p.name, (int)s - 1})) { - int var_s = m_driverWorkVars.at({p.name, (int)s}); - int var_prev = m_driverWorkVars.at({p.name, (int)s - 1}); - - if (var_s != var_prev) { // Only add if different variables (blocks changed) - std::vector idx = {switchVar, var_s, var_prev}; - std::vector val = {1.0, -1.0, 1.0}; - m_highs->addRow(0.0, kHighsInf, 3, idx.data(), val.data()); - } - } - } - } - } - // --- Rotation Beat (Rhythm) Incentive --- if (m_options.rotationBeatWeight > 1e-6) { const size_t N = m_driverPool.size(); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 8401bf8..83f2905 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -32,7 +32,6 @@ add_executable(solver_tests test_optimization.cpp test_minimum_rest.cpp test_fair_share.cpp - test_switching_penalty.cpp test_role_coupling.cpp test_rotation_beat.cpp test_spotter_balancing.cpp diff --git a/test/test_hard_consecutive_limit.cpp b/test/test_hard_consecutive_limit.cpp index a9c3cae..778eca4 100644 --- a/test/test_hard_consecutive_limit.cpp +++ b/test/test_hard_consecutive_limit.cpp @@ -49,8 +49,7 @@ TEST(ConstraintTest, EnforceConsecutiveStintsHardLimit) { options.timeLimit = 10; options.spotterMode = JRES_SPOTTER_MODE_NONE; // Focus on drivers options.allowNoSpotter = true; - options.optimalityGap = 0.0; - options.switchingPenalty = 0.0; + options.optimalityGap = 0.0; JresSolverOutput* output = solve_race_schedule(input, &options); ASSERT_NE(output, nullptr); diff --git a/test/test_spotter_balancing.cpp b/test/test_spotter_balancing.cpp index 928c46a..73308cd 100644 --- a/test/test_spotter_balancing.cpp +++ b/test/test_spotter_balancing.cpp @@ -38,7 +38,6 @@ TEST(SpotterBalancingTest, IntegratedBalancing) { options.spotterMode = JRES_SPOTTER_MODE_INTEGRATED; options.allowNoSpotter = false; options.optimalityGap = 0.0; - options.switchingPenalty = 0.0; options.rotationBeatWeight = 0.0; JresSolverOutput* output = solve_race_schedule(input, &options); @@ -86,7 +85,6 @@ TEST(SpotterBalancingTest, SequentialBalancing) { options.spotterMode = JRES_SPOTTER_MODE_SEQUENTIAL; options.allowNoSpotter = false; options.optimalityGap = 0.0; - options.switchingPenalty = 0.0; options.rotationBeatWeight = 0.0; JresSolverOutput* output = solve_race_schedule(input, &options); diff --git a/test/test_switching_penalty.cpp b/test/test_switching_penalty.cpp deleted file mode 100644 index 11feecc..0000000 --- a/test/test_switching_penalty.cpp +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @file test/test_switching_penalty.cpp - * @brief Tests for driver switching penalty enforcement. - */ - -#include "gtest/gtest.h" -#include "jres_solver/jres_solver.hpp" -#include "nlohmann/json.hpp" -#include -#include -#include - -using json = nlohmann::json; - -TEST(SwitchingPenaltyTest, ForceMinimumSwitches) { - // Scenario: 3 Stints, 2 Drivers. - // Both Available for all. - // Fair Share forces both to drive at least 1 stint (total 3h, fair share ~0.375h). - // Without penalty, fairness balancing (kCostFairness) might favor Alternating (A, B, A) or (A, A, B). - // With HIGH Switching Penalty, we force (A, A, B) or (B, B, A) -> 1 Switch. - // (A, B, A) -> 2 Switches. - - json j; - j["success"] = true; - j["teamMembers"] = { - {{"name", "Driver A"}, {"isDriver", true}, {"isSpotter", false}, {"maxStints", 3}, {"minimumRestHours", 0}}, - {{"name", "Driver B"}, {"isDriver", true}, {"isSpotter", false}, {"maxStints", 3}, {"minimumRestHours", 0}} - }; - j["stints"] = { - {{"id", 1}, {"startTime", "2026-01-17T00:00:00.000Z"}, {"endTime", "2026-01-17T01:00:00.000Z"}}, - {{"id", 2}, {"startTime", "2026-01-17T01:00:00.000Z"}, {"endTime", "2026-01-17T02:00:00.000Z"}}, - {{"id", 3}, {"startTime", "2026-01-17T02:00:00.000Z"}, {"endTime", "2026-01-17T03:00:00.000Z"}} - }; - j["availability"] = json::object(); // All available - j["firstStintDriver"] = nullptr; - - JresSolverOptions options = {}; - options.timeLimit = 5; - options.spotterMode = JRES_SPOTTER_MODE_NONE; - options.allowNoSpotter = true; - options.optimalityGap = 0.0; - options.switchingPenalty = 1000.0; // High penalty - - std::string json_str = j.dump(); - JresSolverInput* input = jres_input_from_json(json_str.c_str()); - ASSERT_NE(input, nullptr); - - JresSolverOutput* output = solve_race_schedule(input, &options); - ASSERT_NE(output, nullptr); - ASSERT_EQ(output->schedule_len, 3); - - // Calculate switches - int switches = 0; - for (int i = 1; i < output->schedule_len; ++i) { - if (std::string(output->schedule[i].driver) != std::string(output->schedule[i-1].driver)) { - switches++; - } - } - - // Since Fair Share forces both to drive, min switches = 1 (e.g. AAB). - // However, with strict consecutiveStints=1 (default), AAB is invalid (A cannot drive 2 consecutive). - // So valid schedule is ABA (2 switches). - // If penalty works, we should not see A-B-A (2 switches) -> Wait, ABA IS the minimum now. - EXPECT_EQ(switches, 2) << "Should minimize switches to 2 (e.g. ABA) given strict consecutiveStints=1"; - - // Verify both drivers drove (Fair Share check) - std::set drivers; - for (int i = 0; i < output->schedule_len; ++i) { - drivers.insert(output->schedule[i].driver); - } - EXPECT_EQ(drivers.size(), 2) << "Both drivers must drive due to Fair Share rule"; - - free_jres_solver_input(input); - free_jres_solver_output(output); -} From 733a4e764bafee759a7d64d4127e9d9ca6c38542 Mon Sep 17 00:00:00 2001 From: popmonkey Date: Fri, 9 Jan 2026 17:12:52 -0800 Subject: [PATCH 2/6] fix firstStintDriver that got refactored out in v3 --- README.md | 7 ++- include/jres_solver/jres_solver.hpp | 2 + src/jres_internal_types.cpp | 3 + src/jres_internal_types.hpp | 1 + src/jres_json_converter.cpp | 10 +++- src/jres_standard_solver.cpp | 14 +++++ test/CMakeLists.txt | 2 +- test/test_first_stint.cpp | 90 +++++++++++++++++++++++++++++ 8 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 test/test_first_stint.cpp diff --git a/README.md b/README.md index 24adaa7..727e871 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ The C-API uses the following structs to pass data to and from the solver. | Field | Type | Description | | :--- | :--- | :--- | | `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.
**Integrated Mode:** Applies to combined Driving and Spotting time.
**Sequential Mode:** Applies only to Driving. | +| `minimumRestHours` | `int` | Hard constraint: Minimum contiguous rest time required once per race. | +| `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. | | `availability` | `JresMemberAvailability*` | A pointer to an array of availability information. | @@ -183,7 +184,8 @@ The `raceDataJson` string passed to `jres_input_from_json` must strictly follow | Field | Type | Required | Description | | :--- | :--- | :--- | :--- | | `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.
**Integrated Mode:** Applies to combined Driving and Spotting time.
**Sequential Mode:** Applies only to Driving. | +| `minimumRestHours` | Integer | No (Default `0`) | Hard constraint: Minimum contiguous rest time required once per race. | +| `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). | | `stints` | Array | Yes | List of pre-defined race stints (see below). | @@ -227,6 +229,7 @@ The `availability` object maps a **Team Member's Name** to a dictionary of **Tim { "consecutiveStints": 2, "minimumRestHours": 4, + "firstStintDriver": "Niki", "teamMembers": [ { "name": "Niki", diff --git a/include/jres_solver/jres_solver.hpp b/include/jres_solver/jres_solver.hpp index b4dd996..8a0510b 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 The name of the team member who must drive the first stint. */ + const char* firstStintDriver; /** @brief A pointer to an array of team members. */ JresTeamMember* teamMembers; /** @brief The number of team members. */ diff --git a/src/jres_internal_types.cpp b/src/jres_internal_types.cpp index 4099b6e..f167e14 100644 --- a/src/jres_internal_types.cpp +++ b/src/jres_internal_types.cpp @@ -67,6 +67,9 @@ SolverInput from_c_input(const JresSolverInput* c_input) { input.consecutiveStints = c_input->consecutiveStints; input.minimumRestHours = c_input->minimumRestHours; + if (c_input->firstStintDriver) { + input.firstStintDriver = c_input->firstStintDriver; + } for (int i = 0; i < c_input->teamMembers_len; ++i) { TeamMember member; diff --git a/src/jres_internal_types.hpp b/src/jres_internal_types.hpp index 8143539..77d30ba 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; + std::string firstStintDriver; std::vector teamMembers; std::map> availability; std::vector stints; diff --git a/src/jres_json_converter.cpp b/src/jres_json_converter.cpp index 31d529a..81d5ee0 100644 --- a/src/jres_json_converter.cpp +++ b/src/jres_json_converter.cpp @@ -76,6 +76,12 @@ 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); + + if (j.contains("firstStintDriver") && !j["firstStintDriver"].is_null()) { + input->firstStintDriver = allocate_and_copy(j["firstStintDriver"]); + } else { + input->firstStintDriver = nullptr; + } // Team Members input->teamMembers_len = j["teamMembers"].size(); @@ -235,8 +241,8 @@ JRES_SOLVER_API void free_jres_solver_output(JresSolverOutput* output) { } JRES_SOLVER_API void free_jres_solver_input(JresSolverInput* input) { - if (!input) { - return; + if (input->firstStintDriver) { + delete[] input->firstStintDriver; } for (int i = 0; i < input->teamMembers_len; ++i) { diff --git a/src/jres_standard_solver.cpp b/src/jres_standard_solver.cpp index 72ffc9e..448c2e1 100644 --- a/src/jres_standard_solver.cpp +++ b/src/jres_standard_solver.cpp @@ -140,6 +140,20 @@ jres::internal::SolverOutput JresStandardSolver::solve() // --- Build Driver Model --- add_participant_model(*m_highs, m_driverPool, m_driverWorkVars); + // --- Hard Constraint: First Stint Driver --- + if (!m_input.firstStintDriver.empty()) { + bool found = false; + if (m_driverWorkVars.count({m_input.firstStintDriver, 0})) { + int varIdx = m_driverWorkVars.at({m_input.firstStintDriver, 0}); + m_highs->changeColBounds(varIdx, 1.0, 1.0); + found = true; + } + + if (!found) { + throw std::runtime_error("First stint driver '" + m_input.firstStintDriver + "' is not a valid driver or is unavailable."); + } + } + // --- Hard Constraint: iRacing Fair Share Rule --- // Rule: Fair Share = 1/4 of (Total Duration / Num Drivers) double total_duration_hours = 0.0; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 83f2905..0880f5f 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -35,7 +35,7 @@ add_executable(solver_tests test_role_coupling.cpp test_rotation_beat.cpp test_spotter_balancing.cpp - test_hard_consecutive_limit.cpp + test_first_stint.cpp ) add_dependencies(solver_tests jres_solver_lib) diff --git a/test/test_first_stint.cpp b/test/test_first_stint.cpp new file mode 100644 index 0000000..b70cc8f --- /dev/null +++ b/test/test_first_stint.cpp @@ -0,0 +1,90 @@ +/** + * @file test/test_first_stint.cpp + * @brief Tests for the firstStintDriver hard constraint. + */ + +#include "gtest/gtest.h" +#include "jres_solver/jres_solver.hpp" +#include "nlohmann/json.hpp" +#include +#include + +using json = nlohmann::json; + +TEST(FirstStintTest, EnforceFirstStintDriver) { + // Scenario: 2 Drivers, 2 Stints. + // Driver A: Preferred for Stint 1 + // Driver B: Preferred for Stint 2, and set as firstStintDriver. + // + // Normally, Driver A would take Stint 1 due to preference. + // But since Driver B is forced as firstStintDriver, Driver B MUST take Stint 1. + + json j; + j["consecutiveStints"] = 1; + j["minimumRestHours"] = 0; + j["teamMembers"] = { + {{"name", "Driver A"}, {"isDriver", true}, {"isSpotter", false}}, + {{"name", "Driver B"}, {"isDriver", true}, {"isSpotter", false}} + }; + j["stints"] = { + {{"id", 1}, {"startTime", "2026-01-17T00:00:00.000Z"}, {"endTime", "2026-01-17T01:00:00.000Z"}}, + {{"id", 2}, {"startTime", "2026-01-17T01:00:00.000Z"}, {"endTime", "2026-01-17T02:00:00.000Z"}} + }; + j["availability"] = { + {"Driver A", {{"2026-01-17T00:00:00.000Z", "Preferred"}, {"2026-01-17T01:00:00.000Z", "Available"}}}, + {"Driver B", {{"2026-01-17T00:00:00.000Z", "Available"}, {"2026-01-17T01:00:00.000Z", "Preferred"}}} + }; + j["firstStintDriver"] = "Driver B"; + + JresSolverOptions options = {}; + options.timeLimit = 5; + options.spotterMode = JRES_SPOTTER_MODE_NONE; + options.allowNoSpotter = true; + options.optimalityGap = 0.0; + + JresSolverInput* input = jres_input_from_json(j.dump().c_str()); + ASSERT_NE(input, nullptr); + EXPECT_STREQ(input->firstStintDriver, "Driver B"); + + JresSolverOutput* output = solve_race_schedule(input, &options); + ASSERT_NE(output, nullptr); + ASSERT_EQ(output->schedule_len, 2); + + // Expect Driver B then Driver A (or B if cost doesn't matter, but B MUST be first) + EXPECT_STREQ(output->schedule[0].driver, "Driver B") << "Stint 1 should be Driver B (Forced)"; + + free_jres_solver_input(input); + free_jres_solver_output(output); +} + +TEST(FirstStintTest, InvalidFirstStintDriverThrows) { + json j; + j["consecutiveStints"] = 1; + j["minimumRestHours"] = 0; + j["teamMembers"] = { + {{"name", "Driver A"}, {"isDriver", true}, {"isSpotter", false}} + }; + j["stints"] = { + {{"id", 1}, {"startTime", "2026-01-17T00:00:00.000Z"}, {"endTime", "2026-01-17T01:00:00.000Z"}} + }; + j["availability"] = { + {"Driver A", {{"2026-01-17T00:00:00.000Z", "Available"}}} + }; + j["firstStintDriver"] = "NonExistent"; + + JresSolverOptions options = {}; + options.timeLimit = 5; + options.spotterMode = JRES_SPOTTER_MODE_NONE; + options.allowNoSpotter = true; + + JresSolverInput* input = jres_input_from_json(j.dump().c_str()); + ASSERT_NE(input, nullptr); + + JresSolverOutput* output = solve_race_schedule(input, &options); + ASSERT_NE(output, nullptr); + EXPECT_GT(output->diagnosis_len, 0); + EXPECT_TRUE(std::string(output->diagnosis[0]).find("is not a valid driver") != std::string::npos); + + free_jres_solver_output(output); + free_jres_solver_input(input); +} From f3a10f785ebb2c5003cede223bacc261e5244670 Mon Sep 17 00:00:00 2001 From: popmonkey Date: Fri, 9 Jan 2026 17:47:01 -0800 Subject: [PATCH 3/6] change input/output interactions in solver/formatter --- CONTRIBUTING.md | 3 -- GEMINI.md | 4 +-- README.md | 7 +++-- TOOLS.md | 15 ++-------- cmd/formatter/cli.cpp | 48 ++++++++++++++++++-------------- cmd/solver/cli.cpp | 39 ++++++++++++-------------- src/formatter/formatter_core.cpp | 9 ++++++ test_formatter_stdout.sh | 37 ++++++++++++++++++++++++ 8 files changed, 100 insertions(+), 62 deletions(-) create mode 100755 test_formatter_stdout.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b3baab7..4f7ce84 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -135,9 +135,6 @@ The `jres_solver` executable is a client that uses the `jres_solver` library. # Run with a file ./jres_solver -i ../data/short_race.json -s integrated -# Pipe from stdin -cat ../data/24h_race.json | ./jres_solver -s sequential --allow-no-spotter - # Run diagnostics on a failing schedule ./jres_solver -i ../data/infeasible.json --diagnose diff --git a/GEMINI.md b/GEMINI.md index 8a243a3..466971c 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -74,8 +74,8 @@ The compiled executables are located in the `build/` directory. # Run with an input file ./jres_solver -i ../data/short_race.json -s integrated -# Pipe data from stdin and output to a file -cat ../data/24h_race.json | ./jres_solver -s sequential -o /tmp/24_race_solution.json +# Run with an input file and output to a file +./jres_solver -i ../data/24h_race.json -s sequential -o /tmp/24_race_solution.json # Run diagnostics on an infeasible schedule ./jres_solver -i ../data/short_race_no_solution.json --diagnose diff --git a/README.md b/README.md index 727e871..b93c68f 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,6 @@ This library can be used to solve for optimal driver and spotter schedules for endurance racing events. It uses the **HiGHS** optimization library. ->[!NOTE] ->this is based on the python JRES Solver https://github.com/popmonkey/jres_solver - ## Additional Documentation * **[Tools](./TOOLS.md)** - releases include some command line tools that use the library @@ -325,6 +322,10 @@ When the solver fails, the `schedule` array will be empty, and the `diagnosis` a } ``` +--- +>[!NOTE] +>this is based on the python JRES Solver https://github.com/popmonkey/jres_solver + --- _Created by popmonkey, Gemini 2.5, Gemini 3.0, and ChatGPT 5.1_ diff --git a/TOOLS.md b/TOOLS.md index 5d807cd..01a2e14 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -3,7 +3,7 @@ This suite consists of two command-line tools designed to generate and format driver schedules for endurance racing events. > [!NOTE] -Currently the easiest way to generate input for the tools (and library) is to use [the JRES Availability Planner spreadsheet](https://docs.google.com/spreadsheets/d/1k2WaNDhXjyHXLirju2IQKxrYExQpDCT5z6jCBM71KVc/edit?usp=sharing) +Currently the easiest way to generate input for the tools (and library) is to use [the JRES Availability Planner spreadsheet](https://docs.google.com/spreadsheets/d/1hayKeug7IdZqwmta68PlqOCc8Y1JaL-QeaVal_-APxA/edit?usp=sharing) ## Platform Support @@ -39,14 +39,14 @@ jres_solver.exe [options] ### Input/Output Behavior -* **Input:** Accepts a JSON file via the `-i` flag. If no input flag is provided, it reads from **Standard Input (stdin)**. +* **Input:** Accepts a JSON file via the `-i` flag (Required). * **Output:** Prints the schedule summary to **stdout** (unless `-q` is used). To save the raw solution for the Formatter, use the `-o` flag. ### Options | Flag | Long Flag | Description | Default | | :--- | :------------------- | :-------------------------------------------------------------------------------------------- | :------ | -| `-i` | `--input` | Path to the race data `.json` file. Reads from `stdin` if omitted. | `stdin` | +| `-i` | `--input` | Path to the race data `.json` file. (Required) | | | `-o` | `--output` | Path to save the calculated schedule (JSON). **Required for the Formatter.** | `stdout`| | `-t` | `--time-limit` | Maximum time (in seconds) to let the optimizer run. | `5` | | `-q` | `--quiet` | Suppress INFO logs and the printed schedule summary. | `false` | @@ -112,16 +112,7 @@ For complex schedules, allow more time while maintaining a practical optimality ./jres_solver -i race_config.json -o solution.json -t 30 --optimality-gap 0.2 ``` -**Pipeline Usage:** -Pipe a JSON generator directly into the solver. -```sh -# Linux / macOS -cat race_data.json | ./jres_solver -o solution.json - -# Windows PowerShell -Get-Content race_data.json | .\jres_solver.exe -o solution.json -``` --- diff --git a/cmd/formatter/cli.cpp b/cmd/formatter/cli.cpp index b4fbc07..5cd5040 100644 --- a/cmd/formatter/cli.cpp +++ b/cmd/formatter/cli.cpp @@ -34,7 +34,7 @@ int main(int argc, char* argv[]) { options.add_options() ("i,input", "Input JSON file (solved schedule)", cxxopts::value()) - ("o,output", "Output file path", cxxopts::value()) + ("o,output", "Output file path (optional, defaults to stdout if omitted)", cxxopts::value()) ("f,format", "Output format (zip, csv, txt). Auto-detected from output filename if omitted.", cxxopts::value()) ("v,version", "Print version information and exit.") ("h,help", "Print usage") @@ -60,13 +60,12 @@ int main(int argc, char* argv[]) { return 1; } - if (!result.count("output")) { - std::cerr << "Error: Output file is required (-o)" << std::endl; - return 1; - } - std::string input_path = result["input"].as(); - std::string output_path = result["output"].as(); + std::string output_path = ""; + if (result.count("output")) { + output_path = result["output"].as(); + } + std::string format; // --- Logic: Determine Format --- @@ -74,22 +73,26 @@ int main(int argc, char* argv[]) { // Explicitly provided by user format = result["format"].as(); } else { - // Auto-detect from extension - std::string ext = get_extension(output_path); - if (ext == ".csv") { - format = "csv"; - } else if (ext == ".txt") { + if (output_path.empty()) { format = "txt"; - } else if (ext == ".zip") { - format = "zip"; } else { - // Unknown extension and no flag -> Error - std::cerr << "Error: Could not determine output format from filename extension (" - << ext << ")." << std::endl; - std::cerr << "Please explicitly specify format using -f or use a standard file extension." << std::endl; - return 1; + // Auto-detect from extension + std::string ext = get_extension(output_path); + if (ext == ".csv") { + format = "csv"; + } else if (ext == ".txt") { + format = "txt"; + } else if (ext == ".zip") { + format = "zip"; + } else { + // Unknown extension and no flag -> Error + std::cerr << "Error: Could not determine output format from filename extension (" + << ext << ")." << std::endl; + std::cerr << "Please explicitly specify format using -f or use a standard file extension." << std::endl; + return 1; + } + std::cout << "Auto-detected format: " << format << std::endl; } - std::cout << "Auto-detected format: " << format << std::endl; } std::ifstream f(input_path); @@ -113,7 +116,10 @@ int main(int argc, char* argv[]) { // Call into our library jres::write_output(solved_data, output_path, format); - std::cout << "Successfully generated " << format << " output: " << output_path << std::endl; + + if (!output_path.empty()) { + std::cout << "Successfully generated " << format << " output: " << output_path << std::endl; + } } catch (const cxxopts::exceptions::exception& e) { std::cerr << "Error parsing options: " << e.what() << std::endl; diff --git a/cmd/solver/cli.cpp b/cmd/solver/cli.cpp index 2e60fe5..8ecc393 100644 --- a/cmd/solver/cli.cpp +++ b/cmd/solver/cli.cpp @@ -29,7 +29,7 @@ int main(int argc, char **argv) cxxopts::Options options("solver", "JRES endurance race solver."); options.add_options("General") - ("i,input", "Path to the race data .json file. Reads from stdin if not provided.", cxxopts::value()) + ("i,input", "Path to the race data .json file. (Required)", cxxopts::value()) ("o,output", "Optional. Path to save the schedule as a JSON file.", cxxopts::value()) ("q,quiet", "Suppress INFO logs and final schedule print-out.", cxxopts::value()->default_value("false")) ("v,version", "Print version information and exit.") @@ -60,6 +60,14 @@ int main(int argc, char **argv) return 0; } + // Input file is now required + if (!result.count("input")) + { + std::cerr << "Error: Input file is required." << std::endl; + std::cout << options.help() << std::endl; + return 1; + } + bool quiet = result["quiet"].as(); bool runDiagnostics = result["diagnose"].as(); @@ -71,28 +79,17 @@ int main(int argc, char **argv) std::string raceDataJsonString; try { - if (result.count("input")) - { - std::string inputPath = result["input"].as(); - if (!quiet) - std::cout << "[App] Loading data from file: " << inputPath << std::endl; - std::ifstream f(inputPath); - if (!f.is_open()) - { - throw std::runtime_error("Could not open input file: " + inputPath); - } - std::stringstream buffer; - buffer << f.rdbuf(); - raceDataJsonString = buffer.str(); - } - else + std::string inputPath = result["input"].as(); + if (!quiet) + std::cout << "[App] Loading data from file: " << inputPath << std::endl; + std::ifstream f(inputPath); + if (!f.is_open()) { - if (!quiet) - std::cout << "[App] Loading data from stdin..." << std::endl; - std::stringstream buffer; - buffer << std::cin.rdbuf(); - raceDataJsonString = buffer.str(); + throw std::runtime_error("Could not open input file: " + inputPath); } + std::stringstream buffer; + buffer << f.rdbuf(); + raceDataJsonString = buffer.str(); } catch (const std::exception &e) { diff --git a/src/formatter/formatter_core.cpp b/src/formatter/formatter_core.cpp index 2942a23..9f5269e 100644 --- a/src/formatter/formatter_core.cpp +++ b/src/formatter/formatter_core.cpp @@ -404,6 +404,15 @@ void jres::write_output( auto member_itineraries = generate_member_itineraries(sched_vec, data, has_spotters); + if (output_file.empty()) { + if (format == "txt") { + std::cout << generate_full_text_report(sched_vec, driver_stats, spotter_stats, member_itineraries, has_spotters); + } else { + std::cerr << "Error: Output to stdout is only supported for 'txt' format." << std::endl; + } + return; + } + if (format == "csv") { _write_to_csv_file(sched_vec, output_file, has_spotters); } else if (format == "txt") { diff --git a/test_formatter_stdout.sh b/test_formatter_stdout.sh new file mode 100755 index 0000000..7d89893 --- /dev/null +++ b/test_formatter_stdout.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Test the formatter's ability to print to stdout + +# Path to the formatter executable +FORMATTER="./build/jres_formatter" +INPUT_FILE="data/24h_race.json" + +# First, ensure we have a solution file to format. +# We'll run the solver on the input file and pipe it to a temporary file. +SOLVER="./build/jres_solver" +SOLUTION_FILE="/tmp/test_solution.json" + +echo "Running solver to generate solution..." +$SOLVER -i $INPUT_FILE -s sequential -o $SOLUTION_FILE + +if [ ! -f $SOLUTION_FILE ]; then + echo "Error: Solver failed to generate solution file." + exit 1 +fi + +echo "Running formatter without -o flag..." +# Run the formatter and capture stdout +OUTPUT=$($FORMATTER -i $SOLUTION_FILE) + +# Check if the output contains expected strings from the summary report +if [[ "$OUTPUT" == *"--- DRIVER SUMMARY ---"* ]] && [[ "$OUTPUT" == *"--- SCHEDULE ---"* ]]; then + echo "Success: Formatter output to stdout detected." +else + echo "Error: Formatter did not print expected summary to stdout." + echo "Output was:" + echo "$OUTPUT" + exit 1 +fi + +# Clean up +rm $SOLUTION_FILE +exit 0 From a5f52f312284c7af063da560384408719f7b7bc7 Mon Sep 17 00:00:00 2001 From: popmonkey Date: Fri, 9 Jan 2026 17:50:49 -0800 Subject: [PATCH 4/6] fix test broken by 733a4e764bafee759a7d64d4127e9d9ca6c38542 --- test/test_fair_share.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_fair_share.cpp b/test/test_fair_share.cpp index 1e425ec..379e31e 100644 --- a/test/test_fair_share.cpp +++ b/test/test_fair_share.cpp @@ -100,7 +100,7 @@ TEST(FairShareTest, EnforcesMinimumRequirement) { std::vector availabilities; availabilities.push_back(mavA); - JresSolverInput input; + JresSolverInput input = {}; input.consecutiveStints = 20; input.minimumRestHours = 0; input.stints = stints.data(); @@ -165,7 +165,7 @@ TEST(FairShareTest, SuccessScenario) { members.push_back(create_driver("DriverA")); members.push_back(create_driver("DriverB")); - JresSolverInput input; + JresSolverInput input = {}; input.consecutiveStints = 1; input.minimumRestHours = 0; input.stints = stints.data(); From eefeb04dcec412a106d46d249198dc83f9c82df2 Mon Sep 17 00:00:00 2001 From: popmonkey Date: Fri, 9 Jan 2026 17:53:20 -0800 Subject: [PATCH 5/6] updated gemini grounding --- GEMINI.md | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 466971c..8bbfa29 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,3 +1,18 @@ +# ⚠️ CRITICAL AGENT INSTRUCTIONS ⚠️ + +**Adhere strictly to the following mandates. Failure to do so is a critical error.** + +1. **NO GIT MODIFICATIONS:** + * **STATUS:** **STRICTLY READ-ONLY**. + * **FORBIDDEN COMMANDS:** `git add`, `git commit`, `git push`, `git merge`, `git rebase`, `git checkout` (for creating branches), `git stash`, and ANY other command that modifies the git index, history, or working tree. + * **ALLOWED COMMANDS:** `git status`, `git log`, `git diff`, `git show`. + * **ACTION:** If the user asks you to commit changes, **REFUSE** and remind them that you are an AI assistant without authority to alter the project's version control history. You may propose commit messages or explain what changed, but **DO NOT** execute the commands. + +2. **Verbosity:** Low. Do not explain the code unless asked. Just output the diff or the file. +3. **Reasoning:** Perform deep reasoning internally, but output only the final solution. + +--- + # Gemini Project: JRES Solver C++ This document provides instructions for understanding, building, and contributing to the JRES Solver C++ project. @@ -64,6 +79,11 @@ The project uses GoogleTest for its test suite. To run the tests, execute the fo ctest ``` +There is also a script to test the formatter's stdout functionality: +```bash +./test_formatter_stdout.sh +``` + ### Running the CLI Tools The compiled executables are located in the `build/` directory. @@ -96,9 +116,4 @@ The formatter takes the JSON output from the solver and can generate different r * **Dependencies:** C++ library dependencies are managed as Git submodules (`cxxopts`, `nlohmann/json`). The HiGHS solver is an external dependency. * **Testing:** The test suite is built with GoogleTest and run via CTest. New tests should be added to the `test/` directory. * **API Design:** The core logic is exposed as a C-API for wider compatibility. Helper functions are provided for JSON serialization and deserialization. -* **Code Style:** The codebase is written in C++. Please follow the existing coding style when contributing. - -# MODEL INSTRUCTIONS -- **Verbosity:** Low. Do not explain the code unless asked. Just output the diff or the file. -- **Reasoning:** Perform deep reasoning internally, but output only the final solution. -- **Git Operations:** READ-ONLY. You may use `git status`, `git log`, or `git diff` to understand the context. You must *NEVER* run `git add`, `git commit`, `git push`, or any command that modifies the git history or index. Leave all version control management to the user. +* **Code Style:** The codebase is written in C++. Please follow the existing coding style when contributing. \ No newline at end of file From de0d16be32ffff578b4a0150e4cd6858797124da Mon Sep 17 00:00:00 2001 From: popmonkey Date: Fri, 9 Jan 2026 18:06:50 -0800 Subject: [PATCH 6/6] make resting periods indented for visibility in summary itineraries --- src/formatter/formatter_core.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/formatter/formatter_core.cpp b/src/formatter/formatter_core.cpp index 9f5269e..5129a04 100644 --- a/src/formatter/formatter_core.cpp +++ b/src/formatter/formatter_core.cpp @@ -289,7 +289,9 @@ std::string generate_full_text_report( size_t max_activity_width = 0; for (const auto& item : itinerary.items) { - max_activity_width = std::max(max_activity_width, item.activity.length()); + size_t len = item.activity.length(); + if (item.activity == "Resting") len += 2; + max_activity_width = std::max(max_activity_width, len); } std::string tz_string = std::string("UTC") + (itinerary.tz_offset >= 0 ? "+" : "") + std::to_string(itinerary.tz_offset); @@ -297,8 +299,11 @@ std::string generate_full_text_report( for (const auto& item : itinerary.items) { double dur = item.end_local.diff_seconds(item.start_local); + std::string display_act = item.activity; + if (display_act == "Resting") display_act = " " + display_act; + f << " " << item.start_local.to_string() << " to " << item.end_local.time_string() - << " " << std::left << std::setw(max_activity_width) << item.activity + << " " << std::left << std::setw(max_activity_width) << display_act << " (" << DateTime::format_duration((long long)dur) << ")\n"; } }