dabke 0.81.1 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +45 -27
  3. package/dist/client.d.ts +20 -2
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +4 -1
  6. package/dist/client.js.map +1 -1
  7. package/dist/client.types.d.ts +9 -0
  8. package/dist/client.types.d.ts.map +1 -1
  9. package/dist/client.types.js +1 -0
  10. package/dist/client.types.js.map +1 -1
  11. package/dist/cpsat/model-builder.d.ts +9 -0
  12. package/dist/cpsat/model-builder.d.ts.map +1 -1
  13. package/dist/cpsat/model-builder.js +36 -34
  14. package/dist/cpsat/model-builder.js.map +1 -1
  15. package/dist/cpsat/response.d.ts +13 -1
  16. package/dist/cpsat/response.d.ts.map +1 -1
  17. package/dist/cpsat/response.js +4 -0
  18. package/dist/cpsat/response.js.map +1 -1
  19. package/dist/cpsat/rules/cost-utils.d.ts +11 -0
  20. package/dist/cpsat/rules/cost-utils.d.ts.map +1 -0
  21. package/dist/cpsat/rules/cost-utils.js +24 -0
  22. package/dist/cpsat/rules/cost-utils.js.map +1 -0
  23. package/dist/cpsat/rules/day-cost-multiplier.d.ts.map +1 -1
  24. package/dist/cpsat/rules/day-cost-multiplier.js +3 -14
  25. package/dist/cpsat/rules/day-cost-multiplier.js.map +1 -1
  26. package/dist/cpsat/rules/day-cost-surcharge.d.ts.map +1 -1
  27. package/dist/cpsat/rules/day-cost-surcharge.js +3 -7
  28. package/dist/cpsat/rules/day-cost-surcharge.js.map +1 -1
  29. package/dist/cpsat/rules/index.d.ts +3 -0
  30. package/dist/cpsat/rules/index.d.ts.map +1 -1
  31. package/dist/cpsat/rules/index.js +3 -0
  32. package/dist/cpsat/rules/index.js.map +1 -1
  33. package/dist/cpsat/rules/max-consecutive-days.d.ts.map +1 -1
  34. package/dist/cpsat/rules/max-consecutive-days.js +16 -2
  35. package/dist/cpsat/rules/max-consecutive-days.js.map +1 -1
  36. package/dist/cpsat/rules/max-days-week.d.ts +44 -0
  37. package/dist/cpsat/rules/max-days-week.d.ts.map +1 -0
  38. package/dist/cpsat/rules/max-days-week.js +95 -0
  39. package/dist/cpsat/rules/max-days-week.js.map +1 -0
  40. package/dist/cpsat/rules/max-hours-day.d.ts.map +1 -1
  41. package/dist/cpsat/rules/max-hours-day.js +15 -2
  42. package/dist/cpsat/rules/max-hours-day.js.map +1 -1
  43. package/dist/cpsat/rules/max-hours-week.d.ts.map +1 -1
  44. package/dist/cpsat/rules/max-hours-week.js +16 -2
  45. package/dist/cpsat/rules/max-hours-week.js.map +1 -1
  46. package/dist/cpsat/rules/max-shifts-day.d.ts.map +1 -1
  47. package/dist/cpsat/rules/max-shifts-day.js +15 -2
  48. package/dist/cpsat/rules/max-shifts-day.js.map +1 -1
  49. package/dist/cpsat/rules/min-consecutive-days.d.ts.map +1 -1
  50. package/dist/cpsat/rules/min-consecutive-days.js +15 -2
  51. package/dist/cpsat/rules/min-consecutive-days.js.map +1 -1
  52. package/dist/cpsat/rules/min-days-week.d.ts +34 -0
  53. package/dist/cpsat/rules/min-days-week.d.ts.map +1 -0
  54. package/dist/cpsat/rules/min-days-week.js +84 -0
  55. package/dist/cpsat/rules/min-days-week.js.map +1 -0
  56. package/dist/cpsat/rules/min-hours-day.d.ts.map +1 -1
  57. package/dist/cpsat/rules/min-hours-day.js +15 -2
  58. package/dist/cpsat/rules/min-hours-day.js.map +1 -1
  59. package/dist/cpsat/rules/min-hours-week.d.ts.map +1 -1
  60. package/dist/cpsat/rules/min-hours-week.js +16 -2
  61. package/dist/cpsat/rules/min-hours-week.js.map +1 -1
  62. package/dist/cpsat/rules/min-rest-between-shifts.d.ts.map +1 -1
  63. package/dist/cpsat/rules/min-rest-between-shifts.js +72 -2
  64. package/dist/cpsat/rules/min-rest-between-shifts.js.map +1 -1
  65. package/dist/cpsat/rules/minimize-cost.d.ts.map +1 -1
  66. package/dist/cpsat/rules/minimize-cost.js +2 -23
  67. package/dist/cpsat/rules/minimize-cost.js.map +1 -1
  68. package/dist/cpsat/rules/must-assign.d.ts +49 -0
  69. package/dist/cpsat/rules/must-assign.d.ts.map +1 -0
  70. package/dist/cpsat/rules/must-assign.js +86 -0
  71. package/dist/cpsat/rules/must-assign.js.map +1 -0
  72. package/dist/cpsat/rules/overtime-daily-multiplier.d.ts.map +1 -1
  73. package/dist/cpsat/rules/overtime-daily-multiplier.js +1 -12
  74. package/dist/cpsat/rules/overtime-daily-multiplier.js.map +1 -1
  75. package/dist/cpsat/rules/overtime-daily-surcharge.d.ts.map +1 -1
  76. package/dist/cpsat/rules/overtime-daily-surcharge.js +1 -5
  77. package/dist/cpsat/rules/overtime-daily-surcharge.js.map +1 -1
  78. package/dist/cpsat/rules/overtime-tiered-multiplier.d.ts +5 -1
  79. package/dist/cpsat/rules/overtime-tiered-multiplier.d.ts.map +1 -1
  80. package/dist/cpsat/rules/overtime-tiered-multiplier.js +1 -12
  81. package/dist/cpsat/rules/overtime-tiered-multiplier.js.map +1 -1
  82. package/dist/cpsat/rules/overtime-weekly-multiplier.d.ts.map +1 -1
  83. package/dist/cpsat/rules/overtime-weekly-multiplier.js +1 -12
  84. package/dist/cpsat/rules/overtime-weekly-multiplier.js.map +1 -1
  85. package/dist/cpsat/rules/overtime-weekly-surcharge.d.ts.map +1 -1
  86. package/dist/cpsat/rules/overtime-weekly-surcharge.js +1 -5
  87. package/dist/cpsat/rules/overtime-weekly-surcharge.js.map +1 -1
  88. package/dist/cpsat/rules/registry.d.ts +28 -2
  89. package/dist/cpsat/rules/registry.d.ts.map +1 -1
  90. package/dist/cpsat/rules/registry.js +4 -1
  91. package/dist/cpsat/rules/registry.js.map +1 -1
  92. package/dist/cpsat/rules/resolver.js +2 -2
  93. package/dist/cpsat/rules/resolver.js.map +1 -1
  94. package/dist/cpsat/rules/rules.types.d.ts +3 -0
  95. package/dist/cpsat/rules/rules.types.d.ts.map +1 -1
  96. package/dist/cpsat/rules/scope.types.d.ts +18 -1
  97. package/dist/cpsat/rules/scope.types.d.ts.map +1 -1
  98. package/dist/cpsat/rules/scope.types.js +59 -16
  99. package/dist/cpsat/rules/scope.types.js.map +1 -1
  100. package/dist/cpsat/rules/time-cost-surcharge.d.ts.map +1 -1
  101. package/dist/cpsat/rules/time-cost-surcharge.js +2 -1
  102. package/dist/cpsat/rules/time-cost-surcharge.js.map +1 -1
  103. package/dist/cpsat/rules/time-off.d.ts.map +1 -1
  104. package/dist/cpsat/rules/time-off.js +6 -3
  105. package/dist/cpsat/rules/time-off.js.map +1 -1
  106. package/dist/cpsat/semantic-time.d.ts +44 -42
  107. package/dist/cpsat/semantic-time.d.ts.map +1 -1
  108. package/dist/cpsat/semantic-time.js +64 -46
  109. package/dist/cpsat/semantic-time.js.map +1 -1
  110. package/dist/cpsat/types.d.ts +37 -27
  111. package/dist/cpsat/types.d.ts.map +1 -1
  112. package/dist/cpsat/utils.d.ts.map +1 -1
  113. package/dist/cpsat/utils.js +7 -12
  114. package/dist/cpsat/utils.js.map +1 -1
  115. package/dist/cpsat/validation-reporter.d.ts +10 -7
  116. package/dist/cpsat/validation-reporter.d.ts.map +1 -1
  117. package/dist/cpsat/validation-reporter.js +44 -72
  118. package/dist/cpsat/validation-reporter.js.map +1 -1
  119. package/dist/cpsat/validation.types.d.ts +54 -44
  120. package/dist/cpsat/validation.types.d.ts.map +1 -1
  121. package/dist/cpsat/validation.types.js +15 -10
  122. package/dist/cpsat/validation.types.js.map +1 -1
  123. package/dist/datetime.utils.d.ts +3 -203
  124. package/dist/datetime.utils.d.ts.map +1 -1
  125. package/dist/datetime.utils.js +1 -288
  126. package/dist/datetime.utils.js.map +1 -1
  127. package/dist/index.d.ts +14 -83
  128. package/dist/index.d.ts.map +1 -1
  129. package/dist/index.js +11 -83
  130. package/dist/index.js.map +1 -1
  131. package/dist/schedule/cost.d.ts +204 -0
  132. package/dist/schedule/cost.d.ts.map +1 -0
  133. package/dist/schedule/cost.js +187 -0
  134. package/dist/schedule/cost.js.map +1 -0
  135. package/dist/schedule/coverage.d.ts +85 -0
  136. package/dist/schedule/coverage.d.ts.map +1 -0
  137. package/dist/schedule/coverage.js +33 -0
  138. package/dist/schedule/coverage.js.map +1 -0
  139. package/dist/schedule/definition.d.ts +227 -0
  140. package/dist/schedule/definition.d.ts.map +1 -0
  141. package/dist/schedule/definition.js +659 -0
  142. package/dist/schedule/definition.js.map +1 -0
  143. package/dist/schedule/index.d.ts +67 -0
  144. package/dist/schedule/index.d.ts.map +1 -0
  145. package/dist/schedule/index.js +69 -0
  146. package/dist/schedule/index.js.map +1 -0
  147. package/dist/schedule/rules.d.ts +353 -0
  148. package/dist/schedule/rules.d.ts.map +1 -0
  149. package/dist/schedule/rules.js +352 -0
  150. package/dist/schedule/rules.js.map +1 -0
  151. package/dist/schedule/shift-patterns.d.ts +34 -0
  152. package/dist/schedule/shift-patterns.d.ts.map +1 -0
  153. package/dist/schedule/shift-patterns.js +41 -0
  154. package/dist/schedule/shift-patterns.js.map +1 -0
  155. package/dist/schedule/time-periods.d.ts +69 -0
  156. package/dist/schedule/time-periods.d.ts.map +1 -0
  157. package/dist/schedule/time-periods.js +91 -0
  158. package/dist/schedule/time-periods.js.map +1 -0
  159. package/dist/types.d.ts +14 -78
  160. package/dist/types.d.ts.map +1 -1
  161. package/dist/types.js.map +1 -1
  162. package/package.json +4 -9
  163. package/solver/src/solver/app.py +1 -1
  164. package/solver/src/solver/solver.py +7 -4
  165. package/src/client.ts +6 -8
  166. package/src/client.types.ts +9 -0
  167. package/src/cpsat/model-builder.ts +44 -35
  168. package/src/cpsat/response.ts +13 -1
  169. package/src/cpsat/rules/cost-utils.ts +25 -0
  170. package/src/cpsat/rules/day-cost-multiplier.ts +3 -14
  171. package/src/cpsat/rules/day-cost-surcharge.ts +3 -8
  172. package/src/cpsat/rules/index.ts +3 -0
  173. package/src/cpsat/rules/max-consecutive-days.ts +17 -0
  174. package/src/cpsat/rules/max-days-week.ts +143 -0
  175. package/src/cpsat/rules/max-hours-day.ts +21 -1
  176. package/src/cpsat/rules/max-hours-week.ts +22 -1
  177. package/src/cpsat/rules/max-shifts-day.ts +21 -1
  178. package/src/cpsat/rules/min-consecutive-days.ts +16 -1
  179. package/src/cpsat/rules/min-days-week.ts +120 -0
  180. package/src/cpsat/rules/min-hours-day.ts +16 -1
  181. package/src/cpsat/rules/min-hours-week.ts +17 -1
  182. package/src/cpsat/rules/min-rest-between-shifts.ts +92 -2
  183. package/src/cpsat/rules/minimize-cost.ts +2 -29
  184. package/src/cpsat/rules/must-assign.ts +108 -0
  185. package/src/cpsat/rules/overtime-daily-multiplier.ts +1 -12
  186. package/src/cpsat/rules/overtime-daily-surcharge.ts +1 -6
  187. package/src/cpsat/rules/overtime-tiered-multiplier.ts +6 -13
  188. package/src/cpsat/rules/overtime-weekly-multiplier.ts +1 -12
  189. package/src/cpsat/rules/overtime-weekly-surcharge.ts +1 -6
  190. package/src/cpsat/rules/registry.ts +8 -2
  191. package/src/cpsat/rules/resolver.ts +2 -2
  192. package/src/cpsat/rules/rules.types.ts +3 -0
  193. package/src/cpsat/rules/scope.types.ts +73 -20
  194. package/src/cpsat/rules/time-cost-surcharge.ts +2 -1
  195. package/src/cpsat/rules/time-off.ts +6 -2
  196. package/src/cpsat/semantic-time.ts +115 -91
  197. package/src/cpsat/types.ts +37 -27
  198. package/src/cpsat/utils.ts +8 -12
  199. package/src/cpsat/validation-reporter.ts +51 -82
  200. package/src/cpsat/validation.types.ts +72 -47
  201. package/src/datetime.utils.ts +3 -334
  202. package/src/index.ts +35 -107
  203. package/src/schedule/cost.ts +242 -0
  204. package/src/schedule/coverage.ts +135 -0
  205. package/src/schedule/definition.ts +958 -0
  206. package/src/schedule/index.ts +112 -0
  207. package/src/schedule/rules.ts +529 -0
  208. package/src/schedule/shift-patterns.ts +46 -0
  209. package/src/schedule/time-periods.ts +110 -0
  210. package/src/types.ts +14 -88
  211. package/dist/errors.d.ts +0 -12
  212. package/dist/errors.d.ts.map +0 -1
  213. package/dist/errors.js +0 -17
  214. package/dist/errors.js.map +0 -1
  215. package/dist/llms.d.ts +0 -2
  216. package/dist/llms.d.ts.map +0 -1
  217. package/dist/llms.js +0 -3
  218. package/dist/llms.js.map +0 -1
  219. package/dist/schedule.d.ts +0 -724
  220. package/dist/schedule.d.ts.map +0 -1
  221. package/dist/schedule.js +0 -899
  222. package/dist/schedule.js.map +0 -1
  223. package/dist/validation.d.ts +0 -105
  224. package/dist/validation.d.ts.map +0 -1
  225. package/dist/validation.js +0 -130
  226. package/dist/validation.js.map +0 -1
  227. package/llms.txt +0 -925
  228. package/src/errors.ts +0 -17
  229. package/src/llms.ts +0 -3
  230. package/src/schedule.ts +0 -1419
  231. package/src/validation.ts +0 -188
