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
@@ -1,11 +1,14 @@
1
1
  import * as z from "zod";
2
- import type { CompilationRule } from "../model-builder.js";
3
- import { priorityToPenalty } from "../utils.js";
2
+ import type { CompilationRule, RuleValidationContext } from "../model-builder.js";
3
+ import type { ResolvedShiftAssignment } from "../response.js";
4
+ import { normalizeEndMinutes, priorityToPenalty, timeOfDayToMinutes } from "../utils.js";
5
+ import type { ValidationReporter } from "../validation-reporter.js";
4
6
  import {
5
7
  PrioritySchema,
6
8
  entityScope,
7
9
  parseEntityScope,
8
10
  resolveMembersFromScope,
11
+ ruleGroup,
9
12
  } from "./scope.types.js";
10
13
 
11
14
  const MinRestBetweenShiftsSchema = z
@@ -41,6 +44,11 @@ export function createMinRestBetweenShiftsRule(
41
44
  const scope = parseEntityScope(parsed);
42
45
  const { hours, priority } = parsed;
43
46
  const minMinutes = hours * 60;
47
+ const group = ruleGroup(
48
+ `min-rest-between-shifts:${hours}`,
49
+ `Min ${hours}h rest between shifts`,
50
+ scope,
51
+ );
44
52
 
45
53
  return {
46
54
  compile(b) {
@@ -121,5 +129,87 @@ export function createMinRestBetweenShiftsRule(
121
129
  }
122
130
  }
123
131
  },
132
+
133
+ validate(
134
+ assignments: ResolvedShiftAssignment[],
135
+ reporter: ValidationReporter,
136
+ context: RuleValidationContext,
137
+ ) {
138
+ if (priority === "MANDATORY") return;
139
+
140
+ const members = resolveMembersFromScope(scope, context.members);
141
+ if (members.length === 0) return;
142
+
143
+ const memberIds = new Set(members.map((m) => m.id));
144
+
145
+ // Group assignments by member, sort by day then start time
146
+ const byMember = new Map<string, ResolvedShiftAssignment[]>();
147
+ for (const a of assignments) {
148
+ if (!memberIds.has(a.memberId)) continue;
149
+ const list = byMember.get(a.memberId) ?? [];
150
+ list.push(a);
151
+ byMember.set(a.memberId, list);
152
+ }
153
+
154
+ for (const [memberId, memberAssignments] of byMember) {
155
+ const sorted = memberAssignments.toSorted((a, b) => {
156
+ if (a.day !== b.day) return a.day < b.day ? -1 : 1;
157
+ return timeOfDayToMinutes(a.startTime) - timeOfDayToMinutes(b.startTime);
158
+ });
159
+
160
+ let violated = false;
161
+ for (let i = 0; i < sorted.length - 1; i++) {
162
+ const current = sorted[i]!;
163
+ const next = sorted[i + 1]!;
164
+
165
+ const currentEndMin = timeOfDayToMinutes(current.endTime);
166
+ const normalizedEnd = normalizeEndMinutes(
167
+ timeOfDayToMinutes(current.startTime),
168
+ currentEndMin,
169
+ );
170
+ const nextStartMin = timeOfDayToMinutes(next.startTime);
171
+
172
+ // Calculate gap considering day boundaries
173
+ let gap: number;
174
+ if (current.day === next.day) {
175
+ gap = nextStartMin - normalizedEnd;
176
+ } else {
177
+ // Different days: account for the day boundary
178
+ const dayDiff = daysBetween(current.day, next.day);
179
+ gap = dayDiff * 24 * 60 - normalizedEnd + nextStartMin;
180
+ }
181
+
182
+ if (gap >= 0 && gap < minMinutes) {
183
+ reporter.reportRuleViolation({
184
+ rule: "min-rest-between-shifts",
185
+ message: `${memberId} has ${Math.round((gap / 60) * 10) / 10}h rest between shifts on ${current.day} and ${next.day}, need ${hours}h`,
186
+ context: { memberIds: [memberId], days: [current.day, next.day] },
187
+ shortfall: minMinutes - gap,
188
+ group,
189
+ });
190
+ violated = true;
191
+ }
192
+ }
193
+
194
+ if (!violated && sorted.length > 1) {
195
+ reporter.reportRulePassed({
196
+ rule: "min-rest-between-shifts",
197
+ message: `${memberId} has ${hours}h+ rest between all shifts`,
198
+ context: {
199
+ memberIds: [memberId],
200
+ days: [...new Set(sorted.map((a) => a.day))],
201
+ },
202
+ group,
203
+ });
204
+ }
205
+ }
206
+ },
124
207
  };
