Skip to content

Commit cabcc91

Browse files
committed
humanize diagonstics further
1 parent 6235427 commit cabcc91

5 files changed

Lines changed: 206 additions & 31 deletions

File tree

src/analysis/solver_diagnostics.cpp

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
#include "solver_diagnostics.hpp"
2+
#include "capacity_analyzer.hpp"
23
#include "../utils/date_utils.hpp"
34
#include <sstream>
45
#include <iomanip>
56
#include <set>
67
#include <chrono>
8+
#include <algorithm>
9+
#include <cmath>
10+
#include <tuple>
711

812
namespace jres::analysis {
913

@@ -135,4 +139,174 @@ std::string explain_assignment_failure(
135139
return ss.str();
136140
}
137141

138-
} // namespace jres::analysis
142+
/**
143+
* @brief Helper to format a list of stint indices into a human-readable string using IDs.
144+
*/
145+
std::string formatStintList(const std::vector<int>& indices, const jres::internal::SolverInput& input) {
146+
if (indices.empty()) return "";
147+
148+
std::vector<int> sorted = indices;
149+
std::sort(sorted.begin(), sorted.end());
150+
151+
std::ostringstream ss;
152+
for (size_t i = 0; i < sorted.size(); ++i) {
153+
int startIdx = sorted[i];
154+
int endIdx = startIdx;
155+
while (i + 1 < sorted.size() && sorted[i + 1] == endIdx + 1) {
156+
endIdx = sorted[++i];
157+
}
158+
159+
if (ss.tellp() > 0) ss << ", ";
160+
if (startIdx == endIdx) ss << input.stints[startIdx].id;
161+
else ss << input.stints[startIdx].id << "-" << input.stints[endIdx].id;
162+
}
163+
return ss.str();
164+
}
165+
166+
std::vector<std::string> formatHumanDiagnostic(
167+
const std::map<int, jres::internal::SlackInfo>& slackInfo,
168+
const std::set<int>& unavailableVars,
169+
const std::map<std::pair<std::string, int>, int>& driverWorkVars,
170+
const std::map<std::pair<std::string, int>, int>& spotterWorkVars,
171+
const std::vector<double>& colValues,
172+
const jres::internal::SolverInput& input,
173+
const std::vector<jres::internal::TeamMember>& driverPool,
174+
const std::vector<jres::internal::TeamMember>& spotterPool)
175+
{
176+
using namespace jres::internal;
177+
std::vector<std::string> report;
178+
179+
// --- Roster Health Check (Drivers) ---
180+
if (!driverPool.empty()) {
181+
auto cap = CapacityAnalyzer::calculate_max_potential_capacity(driverPool, input);
182+
if (cap.totalCapacity < (int)input.stints.size()) {
183+
std::ostringstream ss;
184+
ss << "Driver Roster Understaffed: You have " << cap.totalCapacity
185+
<< " stints of coverage for a " << input.stints.size() << " stint race.";
186+
report.push_back(ss.str());
187+
}
188+
}
189+
190+
// --- Collect Violations per Stint ---
191+
struct ViolationKey {
192+
int priority;
193+
std::string reason;
194+
std::string member;
195+
196+
bool operator<(const ViolationKey& other) const {
197+
return std::tie(priority, reason, member) < std::tie(other.priority, other.reason, other.member);
198+
}
199+
};
200+
201+
std::map<ViolationKey, std::vector<int>> groupedViolations;
202+
std::vector<std::string> globalViolations;
203+
204+
// Build reverse map for variable index -> (Member, Stint, Role)
205+
std::map<int, std::tuple<std::string, int, std::string>> varToInfo;
206+
207+
for(const auto& [key, varIdx] : driverWorkVars) {
208+
varToInfo[varIdx] = std::make_tuple(key.first, key.second, "Driver");
209+
}
210+
for(const auto& [key, varIdx] : spotterWorkVars) {
211+
varToInfo[varIdx] = std::make_tuple(key.first, key.second, "Spotter");
212+
}
213+
214+
// Check Unavailable (Priority 0)
215+
for (int varIdx : unavailableVars) {
216+
if (varIdx < (int)colValues.size() && colValues[varIdx] > 0.5) {
217+
if (varToInfo.count(varIdx)) {
218+
auto [member, stintIdx, role] = varToInfo[varIdx];
219+
std::string reason = std::string("No one could ") + (role == "Spotter" ? "spot" : "drive") +
220+
" without being Unavailable (" + member + ")";
221+
groupedViolations[{0, reason, member}].push_back(stintIdx);
222+
}
223+
}
224+
}
225+
226+
// Check Slack (Rest, Busy, etc)
227+
for (const auto& [varIdx, info] : slackInfo) {
228+
if (varIdx < (int)colValues.size() && colValues[varIdx] > 0.001) {
229+
int priority = 3;
230+
if (info.type.find("Busy") != std::string::npos) priority = 1;
231+
else if (info.type.find("Rest") != std::string::npos) priority = 2;
232+
else if (info.type.find("Fair Share") != std::string::npos) priority = 2;
233+
234+
if (info.stintIndex >= 0) {
235+
std::string reason;
236+
if (priority == 1) reason = "Max Busy Time exceeded (" + info.memberName + ")";
237+
else if (priority == 2) {
238+
if (info.type.find("Fair Share") != std::string::npos) reason = "Fair Share Rules violated (" + info.memberName + ")";
239+
else reason = "Rest Rules violated (" + info.memberName + ")";
240+
}
241+
else reason = info.type + " (" + info.memberName + ")";
242+
243+
groupedViolations[{priority, reason, info.memberName}].push_back(info.stintIndex);
244+
} else {
245+
// Try to attribute global violation to stints
246+
bool assignedAny = false;
247+
for(const auto& [vIdx, tupleInfo] : varToInfo) {
248+
auto [memName, stintIdx, role] = tupleInfo;
249+
if (memName == info.memberName && colValues[vIdx] > 0.5) {
250+
std::string reason;
251+
if (priority == 1) reason = "Max Busy Time exceeded (" + memName + ")";
252+
else if (priority == 2) {
253+
if (info.type.find("Fair Share") != std::string::npos) reason = "Fair Share Rules violated (" + memName + ")";
254+
else reason = "Rest Rules violated (" + memName + ")";
255+
}
256+
else reason = info.type + " (" + memName + ")";
257+
258+
groupedViolations[{priority, reason, memName}].push_back(stintIdx);
259+
assignedAny = true;
260+
}
261+
}
262+
263+
if (!assignedAny) {
264+
std::string reason = info.type;
265+
if (priority == 2) {
266+
if (info.type.find("Fair Share") != std::string::npos)
267+
reason = "Fair Share Rules violated";
268+
else
269+
reason = "Rest Rules violated";
270+
}
271+
globalViolations.push_back(reason + " (" + info.memberName + ")");
272+
}
273+
}
274+
}
275+
}
276+
277+
// --- Sort & Format Grouped Violations ---
278+
std::vector<ViolationKey> sortedKeys;
279+
for(const auto& [key, _] : groupedViolations) sortedKeys.push_back(key);
280+
std::sort(sortedKeys.begin(), sortedKeys.end());
281+
282+
for (const auto& key : sortedKeys) {
283+
const auto& stints = groupedViolations[key];
284+
std::string stintStr = formatStintList(stints, input);
285+
286+
std::ostringstream ss;
287+
if (stints.size() == 1) ss << "Stint " << stintStr << ": " << key.reason;
288+
else ss << "Stints " << stintStr << ": " << key.reason;
289+
report.push_back(ss.str());
290+
291+
// Actionable Advice
292+
if (key.priority == 0) {
293+
report.push_back(" -> Advice: Mark " + key.member + " as Available during this window.");
294+
} else if (key.priority == 1) {
295+
report.push_back(" -> Advice: Increase 'Maximum Busy Hours' (currently " + std::to_string(input.maximumBusyHours) + "h) or add more participants.");
296+
} else if (key.priority == 2) {
297+
if (key.reason.find("Fair Share") != std::string::npos)
298+
report.push_back(" -> Advice: Ensure " + key.member + " is Available for more stints to meet the minimum driving time.");
299+
else
300+
report.push_back(" -> Advice: Reduce 'Minimum Rest Hours' (currently " + std::to_string(input.minimumRestHours) + "h) or increase availability of other participants to cover the gap.");
301+
}
302+
}
303+
304+
// Append Global Violations (Unattributed)
305+
for (const auto& g : globalViolations) {
306+
report.push_back("Global Violation: " + g);
307+
}
308+
309+
return report;
310+
}
311+
312+
} // namespace jres::analysis