@@ -59,19 +59,15 @@ export function normalizeEndMinutes(startMinutes: number, endMinutes: number): n
59
59
  return endMinutes < startMinutes ? endMinutes + MINUTES_PER_DAY : endMinutes;
60
60
  }
61
61
 
62
+ const PRIORITY_PENALTIES = {
63
+ LOW: 1,
64
+ MEDIUM: 10,
65
+ HIGH: 25,
66
+ MANDATORY: 0,
67
+ } as const satisfies Record<Priority, number>;
68
+
62
69
  export function priorityToPenalty(priority: Priority): number {
63
- switch (priority) {
64
- case "HIGH":
65
- return 25;
66
- case "MEDIUM":
67
- return 10;
68
- case "LOW":
69
- return 1;
70
- case "MANDATORY":
71
- return 0;
72
- default:
73
- return 0;
74
- }
70
+ return PRIORITY_PENALTIES[priority];
75
71
  }
76
72
 
77
73
  /**
@@ -1,6 +1,5 @@
1
1
  import type { SolverResponse } from "../client.types.js";
2
2
  import {
3
- groupKey,
4
3
  type ScheduleError,
5
4
  type ScheduleViolation,
6
5
  type SchedulePassed,
@@ -14,7 +13,6 @@ import {
14
13
  type RulePassed,
15
14
  type CoverageExclusion,
16
15
  type ValidationSummary,
17
- type GroupKey,
18
16
  } from "./validation.types.js";
19
17
 
20
18
  export interface ValidationReporter {
@@ -24,7 +22,7 @@ export interface ValidationReporter {
24
22
  // Errors (block generation)
25
23
  reportCoverageError(error: Omit<CoverageError, "type" | "id">): void;
26
24
  reportRuleError(error: Omit<RuleError, "type" | "id">): void;
27
- reportSolverError(reason: string): void;
25
+ reportSolverError(message: string): void;
28
26
 
29
27
  // Violations (soft constraint issues)
30
28
  reportCoverageViolation(violation: Omit<CoverageViolation, "type" | "id">): void;
@@ -54,16 +52,16 @@ function coverageId(
54
52
  category: "error" | "violation" | "passed",
55
53
  day: string,
56
54
  timeSlots: readonly string[],
57
- roles?: readonly string[],
58
- skills?: readonly string[],
55
+ roleIds?: readonly string[],
56
+ skillIds?: readonly string[],
59
57
  ): string {
60
58
  const parts = [
61
59
  category,
62
60
  "coverage",
63
61
  day,
64
62
  [...timeSlots].toSorted().join(",") || "_",
65
- roles && roles.length > 0 ? [...roles].toSorted().join(",") : "_",
66
- skills ? [...skills].toSorted().join(",") : "_",
63
+ roleIds && roleIds.length > 0 ? [...roleIds].toSorted().join(",") : "_",
64
+ skillIds ? [...skillIds].toSorted().join(",") : "_",
67
65
  ];
68
66
  return parts.join(":");
69
67
  }
@@ -100,7 +98,7 @@ export class ValidationReporterImpl implements ValidationReporter {
100
98
  }
101
99
 
102
100
  reportCoverageError(error: Omit<CoverageError, "type" | "id">): void {
103
- const id = coverageId("error", error.day, error.timeSlots, error.roles, error.skills);
101
+ const id = coverageId("error", error.day, error.timeSlots, error.roleIds, error.skillIds);
104
102
  this.#errors.push({ id, type: "coverage", ...error });
105
103
  }
106
104
 
@@ -109,10 +107,10 @@ export class ValidationReporterImpl implements ValidationReporter {
109
107
  this.#errors.push({ id, type: "rule", ...error });
110
108
  }
111
109
 
112
- reportSolverError(reason: string): void {
110
+ reportSolverError(message: string): void {
113
111
  this.#solverErrorCount++;
114
112
  const id = `error:solver:${this.#solverErrorCount}`;
115
- this.#errors.push({ id, type: "solver", reason });
113
+ this.#errors.push({ id, type: "solver", message });
116
114
  }
117
115
 
118
116
  reportCoverageViolation(violation: Omit<CoverageViolation, "type" | "id">): void {
@@ -120,8 +118,8 @@ export class ValidationReporterImpl implements ValidationReporter {
120
118
  "violation",
121
119
  violation.day,
122
120
  violation.timeSlots,
123
- violation.roles,
124
- violation.skills,
121
+ violation.roleIds,
122
+ violation.skillIds,
125
123
  );
126
124
  this.#violations.push({ id, type: "coverage", ...violation });
127
125
  }
@@ -132,7 +130,7 @@ export class ValidationReporterImpl implements ValidationReporter {
132
130
  }
133
131
 
134
132
  reportCoveragePassed(passed: Omit<CoveragePassed, "type" | "id">): void {
135
- const id = coverageId("passed", passed.day, passed.timeSlots, passed.roles, passed.skills);
133
+ const id = coverageId("passed", passed.day, passed.timeSlots, passed.roleIds, passed.skillIds);
136
134
  this.#passed.push({ id, type: "coverage", ...passed });
137
135
  }
138
136
 
@@ -183,31 +181,33 @@ export class ValidationReporterImpl implements ValidationReporter {
183
181
  const tracked = this.#trackedConstraints.get(violation.constraintId);
184
182
 
185
183
  if (tracked?.type === "coverage") {
184
+ const roles = tracked.roleIds?.join(", ") ?? "staff";
185
+ const slot = tracked.timeSlot ?? "all day";
186
186
  this.reportCoverageViolation({
187
187
  day: tracked.day ?? "",
188
188
  timeSlots: tracked.timeSlot ? [tracked.timeSlot] : [],
189
- roles: tracked.roles,
190
- skills: tracked.skills,
189
+ roleIds: tracked.roleIds,
190
+ skillIds: tracked.skillIds,
191
191
  targetCount: violation.targetValue,
192
192
  actualCount: violation.actualValue,
193
193
  shortfall: violation.violationAmount,
194
- groupKey: tracked.groupKey,
194
+ message: `${roles} on ${tracked.day} (${slot}): ${violation.actualValue} assigned, need ${violation.targetValue}`,
195
+ group: tracked.group,
195
196
  });
196
197
  } else if (tracked?.type === "rule") {
197
198
  const isShortfall = tracked.comparator === ">=";
198
199
  this.reportRuleViolation({
199
200
  rule: tracked.rule ?? "unknown",
200
- reason: `${tracked.description}: needed ${violation.targetValue}, got ${violation.actualValue}`,
201
+ message: `${tracked.description}: needed ${violation.targetValue}, got ${violation.actualValue}`,
201
202
  context: tracked.context,
202
203
  shortfall: isShortfall ? violation.violationAmount : undefined,
203
204
  overflow: !isShortfall ? violation.violationAmount : undefined,
204
- groupKey: tracked.groupKey,
205
+ group: tracked.group,
205
206
  });
206
207
  } else {
207
- // Unknown constraint - create generic rule violation
208
208
  this.reportRuleViolation({
209
209
  rule: "unknown",
210
- reason: `Constraint ${violation.constraintId} violated by ${violation.violationAmount}`,
210
+ message: `Constraint ${violation.constraintId} violated by ${violation.violationAmount}`,
211
211
  context: {},
212
212
  });
213
213
  }
@@ -222,10 +222,10 @@ export class ValidationReporterImpl implements ValidationReporter {
222
222
  this.reportCoveragePassed({
223
223
  day: tracked.day ?? "",
224
224
  timeSlots: tracked.timeSlot ? [tracked.timeSlot] : [],
225
- roles: tracked.roles,
226
- skills: tracked.skills,
227
- description: tracked.description,
228
- groupKey: tracked.groupKey,
225
+ roleIds: tracked.roleIds,
226
+ skillIds: tracked.skillIds,
227
+ message: tracked.description,
228
+ group: tracked.group,
229
229
  });
230
230
  }
231
231
  }
@@ -233,23 +233,26 @@ export class ValidationReporterImpl implements ValidationReporter {
233
233
  }
234
234
 
235
235
  // =============================================================================
236
- // Validation Summary - pure function for aggregation
236
+ // Validation Summary
237
237
  // =============================================================================
238
238
 
239
239
  type ValidationItem = ScheduleError | ScheduleViolation | SchedulePassed;
240
240
 
241
241
  /**
242
- * Aggregates validation items by their groupKey into summaries.
243
- * This is a pure function that doesn't modify the input.
242
+ * Aggregates validation items by their group into summaries.
244
243
  *
245
- * Items without a groupKey are grouped by their ID (ungrouped).
244
+ * Items sharing the same `group.key` are merged into a single summary.
245
+ * The title comes from the first item's `group.title`; for ungrouped items
246
+ * the item's `message` is used instead.
247
+ *
248
+ * @category Validation
246
249
  *
247
250
  * @example
248
251
  * ```typescript
249
- * const validation = reporter.getValidation();
250
252
  * const summaries = summarizeValidation(validation);
251
253
  * // summaries[0] = {
252
- * // groupKey: "2x waiter during lunch",
254
+ * // groupKey: "coverage:day_ward:nurse:3:dow:monday,tuesday,...",
255
+ * // title: "3x nurse during day_ward (weekdays)",
253
256
  * // status: "passed",
254
257
  * // passedCount: 180,
255
258
  * // days: ["2026-02-02", "2026-02-03", ...]
@@ -258,10 +261,10 @@ type ValidationItem = ScheduleError | ScheduleViolation | SchedulePassed;
258
261
  */