125
208
  }
209
+
210
+ /** Compute day difference from YYYY-MM-DD strings. */
211
+ function daysBetween(day1: string, day2: string): number {
212
+ const d1 = new Date(`${day1}T00:00:00Z`);
213
+ const d2 = new Date(`${day2}T00:00:00Z`);
214
+ return Math.round((d2.getTime() - d1.getTime()) / (24 * 60 * 60 * 1000));
215
+ }
@@ -4,12 +4,7 @@ import type { CompilationRule, CostContribution } from "../model-builder.js";
4
4
  import type { ShiftPattern, SchedulingMember } from "../types.js";
5
5
  import type { ShiftAssignment } from "../response.js";
6
6
  import { COST_CATEGORY } from "../cost.js";
7
- import {
8
- OBJECTIVE_WEIGHTS,
9
- timeOfDayToMinutes,
10
- normalizeEndMinutes,
11
- splitIntoWeeks,
12
- } from "../utils.js";
7
+ import { OBJECTIVE_WEIGHTS, splitIntoWeeks } from "../utils.js";
13
8
  import {
14
9
  entityScope,
15
10
  timeScope,
@@ -18,6 +13,7 @@ import {
18
13
  resolveMembersFromScope,
19
14
  resolveActiveDaysFromScope,
20
15
  } from "./scope.types.js";
16
+ import { getHourlyRate, getSalariedPay, patternDurationMinutes } from "./cost-utils.js";
21
17
 
22
18
  const MinimizeCostSchema = entityScope(["members", "roles", "skills"]).and(
23
19
  timeScope(["dateRange", "specificDates", "dayOfWeek", "recurring"]),
@@ -26,29 +22,6 @@ const MinimizeCostSchema = entityScope(["members", "roles", "skills"]).and(
26
22
  /** Configuration for {@link createMinimizeCostRule}. */
27
23
  export type MinimizeCostConfig = z.infer<typeof MinimizeCostSchema>;
28
24
 
29
- /** Returns the hourly rate for a member, or undefined if not hourly. */
30
- function getHourlyRate(member: SchedulingMember): number | undefined {
31
- if (!member.pay) return undefined;
32
- if ("hourlyRate" in member.pay) return member.pay.hourlyRate;
33
- return undefined;
34
- }
35
-
36
- /** Returns salaried pay info, or undefined if not salaried. */
37
- function getSalariedPay(
38
- member: SchedulingMember,
39
- ): { annual: number; hoursPerWeek: number } | undefined {
40
- if (!member.pay) return undefined;
41
- if ("annual" in member.pay) return member.pay;
42
- return undefined;
43
- }
44
-
45
- /** Computes shift duration in minutes from a pattern. */
46
- function patternDurationMinutes(pattern: ShiftPattern): number {
47
- const start = timeOfDayToMinutes(pattern.startTime);
48
- const end = normalizeEndMinutes(start, timeOfDayToMinutes(pattern.endTime));
49
- return end - start;
50
- }
51
-
52
25
  /**
53
26
  * Creates the minimize-cost rule.
54
27
  *
@@ -0,0 +1,108 @@
1
+ import * as z from "zod";
2
+ import { DayOfWeekSchema } from "../../types.js";
3
+ import type { CompilationRule } from "../model-builder.js";
4
+ import type { Term } from "../types.js";
5
+ import { priorityToPenalty, splitIntoWeeks } from "../utils.js";
6
+ import {
7
+ entityScope,
8
+ parseEntityScope,
9
+ resolveMembersFromScope,
10
+ ruleGroup,
11
+ } from "./scope.types.js";
12
+
13
+ /** Internally always HIGH. Not user-configurable. */
14
+ const MUST_ASSIGN_PENALTY = priorityToPenalty("HIGH");
15
+
16
+ const MustAssignSchema = z
17
+ .object({
18
+ weekStartsOn: DayOfWeekSchema.optional(),
19
+ })
20
+ .and(entityScope(["members", "roles", "skills"]));
21
+
22
+ /**
23
+ * Configuration for {@link createMustAssignRule}.
24
+ *
25
+ * - `weekStartsOn` (optional): which day starts the week; defaults to
26
+ * {@link ModelBuilder.weekStartsOn}
27
+ *
28
+ * Entity scoping (at most one): `memberIds`, `roleIds`, `skillIds`
29
+ */
30
+ export type MustAssignConfig = z.infer<typeof MustAssignSchema>;
31
+
32
+ /**
33
+ * Guarantees that targeted members appear on the schedule.
34
+ *
35
+ * @remarks
36
+ * Use this for staffing obligations: salaried employees who are paid
37
+ * regardless of whether they work, or contracted staff who must be
38
+ * rostered. The solver ensures each targeted member has at least one
39
+ * assignment per scheduling week.
40
+ *
41
+ * Always compiles as a soft constraint (HIGH priority internally) so the
42
+ * schedule still generates when a member genuinely cannot be placed
43
+ * (e.g., full week of absences, no compatible shift patterns). Violations
44
+ * surface as validation warnings with distinct messaging from
45
+ * `min-days-week`.
46
+ *
47
+ * Unlike {@link createMinDaysWeekRule}, this rule communicates a staffing
48
+ * obligation rather than a scheduling preference. Validation messages
49
+ * reflect this: "Alice was not assigned (staffing obligation)" rather
50
+ * than "Alice worked 0 days, minimum was 1."
51
+ *
52
+ * @param config - See {@link MustAssignConfig}
53
+ *
54
+ * @example Ensure a salaried manager is always rostered
55
+ * ```ts
56
+ * createMustAssignRule({ memberIds: ["diana"] });
57
+ * ```
58
+ *
59
+ * @example Ensure all supervisors appear on the rota
60
+ * ```ts
61
+ * createMustAssignRule({ roleIds: ["supervisor"] });
62
+ * ```
63
+ */
64
+ export function createMustAssignRule(config: MustAssignConfig): CompilationRule {
65
+ const parsed = MustAssignSchema.parse(config);
66
+ const scope = parseEntityScope(parsed);
67
+ const { weekStartsOn } = parsed;
68
+ const group = ruleGroup("must-assign", "Must assign", scope);
69
+
70
+ return {
71
+ compile(b) {
72
+ const members = resolveMembersFromScope(scope, b.members);
73
+ const weeks = splitIntoWeeks(b.days, weekStartsOn ?? b.weekStartsOn);
74
+
75
+ for (const emp of members) {
76
+ for (const weekDays of weeks) {
77
+ const terms: Term[] = [];
78
+
79
+ for (const day of weekDays) {
80
+ for (const pattern of b.shiftPatterns) {
81
+ if (!b.canAssign(emp, pattern)) continue;
82
+ if (!b.patternAvailableOnDay(pattern, day)) continue;
83
+ terms.push({ var: b.assignment(emp.id, pattern.id, day), coeff: 1 });
84
+ }
85
+ }
86
+
87
+ if (terms.length === 0) continue;
88
+
89
+ const weekLabel = weekDays[0]!;
90
+ const constraintId = `must-assign:${emp.id}:${weekLabel}`;
91
+
92
+ b.addSoftLinear(terms, ">=", 1, MUST_ASSIGN_PENALTY, constraintId);
93
+ b.reporter.trackConstraint({
94
+ id: constraintId,
95
+ type: "rule",
96
+ rule: "must-assign",
97
+ description: `${emp.id} not assigned in week starting ${weekLabel} (staffing obligation)`,
98
+ targetValue: 1,
99
+ comparator: ">=",
100
+ day: weekLabel,
101
+ context: { memberIds: [emp.id], days: weekDays },
102
+ group,
103
+ });
104
+ }
105
+ }
106
+ },
107
+ };
108
+ }
@@ -17,6 +17,7 @@ import {
17
17
  resolveMembersFromScope,
18
18
  resolveActiveDaysFromScope,
19
19
  } from "./scope.types.js";
20
+ import { getHourlyRate, patternDurationMinutes } from "./cost-utils.js";
20
21
 
21
22
  const OvertimeDailyMultiplierSchema = z
22
23
  .object({
@@ -29,18 +30,6 @@ const OvertimeDailyMultiplierSchema = z
29
30
  /** Configuration for {@link createOvertimeDailyMultiplierRule}. */
30
31
  export type OvertimeDailyMultiplierConfig = z.infer<typeof OvertimeDailyMultiplierSchema>;
31
32
 
32
- function getHourlyRate(emp: SchedulingMember): number | undefined {
33
- if (!emp.pay) return undefined;
34
- if ("hourlyRate" in emp.pay) return emp.pay.hourlyRate;
35
- return undefined;
36
- }
37
-
38
- function patternDurationMinutes(pattern: ShiftPattern): number {
39
- const start = timeOfDayToMinutes(pattern.startTime);
40
- const end = normalizeEndMinutes(start, timeOfDayToMinutes(pattern.endTime));
41
- return end - start;
42
- }
43
-
44
33
  /**
45
34
  * Creates a daily overtime rate multiplier rule.
46
35
  *
@@ -17,6 +17,7 @@ import {
17
17
  resolveMembersFromScope,
18
18
  resolveActiveDaysFromScope,
19
19
  } from "./scope.types.js";
20
+ import { patternDurationMinutes } from "./cost-utils.js";
20
21
 
21
22
  const OvertimeDailySurchargeSchema = z
22
23
  .object({
@@ -29,12 +30,6 @@ const OvertimeDailySurchargeSchema = z
29
30
  /** Configuration for {@link createOvertimeDailySurchargeRule}. */
30
31
  export type OvertimeDailySurchargeConfig = z.infer<typeof OvertimeDailySurchargeSchema>;
31
32
 
32
- function patternDurationMinutes(pattern: ShiftPattern): number {
33
- const start = timeOfDayToMinutes(pattern.startTime);
34
- const end = normalizeEndMinutes(start, timeOfDayToMinutes(pattern.endTime));
35
- return end - start;
36
- }
37
-
38
33
  /**
39
34
  * Creates a daily overtime flat surcharge rule.
40
35
  *
@@ -19,13 +19,18 @@ import {
19
19
  resolveMembersFromScope,
20
20
  resolveActiveDaysFromScope,
21
21
  } from "./scope.types.js";
22
+ import { getHourlyRate, patternDurationMinutes } from "./cost-utils.js";
22
23
 
23
24
  const OvertimeTierSchema = z.object({
24
25
  after: z.number().min(0),
25
26
  factor: z.number().min(1),
26
27
  });
27
28
 
28
- /** A single tier in a tiered overtime configuration. */
29
+ /**
30
+ * A single tier in a tiered overtime configuration.
31
+ *
32
+ * @category Cost Optimization
33
+ */
29
34
  export type OvertimeTier = z.infer<typeof OvertimeTierSchema>;
30
35
 
31
36
  const OvertimeTieredMultiplierSchema = z
@@ -39,18 +44,6 @@ const OvertimeTieredMultiplierSchema = z
39
44
  /** Configuration for {@link createOvertimeTieredMultiplierRule}. */
40
45
  export type OvertimeTieredMultiplierConfig = z.infer<typeof OvertimeTieredMultiplierSchema>;
41
46
 
42
- function getHourlyRate(emp: SchedulingMember): number | undefined {
43
- if (!emp.pay) return undefined;
44
- if ("hourlyRate" in emp.pay) return emp.pay.hourlyRate;
45
- return undefined;
46
- }
47
-
48
- function patternDurationMinutes(pattern: ShiftPattern): number {
49
- const start = timeOfDayToMinutes(pattern.startTime);
50
- const end = normalizeEndMinutes(start, timeOfDayToMinutes(pattern.endTime));
51
- return end - start;
52
- }
53
-
54
47
  /**
55
48
  * Creates a multi-threshold weekly overtime multiplier rule.
56
49
  *
@@ -19,6 +19,7 @@ import {
19
19
  resolveMembersFromScope,
20
20
  resolveActiveDaysFromScope,
21
21
  } from "./scope.types.js";
22
+ import { getHourlyRate, patternDurationMinutes } from "./cost-utils.js";
22
23
 
23
24
  const OvertimeWeeklyMultiplierSchema = z
24
25
  .object({
@@ -32,18 +33,6 @@ const OvertimeWeeklyMultiplierSchema = z
32
33
  /** Configuration for {@link createOvertimeWeeklyMultiplierRule}. */
33
34
  export type OvertimeWeeklyMultiplierConfig = z.infer<typeof OvertimeWeeklyMultiplierSchema>;
34
35
 
35
- function getHourlyRate(emp: SchedulingMember): number | undefined {
36
- if (!emp.pay) return undefined;
37
- if ("hourlyRate" in emp.pay) return emp.pay.hourlyRate;
38
- return undefined;
39
- }
40
-
41
- function patternDurationMinutes(pattern: ShiftPattern): number {
42
- const start = timeOfDayToMinutes(pattern.startTime);
43
- const end = normalizeEndMinutes(start, timeOfDayToMinutes(pattern.endTime));
44
- return end - start;
45
- }
46
-
47
36
  /**
48
37
  * Creates a weekly overtime rate multiplier rule.
49
38
  *
@@ -19,6 +19,7 @@ import {
19
19
  resolveMembersFromScope,
20
20
  resolveActiveDaysFromScope,
21
21
  } from "./scope.types.js";
22
+ import { patternDurationMinutes } from "./cost-utils.js";
22
23
 
23
24
  const OvertimeWeeklySurchargeSchema = z
24
25
  .object({
@@ -32,12 +33,6 @@ const OvertimeWeeklySurchargeSchema = z
32
33
  /** Configuration for {@link createOvertimeWeeklySurchargeRule}. */
33
34
  export type OvertimeWeeklySurchargeConfig = z.infer<typeof OvertimeWeeklySurchargeSchema>;
34
35
 
35
- function patternDurationMinutes(pattern: ShiftPattern): number {
36
- const start = timeOfDayToMinutes(pattern.startTime);
37
- const end = normalizeEndMinutes(start, timeOfDayToMinutes(pattern.endTime));
38
- return end - start;
39
- }
40
-
41
36
  /**
42
37
  * Creates a weekly overtime flat surcharge rule.
43
38
  *
@@ -3,13 +3,16 @@ import {
3
3
  createAssignmentPriorityRule,
4
4
  createLocationPreferenceRule,
5
5
  createMaxConsecutiveDaysRule,
6
+ createMaxDaysWeekRule,
6
7
  createMaxHoursDayRule,
7
8
  createMaxHoursWeekRule,
8
9
  createMaxShiftsDayRule,
9
10
  createMinConsecutiveDaysRule,
11
+ createMinDaysWeekRule,
10
12
  createMinHoursDayRule,
11
13
  createMinHoursWeekRule,
12
14
  createMinRestBetweenShiftsRule,
15
+ createMustAssignRule,
13
16
  createTimeOffRule,
14
17
  createMinimizeCostRule,
15
18
  createDayCostMultiplierRule,
@@ -27,18 +30,21 @@ import type {
27
30
  CpsatRuleName,
28
31
  } from "./rules.types.js";
29
32
 
30
- export const builtInCpsatRuleFactories: BuiltInCpsatRuleFactories = {
33
+ export const builtInCpsatRuleFactories = {
31
34
  "assign-together": createAssignTogetherRule,
32
35
  "assignment-priority": createAssignmentPriorityRule,
33
36
  "location-preference": createLocationPreferenceRule,
34
37
  "max-consecutive-days": createMaxConsecutiveDaysRule,
38
+ "max-days-week": createMaxDaysWeekRule,
35
39
  "max-hours-day": createMaxHoursDayRule,
36
40
  "max-hours-week": createMaxHoursWeekRule,
37
41
  "max-shifts-day": createMaxShiftsDayRule,
38
42
  "min-consecutive-days": createMinConsecutiveDaysRule,
43
+ "min-days-week": createMinDaysWeekRule,
39
44
  "min-hours-day": createMinHoursDayRule,
40
45
  "min-hours-week": createMinHoursWeekRule,
41
46
  "min-rest-between-shifts": createMinRestBetweenShiftsRule,
47
+ "must-assign": createMustAssignRule,
42
48
  "time-off": createTimeOffRule,
43
49
  "minimize-cost": createMinimizeCostRule,
44
50
  "day-cost-multiplier": createDayCostMultiplierRule,
@@ -49,7 +55,7 @@ export const builtInCpsatRuleFactories: BuiltInCpsatRuleFactories = {
49
55
  "overtime-daily-multiplier": createOvertimeDailyMultiplierRule,
50
56
  "overtime-daily-surcharge": createOvertimeDailySurchargeRule,
51
57
  "overtime-tiered-multiplier": createOvertimeTieredMultiplierRule,
52
- };
58
+ } satisfies BuiltInCpsatRuleFactories;
53
59
 
54
60
  /**
55
61
  * Creates a rule factory map, preventing overriding built-in rules.
@@ -45,11 +45,11 @@ export function getMemberIdsForScope(
45
45
  }
46
46
  case "roles":
47
47
  return members
48
- .filter((e) => e.roles.some((r) => entity.roleIds.includes(r)))
48
+ .filter((e) => e.roleIds.some((r) => entity.roleIds.includes(r)))
49
49
  .map((e) => e.id);
50
50
  case "skills":
51
51
  return members
52
- .filter((e) => e.skills?.some((s) => entity.skillIds.includes(s)))
52
+ .filter((e) => e.skillIds?.some((s) => entity.skillIds.includes(s)))
53
53
  .map((e) => e.id);
54
54
  case "global":
55
55
  default:
@@ -8,13 +8,16 @@ export interface CpsatRuleRegistry {
8
8
  "assignment-priority": import("./assignment-priority.js").AssignmentPriorityConfig;
9
9
  "location-preference": import("./location-preference.js").LocationPreferenceConfig;
10
10
  "max-consecutive-days": import("./max-consecutive-days.js").MaxConsecutiveDaysConfig;
11
+ "max-days-week": import("./max-days-week.js").MaxDaysWeekConfig;
11
12
  "max-hours-day": import("./max-hours-day.js").MaxHoursDayConfig;
12
13
  "max-hours-week": import("./max-hours-week.js").MaxHoursWeekConfig;
13
14
  "max-shifts-day": import("./max-shifts-day.js").MaxShiftsDayConfig;
14
15
  "min-consecutive-days": import("./min-consecutive-days.js").MinConsecutiveDaysConfig;
16
+ "min-days-week": import("./min-days-week.js").MinDaysWeekConfig;
15
17
  "min-hours-day": import("./min-hours-day.js").MinHoursDayConfig;
16
18
  "min-hours-week": import("./min-hours-week.js").MinHoursWeekConfig;
17
19
  "min-rest-between-shifts": import("./min-rest-between-shifts.js").MinRestBetweenShiftsConfig;
20
+ "must-assign": import("./must-assign.js").MustAssignConfig;
18
21
  "time-off": import("./time-off.js").TimeOffConfig;
19
22
  "minimize-cost": import("./minimize-cost.js").MinimizeCostConfig;
20
23
  "day-cost-multiplier": import("./day-cost-multiplier.js").DayCostMultiplierConfig;
@@ -35,8 +35,10 @@
35
35
 
36
36
  import * as z from "zod";
37
37
  import { DayOfWeekSchema, type DayOfWeek } from "../../types.js";
38
+ import { toDayOfWeekUTC } from "../../datetime.utils.js";
38
39
  import type { SchedulingMember } from "../types.js";
39
40
  import { parseDayString } from "../utils.js";
41
+ import { assertSafeKeySegments, type ValidationGroup } from "../validation.types.js";
40
42
 
41
43
  // ============================================================================
42
44
  // Priority
@@ -85,7 +87,11 @@ type InactiveEntityFields = {
85
87
 
86
88
  type NonEmptyArray<T> = [T, ...T[]];
87
89
 
88
- /** Recurring calendar period for time scoping. */
90
+ /**
91
+ * Recurring calendar period for time scoping.
92
+ *
93
+ * @category Supporting Types
94
+ */
89
95
  export interface RecurringPeriod {
90
96
  name: string;
91
97
  startMonth: number;
@@ -470,9 +476,9 @@ export function resolveMembersFromScope(
470
476
  return members.filter((e) => idSet.has(e.id));
471
477
  }
472
478
  case "roles":
473
- return members.filter((e) => e.roles.some((r) => scope.roleIds.includes(r)));
479
+ return members.filter((e) => e.roleIds.some((r) => scope.roleIds.includes(r)));
474
480
  case "skills":
475
- return members.filter((e) => e.skills?.some((s) => scope.skillIds.includes(s)));
481
+ return members.filter((e) => e.skillIds?.some((s) => scope.skillIds.includes(s)));
476
482
  case "global":
477
483
  default:
478
484
  return members;
@@ -494,8 +500,7 @@ export function resolveActiveDaysFromScope(scope: ParsedTimeScope, allDays: stri
494
500
  const targetDays = new Set(scope.days);
495
501
  return allDays.filter((day) => {
496
502
  const date = parseDayString(day);
497
- const dayName = getDayOfWeekName(date.getUTCDay());
498
- return targetDays.has(dayName);
503
+ return targetDays.has(toDayOfWeekUTC(date));
499
504
  });
500
505
  }
501
506
  case "recurring":
@@ -512,21 +517,6 @@ export function resolveActiveDaysFromScope(scope: ParsedTimeScope, allDays: stri
512
517
  // Internal Helpers
513
518
  // ============================================================================
514
519
 
515
- type DayName = "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday";
516
-
517
- function getDayOfWeekName(dayIndex: number): DayName {
518
- const names: Record<number, DayName> = {
519
- 0: "sunday",
520
- 1: "monday",
521
- 2: "tuesday",
522
- 3: "wednesday",
523
- 4: "thursday",
524
- 5: "friday",
525
- 6: "saturday",
526
- };
527
- return names[dayIndex % 7] ?? "sunday";
528
- }
529
-
530
520
  function isDateInRecurringPeriod(
531
521
  month: number,
532
522
  dayOfMonth: number,
@@ -546,3 +536,66 @@ function isDateInRecurringPeriod(
546
536
  return true;
547
537
  }
548
538
  }
539
+
540
+ /**
541
+ * Creates a validation group for a rule with a deterministic structural key.
542
+ *
543
+ * The key encodes the rule name (including parameters), entity scope, and
544
+ * time scope so that identical configurations always produce the same group.
545
+ *
546
+ * @param rule - Rule name with parameters, e.g. `"max-hours-week:40"`
547
+ * @param title - Human-readable label, e.g. `"Max 40h per week"`
548
+ * @param entity - Parsed entity scope (members, roles, skills, or global)
549
+ * @param time - Parsed time scope (optional)
550
+ */
551
+ export function ruleGroup(
552
+ rule: string,
553
+ title: string,
554
+ entity: ParsedEntityScope,
555
+ time?: ParsedTimeScope,
556
+ ): ValidationGroup {
557
+ let key = `rule:${rule}`;
558
+
559
+ switch (entity.type) {
560
+ case "global":
561
+ break;
562
+ case "members":
563
+ assertSafeKeySegments(entity.memberIds, "member ID");
564
+ key += `:members:${entity.memberIds.toSorted().join(",")}`;
565
+ break;
566
+ case "roles":
567
+ assertSafeKeySegments(entity.roleIds, "role ID");
568
+ key += `:roles:${entity.roleIds.toSorted().join(",")}`;
569
+ break;
570
+ case "skills":
571
+ assertSafeKeySegments(entity.skillIds, "skill ID");
572
+ key += `:skills:${entity.skillIds.toSorted().join(",")}`;
573
+ break;
574
+ }
575
+
576
+ if (time && time.type !== "none") {
577
+ switch (time.type) {
578
+ case "dateRange":
579
+ key += `:range:${time.start}..${time.end}`;
580
+ break;
581
+ case "specificDates":
582
+ key += `:dates:${time.dates.toSorted().join(",")}`;
583
+ break;
584
+ case "dayOfWeek":
585
+ key += `:dow:${time.days.toSorted().join(",")}`;
586
+ break;
587
+ case "recurring":
588
+ assertSafeKeySegments(
589
+ time.periods.map((p) => p.name),
590
+ "recurring period name",
591
+ );
592
+ key += `:recurring:${time.periods
593
+ .map((p) => p.name)
594
+ .toSorted()
595
+ .join(",")}`;
596
+ break;
597
+ }
598
+ }
599
+
600
+ return { key, title };
601
+ }
@@ -3,6 +3,7 @@ import type { CompilationRule, CostContribution } from "../model-builder.js";
3
3
  import type { ShiftPattern, SchedulingMember } from "../types.js";
4
4
  import type { TimeOfDay } from "../../types.js";
5
5
  import type { ShiftAssignment } from "../response.js";
6
+ import { COST_CATEGORY } from "../cost.js";
6
7
  import { timeOfDayToMinutes, normalizeEndMinutes, MINUTES_PER_DAY } from "../utils.js";
7
8
  import {
8
9
  entityScope,
@@ -158,7 +159,7 @@ export function createTimeCostSurchargeRule(config: TimeCostSurchargeConfig): Co
158
159
  entries.push({
159
160
  memberId: a.memberId,
160
161
  day: a.day,
161
- category: "premium",
162
+ category: COST_CATEGORY.PREMIUM,
162
163
  amount,
163
164
  });
164
165
  }