src/analysis/solver_diagnostics.hpp

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#include <vector>
55
#include <map>
66
#include <string>
7+
#include <set>
78

89
namespace jres::analysis {
910

@@ -28,4 +29,18 @@ std::string explain_assignment_failure(
2829
const std::vector<double>& colValues
2930
);
3031

32+
/**
33+
* @brief Formats raw solver slack and violation information into a human-readable report.
34+
*/
35+
std::vector<std::string> formatHumanDiagnostic(
36+
const std::map<int, jres::internal::SlackInfo>& slackInfo,
37+
const std::set<int>& unavailableVars,
38+
const std::map<std::pair<std::string, int>, int>& driverWorkVars,
39+
const std::map<std::pair<std::string, int>, int>& spotterWorkVars,
40+
const std::vector<double>& colValues,
41+
const jres::internal::SolverInput& input,
42+
const std::vector<jres::internal::TeamMember>& driverPool,
43+
const std::vector<jres::internal::TeamMember>& spotterPool
44+
);
45+
3146
} // namespace jres::analysis

src/jres_standard_solver.cpp

Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -388,25 +388,18 @@ jres::internal::SolverOutput JresStandardSolver::solve()
388388
const auto& solution = m_highs->getSolution();
389389
const std::vector<double>& colValues = solution.col_value;
390390