259
262
  export function summarizeValidation(validation: ScheduleValidation): readonly ValidationSummary[] {
260
263
  const groups = new Map<
261
- GroupKey,
264
+ string,
262
265
  {
263
266
  type: "coverage" | "rule";
264
- items: ValidationItem[];
267
+ title: string;
265
268
  days: Set<string>;
266
269
  passedCount: number;
267
270
  violatedCount: number;
@@ -269,11 +272,14 @@ export function summarizeValidation(validation: ScheduleValidation): readonly Va
269
272
  }
270
273
  >();
271
274
 
272
- const getOrCreateGroup = (key: GroupKey, type: "coverage" | "rule") => {
275
+ const getOrCreateStats = (item: Exclude<ValidationItem, { type: "solver" }>) => {
276
+ const key = ("group" in item && item.group?.key) || `ungrouped:${item.id}`;
277
+ const title = ("group" in item && item.group?.title) || item.message;
278
+
273
279
  if (!groups.has(key)) {
274
280
  groups.set(key, {
275
- type,
276
- items: [],
281
+ type: item.type,
282
+ title,
277
283
  days: new Set(),
278
284
  passedCount: 0,
279
285
  violatedCount: 0,
@@ -283,41 +289,31 @@ export function summarizeValidation(validation: ScheduleValidation): readonly Va
283
289
  return groups.get(key)!;
284
290
  };
285
291
 
286
- // Group passed items
287
292
  for (const item of validation.passed) {
288
- const key = item.groupKey ?? groupKey(`ungrouped:${item.id}`);
289
- const group = getOrCreateGroup(key, item.type);
290
- group.items.push(item);
291
- group.passedCount++;
293
+ const stats = getOrCreateStats(item);
294
+ stats.passedCount++;
292
295
  if (item.type === "coverage" && item.day) {
293
- group.days.add(item.day);
296
+ stats.days.add(item.day);
294
297
  }
295
298
  }
296
299
 
297
- // Group violations
298
300
  for (const item of validation.violations) {
299
- const key = item.groupKey ?? groupKey(`ungrouped:${item.id}`);
300
- const group = getOrCreateGroup(key, item.type);
301
- group.items.push(item);
302
- group.violatedCount++;
301
+ const stats = getOrCreateStats(item);
302
+ stats.violatedCount++;
303
303
  if (item.type === "coverage" && item.day) {
304
- group.days.add(item.day);
304
+ stats.days.add(item.day);
305
305
  }
306
306
  }
307
307
 
308
- // Group errors (except solver errors which don't have groupKey)
309
308
  for (const item of validation.errors) {
310
309
  if (item.type === "solver") continue;
311
- const key = item.groupKey ?? groupKey(`ungrouped:${item.id}`);
312
- const group = getOrCreateGroup(key, item.type);
313
- group.items.push(item);
314
- group.errorCount++;
310
+ const stats = getOrCreateStats(item);
311
+ stats.errorCount++;
315
312
  if (item.type === "coverage" && item.day) {
316
- group.days.add(item.day);
313
+ stats.days.add(item.day);
317
314
  }
318
315
  }
319
316
 
320
- // Build summaries
321
317
  const summaries: ValidationSummary[] = [];
322
318
  for (const [key, group] of groups) {
323
319
  const status: ValidationSummary["status"] =
@@ -326,7 +322,7 @@ export function summarizeValidation(validation: ScheduleValidation): readonly Va
326
322
  summaries.push({
327
323
  groupKey: key,
328
324
  type: group.type,
329
- description: inferDescription(key, group.items),
325
+ title: group.title,
330
326
  days: [...group.days].toSorted(),
331
327
  status,
332
328
  passedCount: group.passedCount,
@@ -337,30 +333,3 @@ export function summarizeValidation(validation: ScheduleValidation): readonly Va
337
333
 
338
334
  return summaries;
339
335
  }
340
-
341
- /**
342
- * Infers a human-readable description from the groupKey or items.
343
- */
344
- function inferDescription(key: GroupKey, items: ValidationItem[]): string {
345
- // If the key looks like a human-readable description, use it
346
- if (!key.startsWith("ungrouped:")) {
347
- return key;
348
- }
349
-
350
- // Try to infer from the first item with a description
351
- for (const item of items) {
352
- if ("description" in item && item.description) {
353
- // Extract the meaningful part (e.g., "2x waiter" from "2x waiter on 2026-02-03 at 09:00")
354
- const match = item.description.match(/^(\d+x \w+)/);
355
- if (match?.[1]) {
356
- return match[1];
357
- }
358
- return item.description;
359
- }
360
- if ("reason" in item && item.reason) {
361
- return item.reason;
362
- }
363
- }
364
-
365
- return key;
366
- }
@@ -1,38 +1,56 @@
1
1
  import type { TimeOfDay } from "../types.js";
2
2
 
3
- // =============================================================================
4
- // Group Key - for aggregating related validation items
5
- // =============================================================================
6
-
7
- declare const GroupKeyBrand: unique symbol;
8
-
9
3
  /**
10
- * Branded type for validation group keys.
11
- * Groups related validation items that originated from the same instruction.
4
+ * Context shared across validation results for grouping/display.
12
5
  */
13
- export type GroupKey = string & { readonly [GroupKeyBrand]: never };
6
+ export interface ValidationContext {
7
+ days?: string[];
8
+ timeSlots?: string[];
9
+ memberIds?: string[];
10
+ }
11
+
12
+ // =============================================================================
13
+ // Validation Group
14
+ // =============================================================================
14
15
 
15
16
  /**
16
- * Creates a GroupKey from a description string.
17
- * Use this to create keys that group related validation items together.
17
+ * Groups related validation items under a deterministic, structural key.
18
+ *
19
+ * The `key` is derived from the rule/coverage structure (e.g.,
20
+ * `"rule:max-hours-week:40:roles:nurse"`), ensuring that identical
21
+ * configurations always produce the same key. The `title` is the
22
+ * human-readable label shown in summaries.
18
23
  *
19
- * @example
20
- * ```typescript
21
- * const key = groupKey("2x waiter during lunch");
22
- * coverage.groupKey = key;
23
- * ```
24
+ * Rule authors create groups via {@link ruleGroup} in `scope.types.ts`;
25
+ * coverage groups are produced by the semantic-time resolver.
24
26
  */
25
- export function groupKey(description: string): GroupKey {
26
- return description as GroupKey;
27
+ export interface ValidationGroup {
28
+ /** Deterministic key derived from rule/coverage structure. */
29
+ readonly key: string;
30
+ /** Human-readable label for summaries (e.g., "Max 40h per week"). */
31
+ readonly title: string;
27
32
  }
28
33
 
34
+ // =============================================================================
35
+ // Key safety helpers
36
+ // =============================================================================
37
+
38
+ const KEY_DELIMITERS = /[,:/+]/;
39
+
29
40
  /**
30
- * Context shared across validation results for grouping/display.
41
+ * Throws if a value contains characters used as key delimiters.
42
+ * Call before interpolating user-provided IDs into group keys.
31
43
  */
32
- export interface ValidationContext {
33
- days?: string[];
34
- timeSlots?: string[];
35
- memberIds?: string[];
44
+ export function assertSafeKeySegment(value: string, label: string): void {
45
+ if (KEY_DELIMITERS.test(value)) {
46
+ throw new Error(`${label} "${value}" contains reserved delimiter characters (: , / +)`);
47
+ }
48
+ }
49
+
50
+ export function assertSafeKeySegments(values: readonly string[], label: string): void {
51
+ for (const v of values) {
52
+ assertSafeKeySegment(v, label);
53
+ }
36
54
  }
37
55
 
38
56
  // =============================================================================
@@ -44,27 +62,27 @@ export interface CoverageError {
44
62
  readonly type: "coverage";
45
63
  readonly day: string;
46
64
  readonly timeSlots: readonly string[];
47
- readonly roles?: string[];
48
- readonly skills?: readonly string[];
49
- readonly reason: string;
65
+ readonly roleIds?: string[];
66
+ readonly skillIds?: readonly string[];
67
+ readonly message: string;
50
68
  readonly suggestions?: readonly string[];
51
- readonly groupKey?: GroupKey;
69
+ readonly group?: ValidationGroup;
52
70
  }
53
71
 
54
72
  export interface RuleError {
55
73
  readonly id: string;
56
74
  readonly type: "rule";
57
75
  readonly rule: string;
58
- readonly reason: string;
76
+ readonly message: string;
59
77
  readonly context: ValidationContext;
60
78
  readonly suggestions?: readonly string[];
61
- readonly groupKey?: GroupKey;
79
+ readonly group?: ValidationGroup;
62
80
  }
63
81
 
64
82
  export interface SolverError {
65
83
  readonly id: string;
66
84
  readonly type: "solver";
67
- readonly reason: string;
85
+ readonly message: string;
68
86
  }
69
87
 
70
88
  export type ScheduleError = CoverageError | RuleError | SolverError;
@@ -78,23 +96,24 @@ export interface CoverageViolation {
78
96
  readonly type: "coverage";
79
97
  readonly day: string;
80
98
  readonly timeSlots: readonly string[];
81
- readonly roles?: string[];
82
- readonly skills?: readonly string[];
99
+ readonly roleIds?: string[];
100
+ readonly skillIds?: readonly string[];
83
101
  readonly targetCount: number;
84
102
  readonly actualCount: number;
85
103
  readonly shortfall: number;
86
- readonly groupKey?: GroupKey;
104
+ readonly message: string;
105
+ readonly group?: ValidationGroup;
87
106
  }
88
107
 
89
108
  export interface RuleViolation {
90
109
  readonly id: string;
91
110
  readonly type: "rule";
92
111
  readonly rule: string;
93
- readonly reason: string;
112
+ readonly message: string;
94
113
  readonly context: ValidationContext;
95
114
  readonly shortfall?: number;
96
115
  readonly overflow?: number;
97
- readonly groupKey?: GroupKey;
116
+ readonly group?: ValidationGroup;
98
117
  }
99
118
 
100
119
  export type ScheduleViolation = CoverageViolation | RuleViolation;
@@ -108,19 +127,19 @@ export interface CoveragePassed {
108
127
  readonly type: "coverage";
109
128
  readonly day: string;
110
129
  readonly timeSlots: readonly string[];
111
- readonly roles?: string[];
112
- readonly skills?: readonly string[];
113
- readonly description: string;
114
- readonly groupKey?: GroupKey;
130
+ readonly roleIds?: string[];
131
+ readonly skillIds?: readonly string[];
132
+ readonly message: string;
133
+ readonly group?: ValidationGroup;
115
134
  }
116
135
 
117
136
  export interface RulePassed {
118
137
  readonly id: string;
119
138
  readonly type: "rule";
120
139
  readonly rule: string;
121
- readonly description: string;
140
+ readonly message: string;
122
141
  readonly context: ValidationContext;
123
- readonly groupKey?: GroupKey;
142
+ readonly group?: ValidationGroup;
124
143
  }
125
144
 
126
145
  export type SchedulePassed = CoveragePassed | RulePassed;
@@ -129,6 +148,7 @@ export type SchedulePassed = CoveragePassed | RulePassed;
129
148
  // Complete validation result
130
149
  // =============================================================================
131
150
 
151
+ /** @category Validation */
132
152
  export interface ScheduleValidation {
133
153
  readonly errors: readonly ScheduleError[];
134
154
  readonly violations: readonly ScheduleViolation[];
@@ -141,12 +161,16 @@ export interface ScheduleValidation {
141
161
 
142
162
  /**
143
163
  * Summary of validation items grouped by their source instruction.
144
- * Use `summarizeValidation()` to create these from a ScheduleValidation.
164
+ * Use `summarizeValidation()` to create these from a `ScheduleValidation`.
165
+ *
166
+ * @category Validation
145
167
  */
146
168
  export interface ValidationSummary {
147
- readonly groupKey: GroupKey;
169
+ /** Deterministic group key derived from rule/coverage structure. */
170
+ readonly groupKey: string;
148
171
  readonly type: "coverage" | "rule";
149
- readonly description: string;
172
+ /** Human-readable title for this group (e.g., "3x nurse during day_ward"). */
173
+ readonly title: string;
150
174
  readonly days: readonly string[];
151
175
  readonly status: "passed" | "partial" | "failed";
152
176
  readonly passedCount: number;
@@ -162,15 +186,16 @@ export interface TrackedConstraint {
162
186
  readonly id: string;
163
187
  readonly type: "coverage" | "rule";
164
188
  readonly rule?: string;
189
+ /** Per-constraint description used to generate violation messages. */
165
190
  readonly description: string;
166
191
  readonly targetValue: number;
167
192
  readonly comparator: "<=" | ">=";
168
193
  readonly day?: string;
169
194
  readonly timeSlot?: string;
170
- readonly roles?: string[];
171
- readonly skills?: readonly string[];
195
+ readonly roleIds?: string[];
196
+ readonly skillIds?: readonly string[];
172
197
  readonly context: ValidationContext;
173
- readonly groupKey?: GroupKey;
198
+ readonly group?: ValidationGroup;
174
199
  }
175
200
 
176
201
  /**