391-
// Check Slacks (Covers both Drivers and Spotters in Integrated mode)
392-
for (const auto& [varIdx, info] : m_slackInfo) {
393-
if (varIdx < colValues.size() && colValues[varIdx] > 0.001) {
394-
std::ostringstream ss;
395-
ss << "Violation: " << info.type << " for " << info.memberName;
396-
if (info.stintIndex >= 0) {
397-
ss << " at Stint " << info.stintIndex;
398-
}
399-
ss << " (Value: " << colValues[varIdx] << ")";
400-
output.diagnosis.push_back(ss.str());
401-
}
402-
}
403-
404-
// Check Unavailable Assignments
405-
for (int varIdx : m_unavailableVars) {
406-
if (varIdx < colValues.size() && colValues[varIdx] > 0.5) {
407-
// We can defer detailed message generation to the loop below
408-
}
409-
}
391+
// Use human-readable diagnostic formatter
392+
auto formattedDiags = jres::analysis::formatHumanDiagnostic(
393+
m_slackInfo,
394+
m_unavailableVars,
395+
m_driverWorkVars,
396+
m_spotterWorkVars,
397+
colValues,
398+
m_input,
399+
m_driverPool,
400+
m_spotterPool
401+
);
402+
output.diagnosis.insert(output.diagnosis.end(), formattedDiags.begin(), formattedDiags.end());
410403

411404
for (size_t s = 0; s < m_input.stints.size(); ++s) {
412405
jres::internal::ScheduleEntry entry;
@@ -422,10 +415,6 @@ jres::internal::SolverOutput JresStandardSolver::solve()
422415
int idx = m_driverWorkVars.at({p.name, (int)s});
423416
if (colValues[idx] > 0.5) {
424417
entry.driver = p.name;
425-
if (m_unavailableVars.count(idx)) {
426-
output.diagnosis.push_back("Violation: Unavailable Driver " + p.name + " assigned to Stint " + std::to_string(s));
427-
output.diagnosis.push_back(jres::analysis::explain_assignment_failure((int)s, p.name, m_input, m_driverPool, m_driverWorkVars, colValues));
428-
}
429418
break;
430419
}
431420
}
@@ -438,9 +427,6 @@ jres::internal::SolverOutput JresStandardSolver::solve()
438427
int idx = m_spotterWorkVars.at({p.name, (int)s});
439428
if (colValues[idx] > 0.5) {
440429
entry.spotter = p.name;
441-
if (m_unavailableVars.count(idx)) {
442-
output.diagnosis.push_back("Violation: Unavailable Spotter " + p.name + " assigned to Stint " + std::to_string(s));
443-
}
444430
break;
445431
}
446432
}

test/test_availability_boundaries.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ TEST(AvailabilityBoundaryTest, StintEndsExactlyAtUnavailableStart) {
3030
JresSolverOutput* output = solve_race_schedule(input, &options);
3131
bool hasViolation = false;
3232
for (int i = 0; i < output->diagnosis_len; ++i) {
33-
if (std::string(output->diagnosis[i]).find("Violation: Unavailable Driver") != std::string::npos) hasViolation = true;
33+
if (std::string(output->diagnosis[i]).find("No one could drive without being Unavailable") != std::string::npos) hasViolation = true;
3434
}
3535

3636
EXPECT_FALSE(hasViolation);
@@ -57,7 +57,7 @@ TEST(AvailabilityBoundaryTest, StintOverlapsUnavailableByOneMinute) {
5757
JresSolverOutput* output = solve_race_schedule(input, &options);
5858
bool hasViolation = false;
5959
for (int i = 0; i < output->diagnosis_len; ++i) {
60-
if (std::string(output->diagnosis[i]).find("Violation: Unavailable Driver") != std::string::npos) hasViolation = true;
60+
if (std::string(output->diagnosis[i]).find("No one could drive without being Unavailable") != std::string::npos) hasViolation = true;
6161
}
6262

6363
EXPECT_TRUE(hasViolation);

test/test_diagnosis.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,12 @@ namespace {
6363
bool found = false;
6464
for (int i = 0; i < output->diagnosis_len; ++i) {
6565
std::string msg(output->diagnosis[i]);
66-
if (msg.find("Violation: Unavailable Spotter") != std::string::npos) {
66+
if (msg.find("No one could spot without being Unavailable") != std::string::npos) {
6767
found = true;
6868
break;
6969
}
7070
}
71-
EXPECT_TRUE(found) << "Expected diagnosis to contain 'Violation: Unavailable Spotter'";
71+
EXPECT_TRUE(found) << "Expected diagnosis to contain 'No one could spot without being Unavailable'";
7272
free_jres_solver_input(input);
7373
free_jres_solver_output(output);
7474
}

0 commit comments

Comments
 (0)