dabke 0.78.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 (194) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/LICENSE +21 -0
  3. package/README.md +187 -0
  4. package/dist/client.d.ts +14 -0
  5. package/dist/client.d.ts.map +1 -0
  6. package/dist/client.js +42 -0
  7. package/dist/client.js.map +1 -0
  8. package/dist/client.schemas.d.ts +250 -0
  9. package/dist/client.schemas.d.ts.map +1 -0
  10. package/dist/client.schemas.js +137 -0
  11. package/dist/client.schemas.js.map +1 -0
  12. package/dist/client.types.d.ts +34 -0
  13. package/dist/client.types.d.ts.map +1 -0
  14. package/dist/client.types.js +18 -0
  15. package/dist/client.types.js.map +1 -0
  16. package/dist/cpsat/model-builder.d.ts +128 -0
  17. package/dist/cpsat/model-builder.d.ts.map +1 -0
  18. package/dist/cpsat/model-builder.js +640 -0
  19. package/dist/cpsat/model-builder.js.map +1 -0
  20. package/dist/cpsat/response.d.ts +74 -0
  21. package/dist/cpsat/response.d.ts.map +1 -0
  22. package/dist/cpsat/response.js +92 -0
  23. package/dist/cpsat/response.js.map +1 -0
  24. package/dist/cpsat/rules/assign-together.d.ts +23 -0
  25. package/dist/cpsat/rules/assign-together.d.ts.map +1 -0
  26. package/dist/cpsat/rules/assign-together.js +78 -0
  27. package/dist/cpsat/rules/assign-together.js.map +1 -0
  28. package/dist/cpsat/rules/employee-assignment-priority.d.ts +64 -0
  29. package/dist/cpsat/rules/employee-assignment-priority.d.ts.map +1 -0
  30. package/dist/cpsat/rules/employee-assignment-priority.js +151 -0
  31. package/dist/cpsat/rules/employee-assignment-priority.js.map +1 -0
  32. package/dist/cpsat/rules/index.d.ts +13 -0
  33. package/dist/cpsat/rules/index.d.ts.map +1 -0
  34. package/dist/cpsat/rules/index.js +13 -0
  35. package/dist/cpsat/rules/index.js.map +1 -0
  36. package/dist/cpsat/rules/location-preference.d.ts +29 -0
  37. package/dist/cpsat/rules/location-preference.d.ts.map +1 -0
  38. package/dist/cpsat/rules/location-preference.js +59 -0
  39. package/dist/cpsat/rules/location-preference.js.map +1 -0
  40. package/dist/cpsat/rules/max-consecutive-days.d.ts +28 -0
  41. package/dist/cpsat/rules/max-consecutive-days.d.ts.map +1 -0
  42. package/dist/cpsat/rules/max-consecutive-days.js +70 -0
  43. package/dist/cpsat/rules/max-consecutive-days.js.map +1 -0
  44. package/dist/cpsat/rules/max-hours-day.d.ts +57 -0
  45. package/dist/cpsat/rules/max-hours-day.d.ts.map +1 -0
  46. package/dist/cpsat/rules/max-hours-day.js +159 -0
  47. package/dist/cpsat/rules/max-hours-day.js.map +1 -0
  48. package/dist/cpsat/rules/max-hours-week.d.ts +62 -0
  49. package/dist/cpsat/rules/max-hours-week.d.ts.map +1 -0
  50. package/dist/cpsat/rules/max-hours-week.js +169 -0
  51. package/dist/cpsat/rules/max-hours-week.js.map +1 -0
  52. package/dist/cpsat/rules/max-shifts-day.d.ts +69 -0
  53. package/dist/cpsat/rules/max-shifts-day.d.ts.map +1 -0
  54. package/dist/cpsat/rules/max-shifts-day.js +170 -0
  55. package/dist/cpsat/rules/max-shifts-day.js.map +1 -0
  56. package/dist/cpsat/rules/min-consecutive-days.d.ts +29 -0
  57. package/dist/cpsat/rules/min-consecutive-days.d.ts.map +1 -0
  58. package/dist/cpsat/rules/min-consecutive-days.js +104 -0
  59. package/dist/cpsat/rules/min-consecutive-days.js.map +1 -0
  60. package/dist/cpsat/rules/min-hours-day.d.ts +28 -0
  61. package/dist/cpsat/rules/min-hours-day.d.ts.map +1 -0
  62. package/dist/cpsat/rules/min-hours-day.js +61 -0
  63. package/dist/cpsat/rules/min-hours-day.js.map +1 -0
  64. package/dist/cpsat/rules/min-hours-week.d.ts +29 -0
  65. package/dist/cpsat/rules/min-hours-week.d.ts.map +1 -0
  66. package/dist/cpsat/rules/min-hours-week.js +68 -0
  67. package/dist/cpsat/rules/min-hours-week.js.map +1 -0
  68. package/dist/cpsat/rules/min-rest-between-shifts.d.ts +28 -0
  69. package/dist/cpsat/rules/min-rest-between-shifts.d.ts.map +1 -0
  70. package/dist/cpsat/rules/min-rest-between-shifts.js +95 -0
  71. package/dist/cpsat/rules/min-rest-between-shifts.js.map +1 -0
  72. package/dist/cpsat/rules/registry.d.ts +7 -0
  73. package/dist/cpsat/rules/registry.d.ts.map +1 -0
  74. package/dist/cpsat/rules/registry.js +28 -0
  75. package/dist/cpsat/rules/registry.js.map +1 -0
  76. package/dist/cpsat/rules/resolver.d.ts +31 -0
  77. package/dist/cpsat/rules/resolver.d.ts.map +1 -0
  78. package/dist/cpsat/rules/resolver.js +124 -0
  79. package/dist/cpsat/rules/resolver.js.map +1 -0
  80. package/dist/cpsat/rules/rules.types.d.ts +32 -0
  81. package/dist/cpsat/rules/rules.types.d.ts.map +1 -0
  82. package/dist/cpsat/rules/rules.types.js +2 -0
  83. package/dist/cpsat/rules/rules.types.js.map +1 -0
  84. package/dist/cpsat/rules/scoping.d.ts +129 -0
  85. package/dist/cpsat/rules/scoping.d.ts.map +1 -0
  86. package/dist/cpsat/rules/scoping.js +190 -0
  87. package/dist/cpsat/rules/scoping.js.map +1 -0
  88. package/dist/cpsat/rules/time-off.d.ts +78 -0
  89. package/dist/cpsat/rules/time-off.d.ts.map +1 -0
  90. package/dist/cpsat/rules/time-off.js +261 -0
  91. package/dist/cpsat/rules/time-off.js.map +1 -0
  92. package/dist/cpsat/rules.d.ts +5 -0
  93. package/dist/cpsat/rules.d.ts.map +1 -0
  94. package/dist/cpsat/rules.js +4 -0
  95. package/dist/cpsat/rules.js.map +1 -0
  96. package/dist/cpsat/semantic-time.d.ts +198 -0
  97. package/dist/cpsat/semantic-time.d.ts.map +1 -0
  98. package/dist/cpsat/semantic-time.js +222 -0
  99. package/dist/cpsat/semantic-time.js.map +1 -0
  100. package/dist/cpsat/types.d.ts +180 -0
  101. package/dist/cpsat/types.d.ts.map +1 -0
  102. package/dist/cpsat/types.js +2 -0
  103. package/dist/cpsat/types.js.map +1 -0
  104. package/dist/cpsat/utils.d.ts +47 -0
  105. package/dist/cpsat/utils.d.ts.map +1 -0
  106. package/dist/cpsat/utils.js +92 -0
  107. package/dist/cpsat/utils.js.map +1 -0
  108. package/dist/cpsat/validation-reporter.d.ts +54 -0
  109. package/dist/cpsat/validation-reporter.d.ts.map +1 -0
  110. package/dist/cpsat/validation-reporter.js +261 -0
  111. package/dist/cpsat/validation-reporter.js.map +1 -0
  112. package/dist/cpsat/validation.types.d.ts +141 -0
  113. package/dist/cpsat/validation.types.d.ts.map +1 -0
  114. package/dist/cpsat/validation.types.js +14 -0
  115. package/dist/cpsat/validation.types.js.map +1 -0
  116. package/dist/datetime.utils.d.ts +245 -0
  117. package/dist/datetime.utils.d.ts.map +1 -0
  118. package/dist/datetime.utils.js +372 -0
  119. package/dist/datetime.utils.js.map +1 -0
  120. package/dist/errors.d.ts +12 -0
  121. package/dist/errors.d.ts.map +1 -0
  122. package/dist/errors.js +17 -0
  123. package/dist/errors.js.map +1 -0
  124. package/dist/index.d.ts +112 -0
  125. package/dist/index.d.ts.map +1 -0
  126. package/dist/index.js +116 -0
  127. package/dist/index.js.map +1 -0
  128. package/dist/llms.d.ts +5 -0
  129. package/dist/llms.d.ts.map +1 -0
  130. package/dist/llms.js +8 -0
  131. package/dist/llms.js.map +1 -0
  132. package/dist/testing/index.d.ts +12 -0
  133. package/dist/testing/index.d.ts.map +1 -0
  134. package/dist/testing/index.js +11 -0
  135. package/dist/testing/index.js.map +1 -0
  136. package/dist/testing/solver-container.d.ts +49 -0
  137. package/dist/testing/solver-container.d.ts.map +1 -0
  138. package/dist/testing/solver-container.js +127 -0
  139. package/dist/testing/solver-container.js.map +1 -0
  140. package/dist/types.d.ts +155 -0
  141. package/dist/types.d.ts.map +1 -0
  142. package/dist/types.js +20 -0
  143. package/dist/types.js.map +1 -0
  144. package/dist/validation.d.ts +105 -0
  145. package/dist/validation.d.ts.map +1 -0
  146. package/dist/validation.js +130 -0
  147. package/dist/validation.js.map +1 -0
  148. package/llms.txt +2188 -0
  149. package/package.json +76 -0
  150. package/solver/Dockerfile +31 -0
  151. package/solver/README.md +23 -0
  152. package/solver/pyproject.toml +28 -0
  153. package/solver/src/solver/__init__.py +1 -0
  154. package/solver/src/solver/app.py +24 -0
  155. package/solver/src/solver/models.py +120 -0
  156. package/solver/src/solver/solver.py +359 -0
  157. package/solver/tests/test_solver.py +156 -0
  158. package/solver/uv.lock +661 -0
  159. package/src/client.schemas.ts +163 -0
  160. package/src/client.ts +67 -0
  161. package/src/client.types.ts +66 -0
  162. package/src/cpsat/model-builder.ts +858 -0
  163. package/src/cpsat/response.ts +130 -0
  164. package/src/cpsat/rules/assign-together.ts +96 -0
  165. package/src/cpsat/rules/employee-assignment-priority.ts +182 -0
  166. package/src/cpsat/rules/index.ts +12 -0
  167. package/src/cpsat/rules/location-preference.ts +68 -0
  168. package/src/cpsat/rules/max-consecutive-days.ts +98 -0
  169. package/src/cpsat/rules/max-hours-day.ts +187 -0
  170. package/src/cpsat/rules/max-hours-week.ts +197 -0
  171. package/src/cpsat/rules/max-shifts-day.ts +198 -0
  172. package/src/cpsat/rules/min-consecutive-days.ts +140 -0
  173. package/src/cpsat/rules/min-hours-day.ts +69 -0
  174. package/src/cpsat/rules/min-hours-week.ts +77 -0
  175. package/src/cpsat/rules/min-rest-between-shifts.ts +121 -0
  176. package/src/cpsat/rules/registry.ts +49 -0
  177. package/src/cpsat/rules/resolver.ts +181 -0
  178. package/src/cpsat/rules/rules.types.ts +41 -0
  179. package/src/cpsat/rules/scoping.ts +340 -0
  180. package/src/cpsat/rules/time-off.ts +336 -0
  181. package/src/cpsat/rules.ts +27 -0
  182. package/src/cpsat/semantic-time.ts +463 -0
  183. package/src/cpsat/types.ts +194 -0
  184. package/src/cpsat/utils.ts +105 -0
  185. package/src/cpsat/validation-reporter.ts +366 -0
  186. package/src/cpsat/validation.types.ts +185 -0
  187. package/src/datetime.utils.ts +426 -0
  188. package/src/errors.ts +17 -0
  189. package/src/index.ts +289 -0
  190. package/src/llms.ts +9 -0
  191. package/src/testing/index.ts +12 -0
  192. package/src/testing/solver-container.ts +172 -0
  193. package/src/types.ts +191 -0
  194. package/src/validation.ts +188 -0
@@ -0,0 +1,858 @@
1
+ import type { SolverConstraint, SolverRequest, SolverVariable } from "../client.types.js";
2
+ import type { DayOfWeek, SchedulingPeriod } from "../types.js";
3
+ import { resolveDaysFromPeriod, toDayOfWeekUTC } from "../datetime.utils.js";
4
+ import {
5
+ MINUTES_PER_DAY,
6
+ normalizeEndMinutes,
7
+ OBJECTIVE_WEIGHTS,
8
+ parseDayString,
9
+ priorityToPenalty,
10
+ timeOfDayToMinutes,
11
+ } from "./utils.js";
12
+ import type {
13
+ CoverageRequirement,
14
+ ModelBuilderOptions,
15
+ ShiftPattern,
16
+ SchedulingEmployee,
17
+ Term,
18
+ } from "./types.js";
19
+ import type { CpsatRuleConfigEntry, CpsatRuleFactories } from "./rules/rules.types.js";
20
+ import { buildCpsatRules } from "./rules/resolver.js";
21
+ import { builtInCpsatRuleFactories } from "./rules/registry.js";
22
+ import { ValidationReporterImpl } from "./validation-reporter.js";
23
+ import type { ValidationReporter } from "./validation-reporter.js";
24
+ import type { ScheduleValidation, CoverageExclusion } from "./validation.types.js";
25
+ import type { ResolvedShiftAssignment } from "./response.js";
26
+
27
+ /**
28
+ * Builds a CP-SAT solver request from high-level scheduling constructs
29
+ * (team, shift patterns, coverage, and rule compilers).
30
+ */
31
+ /**
32
+ * Context provided to rules during post-solve validation.
33
+ */
34
+ export interface RuleValidationContext {
35
+ readonly employees: SchedulingEmployee[];
36
+ readonly days: string[];
37
+ readonly shiftPatterns: ShiftPattern[];
38
+ }
39
+
40
+ export interface CompilationRule {
41
+ compile(builder: ModelBuilder): void;
42
+ validate?(
43
+ assignments: ResolvedShiftAssignment[],
44
+ reporter: ValidationReporter,
45
+ context: RuleValidationContext,
46
+ ): void;
47
+ }
48
+
49
+ export interface CompilationResult {
50
+ request: SolverRequest;
51
+ validation: ScheduleValidation;
52
+ canSolve: boolean;
53
+ }
54
+
55
+ /**
56
+ * Configuration for ModelBuilder.
57
+ *
58
+ * @example Date range with day-of-week filtering (restaurant closed Mon/Tue)
59
+ * ```typescript
60
+ * const config: ModelBuilderConfig = {
61
+ * employees: [...],
62
+ * shiftPatterns: [...],
63
+ * coverage: [...],
64
+ * schedulingPeriod: {
65
+ * dateRange: { start: '2025-02-03', end: '2025-02-09' },
66
+ * daysOfWeek: ['wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
67
+ * },
68
+ * };
69
+ * ```
70
+ */
71
+ export interface ModelBuilderConfig extends ModelBuilderOptions {
72
+ employees: SchedulingEmployee[];
73
+ shiftPatterns: ShiftPattern[];
74
+ /**
75
+ * Defines when scheduling should occur. Can specify either a date range
76
+ * (with optional day-of-week filtering) or a list of specific dates.
77
+ */
78
+ schedulingPeriod: SchedulingPeriod;
79
+ coverage: CoverageRequirement[];
80
+ /**
81
+ * Pre-compiled rules; use this for custom rules that are not part of the registry.
82
+ */
83
+ rules?: CompilationRule[];
84
+ /**
85
+ * Named rule configurations that will be compiled using the provided rule factories.
86
+ */
87
+ ruleConfigs?: CpsatRuleConfigEntry[];
88
+ /**
89
+ * Rule factories to use when compiling ruleConfigs. Defaults to built-in CP-SAT rules.
90
+ */
91
+ ruleFactories?: CpsatRuleFactories;
92
+ /**
93
+ * Optional validation reporter for diagnostics.
94
+ */
95
+ reporter?: ValidationReporter;
96
+ }
97
+
98
+ /**
99
+ * Compilation context that creates variables, constraints, and objectives
100
+ * and emits a `SolverRequest` for the Python CP-SAT solver service.
101
+ */
102
+ export class ModelBuilder {
103
+ readonly employees: SchedulingEmployee[];
104
+ readonly shiftPatterns: ShiftPattern[];
105
+ readonly days: string[];
106
+ readonly coverage: CoverageRequirement[];
107
+ readonly rules: CompilationRule[];
108
+ readonly weekStartsOn: DayOfWeek;
109
+ readonly options: SolverRequest["options"] | undefined;
110
+ readonly coverageBucketMinutes: number;
111
+ readonly reporter: ValidationReporter;
112
+ readonly fairDistribution: boolean;
113
+
114
+ #variables = new Map<string, SolverVariable>();
115
+ #constraints: SolverConstraint[] = [];
116
+ #objective: Term[] = [];
117
+ #dayIndex = new Map<string, number>();
118
+ #shiftPatternMap = new Map<string, ShiftPattern>();
119
+ #builtRequest: SolverRequest | undefined;
120
+ #builtCompilation: CompilationResult | undefined;
121
+
122
+ constructor(config: ModelBuilderConfig) {
123
+ // Validate IDs don't contain the separator character
124
+ for (const emp of config.employees) {
125
+ if (emp.id.includes(":")) {
126
+ throw new Error(`Employee ID "${emp.id}" cannot contain colons`);
127
+ }
128
+ }
129
+ for (const pattern of config.shiftPatterns) {
130
+ if (pattern.id.includes(":")) {
131
+ throw new Error(`Shift pattern ID "${pattern.id}" cannot contain colons`);
132
+ }
133
+ }
134
+
135
+ // Validate coverage requirements have at least roleIds or skillIds
136
+ for (const cov of config.coverage) {
137
+ const hasRoles = cov.roleIds !== undefined && cov.roleIds.length > 0;
138
+ const hasSkills = cov.skillIds !== undefined && cov.skillIds.length > 0;
139
+ if (!hasRoles && !hasSkills) {
140
+ throw new Error(
141
+ `Coverage requirement for day "${cov.day}" must have at least one of roleIds or skillIds`,
142
+ );
143
+ }
144
+ }
145
+
146
+ this.employees = config.employees;
147
+ this.shiftPatterns = config.shiftPatterns;
148
+ this.days = resolveDaysFromPeriod(config.schedulingPeriod);
149
+ this.coverage = config.coverage;
150
+ const compiledRuleConfigs = config.ruleConfigs
151
+ ? buildCpsatRules(
152
+ config.ruleConfigs,
153
+ this.employees,
154
+ config.ruleFactories ?? builtInCpsatRuleFactories,
155
+ )
156
+ : [];
157
+ this.rules = [...compiledRuleConfigs, ...(config.rules ?? [])];
158
+ this.weekStartsOn = config.weekStartsOn ?? "monday";
159
+ this.options = config.solverOptions;
160
+ this.coverageBucketMinutes = config.coverageBucketMinutes ?? 15;
161
+ this.reporter = config.reporter ?? new ValidationReporterImpl();
162
+ this.fairDistribution = config.fairDistribution ?? true;
163
+
164
+ this.days.forEach((day, idx) => this.#dayIndex.set(day, idx));
165
+ this.shiftPatterns.forEach((pattern) => this.#shiftPatternMap.set(pattern.id, pattern));
166
+ }
167
+
168
+ boolVar(name: string): string {
169
+ const existing = this.#variables.get(name);
170
+ if (existing) {
171
+ if (existing.type !== "bool") {
172
+ throw new Error(`Variable ${name} already exists with different type`);
173
+ }
174
+ return name;
175
+ }
176
+
177
+ this.#variables.set(name, { type: "bool", name });
178
+ return name;
179
+ }
180
+
181
+ intVar(name: string, min: number, max: number): string {
182
+ const existing = this.#variables.get(name);
183
+ if (existing) {
184
+ if (existing.type !== "int" || existing.min !== min || existing.max !== max) {
185
+ throw new Error(`Variable ${name} already exists with different bounds`);
186
+ }
187
+ return name;
188
+ }
189
+
190
+ this.#variables.set(name, { type: "int", name, min, max });
191
+ return name;
192
+ }
193
+
194
+ shiftActive(patternId: string, day: string): string {
195
+ return this.boolVar(`shift:${patternId}:${day}`);
196
+ }
197
+
198
+ assignment(employeeId: string, patternId: string, day: string): string {
199
+ return this.boolVar(`assign:${employeeId}:${patternId}:${day}`);
200
+ }
201
+
202
+ addLinear(terms: Term[], op: "<=" | ">=" | "==", rhs: number): void {
203
+ this.#constraints.push({ type: "linear", terms, op, rhs });
204
+ }
205
+
206
+ addSoftLinear(terms: Term[], op: "<=" | ">=", rhs: number, penalty: number, id?: string): void {
207
+ this.#constraints.push({ type: "soft_linear", terms, op, rhs, penalty, id });
208
+ }
209
+
210
+ addExactlyOne(vars: string[]): void {
211
+ if (vars.length === 0) return;
212
+ this.#constraints.push({ type: "exactly_one", vars });
213
+ }
214
+
215
+ addAtMostOne(vars: string[]): void {
216
+ if (vars.length === 0) return;
217
+ this.#constraints.push({ type: "at_most_one", vars });
218
+ }
219
+
220
+ addImplication(ifVar: string, thenVar: string): void {
221
+ // oxlint-disable-next-line unicorn/no-thenable -- This is a constraint property, not a Promise
222
+ this.#constraints.push({ type: "implication", if: ifVar, then: thenVar });
223
+ }
224
+
225
+ addBoolOr(vars: string[]): void {
226
+ if (vars.length === 0) return;
227
+ this.#constraints.push({ type: "bool_or", vars });
228
+ }
229
+
230
+ addBoolAnd(vars: string[]): void {
231
+ if (vars.length === 0) return;
232
+ this.#constraints.push({ type: "bool_and", vars });
233
+ }
234
+
235
+ intervalVar(
236
+ name: string,
237
+ start: number,
238
+ end: number,
239
+ size: number,
240
+ presenceVar?: string,
241
+ ): string {
242
+ const existing = this.#variables.get(name);
243
+ if (existing) {
244
+ if (existing.type !== "interval") {
245
+ throw new Error(`Variable ${name} already exists with different type`);
246
+ }
247
+ if (
248
+ existing.start !== start ||
249
+ existing.end !== end ||
250
+ existing.size !== size ||
251
+ existing.presenceVar !== presenceVar
252
+ ) {
253
+ throw new Error(`Variable ${name} already exists with different parameters`);
254
+ }
255
+ return name;
256
+ }
257
+
258
+ this.#variables.set(name, {
259
+ type: "interval",
260
+ name,
261
+ start,
262
+ end,
263
+ size,
264
+ presenceVar,
265
+ });
266
+ return name;
267
+ }
268
+
269
+ addNoOverlap(intervals: string[]): void {
270
+ if (intervals.length === 0) return;
271
+ this.#constraints.push({ type: "no_overlap", intervals });
272
+ }
273
+
274
+ addPenalty(varName: string, weight: number): void {
275
+ if (weight === 0) return;
276
+ this.#objective.push({ var: varName, coeff: weight });
277
+ }
278
+
279
+ employeesWithRole(roleId: string): SchedulingEmployee[] {
280
+ return this.employees.filter((emp) => emp.roleIds.includes(roleId));
281
+ }
282
+
283
+ /**
284
+ * Returns team members who can satisfy a coverage requirement.
285
+ *
286
+ * Matching logic:
287
+ * - If only roleId: must have that role
288
+ * - If only skillIds: must have ALL specified skills
289
+ * - If both: must have the role AND ALL specified skills
290
+ */
291
+ employeesForCoverage(cov: CoverageRequirement): SchedulingEmployee[] {
292
+ return this.employees.filter((emp) => {
293
+ // Check role requirement if specified (OR logic - must have ANY of the roles)
294
+ if (cov.roleIds && cov.roleIds.length > 0) {
295
+ const hasMatchingRole = cov.roleIds.some((role) => emp.roleIds.includes(role));
296
+ if (!hasMatchingRole) {
297
+ return false;
298
+ }
299
+ }
300
+ // Check skill requirements if specified (AND logic - must have ALL skills)
301
+ if (cov.skillIds && cov.skillIds.length > 0) {
302
+ const empSkills = emp.skillIds ?? [];
303
+ if (!cov.skillIds.every((skill) => empSkills.includes(skill))) {
304
+ return false;
305
+ }
306
+ }
307
+ return true;
308
+ });
309
+ }
310
+
311
+ canAssign(employee: SchedulingEmployee, pattern: ShiftPattern): boolean {
312
+ // If pattern has no roleIds, anyone can work it
313
+ if (!pattern.roleIds || pattern.roleIds.length === 0) {
314
+ return true;
315
+ }
316
+ // Otherwise, must have at least one matching role
317
+ return pattern.roleIds.some((roleId) => employee.roleIds.includes(roleId));
318
+ }
319
+
320
+ /**
321
+ * Checks if a shift pattern can be used on a specific day.
322
+ * Returns false if the pattern has daysOfWeek restrictions that exclude this day.
323
+ */
324
+ patternAvailableOnDay(pattern: ShiftPattern, day: string): boolean {
325
+ // If pattern has no day restrictions, it's available every day
326
+ if (!pattern.daysOfWeek || pattern.daysOfWeek.length === 0) {
327
+ return true;
328
+ }
329
+ // Check if this day's day-of-week is in the allowed list
330
+ const date = parseDayString(day);
331
+ const dayOfWeek = toDayOfWeekUTC(date);
332
+ return pattern.daysOfWeek.includes(dayOfWeek);
333
+ }
334
+
335
+ patternDuration(patternId: string): number {
336
+ const pattern = this.#shiftPatternMap.get(patternId);
337
+ if (!pattern) throw new Error(`Unknown pattern ${patternId}`);
338
+
339
+ const start = timeOfDayToMinutes(pattern.startTime);
340
+ const end = normalizeEndMinutes(start, timeOfDayToMinutes(pattern.endTime));
341
+ return end - start;
342
+ }
343
+
344
+ startMinutes(pattern: ShiftPattern, day: string): number {
345
+ const base = this.#dayOffset(day);
346
+ return base + timeOfDayToMinutes(pattern.startTime);
347
+ }
348
+
349
+ endMinutes(pattern: ShiftPattern, day: string): number {
350
+ const base = this.#dayOffset(day);
351
+ const startOffset = timeOfDayToMinutes(pattern.startTime);
352
+ const endOffset = normalizeEndMinutes(startOffset, timeOfDayToMinutes(pattern.endTime));
353
+ return base + endOffset;
354
+ }
355
+
356
+ compile(): CompilationResult {
357
+ if (this.#builtCompilation) {
358
+ return this.#builtCompilation;
359
+ }
360
+
361
+ // 0. Apply all rules first (they may report exclusions and add constraints)
362
+ for (const rule of this.rules) {
363
+ rule.compile(this);
364
+ }
365
+
366
+ // Build exclusion lookup from reporter (populated by rules during compile)
367
+ const mandatoryExclusions = buildExclusionLookup(this.reporter.getExclusions());
368
+
369
+ // 1. Assignment implies shift is active
370
+ for (const emp of this.employees) {
371
+ for (const pattern of this.shiftPatterns) {
372
+ if (!this.canAssign(emp, pattern)) continue;
373
+ for (const day of this.days) {
374
+ if (!this.patternAvailableOnDay(pattern, day)) continue;
375
+ this.addImplication(
376
+ this.assignment(emp.id, pattern.id, day),
377
+ this.shiftActive(pattern.id, day),
378
+ );
379
+ }
380
+ }
381
+ }
382
+
383
+ // 1b. Build optional interval variables for assignments and prevent overlaps.
384
+ // One optional interval per (person, pattern, day) assignment.
385
+ for (const emp of this.employees) {
386
+ const empIntervals: string[] = [];
387
+
388
+ for (const pattern of this.shiftPatterns) {
389
+ if (!this.canAssign(emp, pattern)) continue;
390
+
391
+ for (const day of this.days) {
392
+ if (!this.patternAvailableOnDay(pattern, day)) continue;
393
+ const presenceVar = this.assignment(emp.id, pattern.id, day);
394
+ const start = this.startMinutes(pattern, day);
395
+ const end = this.endMinutes(pattern, day);
396
+ const size = end - start;
397
+
398
+ const intervalName = `interval:${emp.id}:${pattern.id}:${day}`;
399
+ this.intervalVar(intervalName, start, end, size, presenceVar);
400
+ empIntervals.push(intervalName);
401
+ }
402
+ }
403
+
404
+ this.addNoOverlap(empIntervals);
405
+ }
406
+
407
+ // 2. Coverage requirements (bucketed, time-indexed)
408
+ // Coverage requirements are expressed independently from shift patterns.
409
+ // We discretize each requirement into fixed-size buckets (default: 15 minutes)
410
+ // and ensure enough people are working in EACH bucket.
411
+ //
412
+ // This supports staggered overlapping shifts, where multiple patterns together
413
+ // can satisfy the coverage window.
414
+ const bucket = this.coverageBucketMinutes;
415
+ const allowedBuckets = new Set([5, 10, 15, 30, 60]);
416
+ if (!Number.isInteger(bucket) || !allowedBuckets.has(bucket)) {
417
+ throw new Error(
418
+ `coverageBucketMinutes must be one of ${[...allowedBuckets].join(", ")}, got ${bucket}`,
419
+ );
420
+ }
421
+
422
+ // Precompute pattern time ranges and which patterns overlap each bucket start.
423
+ // Patterns are day-invariant (same time-of-day every day), so we can compute once.
424
+ const patternRanges = this.shiftPatterns.map((p) => {
425
+ const start = timeOfDayToMinutes(p.startTime);
426
+ const end = normalizeEndMinutes(start, timeOfDayToMinutes(p.endTime));
427
+ return { pattern: p, start, end };
428
+ });
429
+
430
+ const patternsByBucketStart = new Map<number, ShiftPattern[]>();
431
+ for (let t = 0; t < MINUTES_PER_DAY; t += bucket) {
432
+ const bucketStart = t;
433
+ const bucketEnd = Math.min(t + bucket, MINUTES_PER_DAY);
434
+
435
+ const patterns: ShiftPattern[] = [];
436
+ for (const { pattern, start, end } of patternRanges) {
437
+ // Standard overlap check
438
+ const standardOverlap = Math.max(start, bucketStart) < Math.min(end, bucketEnd);
439
+ // Overnight pattern wrap-around check: if pattern ends past midnight (end > MINUTES_PER_DAY),
440
+ // the bucket also overlaps if it falls in the early morning portion (0 to end - MINUTES_PER_DAY)
441
+ const wrapAroundOverlap = end > MINUTES_PER_DAY && bucketStart < end - MINUTES_PER_DAY;
442
+ if (standardOverlap || wrapAroundOverlap) patterns.push(pattern);
443
+ }
444
+ patternsByBucketStart.set(bucketStart, patterns);
445
+ }
446
+
447
+ for (const cov of this.coverage) {
448
+ const covStart = timeOfDayToMinutes(cov.startTime);
449
+ const covEnd = normalizeEndMinutes(covStart, timeOfDayToMinutes(cov.endTime));
450
+ const covKey = cov.roleIds?.join(",") ?? cov.skillIds?.join(",") ?? "unknown";
451
+ const coverageLabel = cov.roleIds && cov.roleIds.length > 0
452
+ ? cov.roleIds.length === 1
453
+ ? `role "${cov.roleIds[0]}"`
454
+ : `role "${cov.roleIds.join(" or ")}"`
455
+ : `skills [${cov.skillIds?.join(", ")}]`;
456
+ const coverageWindow = formatTimeRange(covStart, covEnd);
457
+
458
+ const eligibleEmployees = this.employeesForCoverage(cov);
459
+ if (eligibleEmployees.length === 0) {
460
+ if (cov.priority === "MANDATORY" && cov.targetCount > 0) {
461
+ this.reporter.reportCoverageError({
462
+ day: cov.day,
463
+ timeSlots: [coverageWindow],
464
+ roleIds: cov.roleIds,
465
+ skillIds: cov.skillIds,
466
+ reason: `Coverage for ${coverageLabel} on ${cov.day} (${coverageWindow}) cannot be met: no eligible team members available.`,
467
+ suggestions: [
468
+ cov.roleIds && cov.roleIds.length > 0
469
+ ? `Add team members with role "${cov.roleIds.join(" or ")}"`
470
+ : "Add team members with the required skills",
471
+ "Change the coverage requirement to match available team members",
472
+ ],
473
+ groupKey: cov.groupKey,
474
+ });
475
+
476
+ const impossibleVar = `infeasible:coverage:${covKey}:${cov.day}`;
477
+ this.intVar(impossibleVar, 0, 0);
478
+ this.addLinear([{ var: impossibleVar, coeff: 1 }], ">=", cov.targetCount);
479
+ }
480
+ continue;
481
+ }
482
+
483
+ const bucketIssues = new Map<string, BucketIssueGroup>();
484
+
485
+ for (let t = covStart; t < covEnd; t += bucket) {
486
+ const bucketStart = t;
487
+ const bucketEnd = Math.min(t + bucket, covEnd);
488
+ // For overnight coverage (times >= MINUTES_PER_DAY), wrap around to find
489
+ // which shift patterns overlap. E.g., 25:00 wraps to 01:00.
490
+ const lookupBucketStart = bucketStart % MINUTES_PER_DAY;
491
+ // Get patterns that overlap this time bucket, then filter by day availability
492
+ const allPatterns = patternsByBucketStart.get(lookupBucketStart) ?? [];
493
+ const patterns = allPatterns.filter((p) => this.patternAvailableOnDay(p, cov.day));
494
+
495
+ if (patterns.length === 0) {
496
+ recordBucketIssue(
497
+ bucketIssues,
498
+ {
499
+ key: "no_patterns",
500
+ severity: cov.priority === "MANDATORY" ? "impossible" : "warning",
501
+ reason: "no shift patterns overlap this time",
502
+ suggestions: [
503
+ "Add shift patterns that overlap this coverage window",
504
+ "Adjust the coverage window to match available shifts",
505
+ ],
506
+ },
507
+ bucketStart,
508
+ );
509
+
510
+ if (cov.priority === "MANDATORY" && cov.targetCount > 0) {
511
+ const impossibleVar = `infeasible:coverage:${covKey}:${cov.day}:${bucketStart}`;
512
+ this.intVar(impossibleVar, 0, 0);
513
+ this.addLinear([{ var: impossibleVar, coeff: 1 }], ">=", cov.targetCount);
514
+ }
515
+ continue;
516
+ }
517
+
518
+ const assignableEmployees = new Set<string>();
519
+ for (const emp of eligibleEmployees) {
520
+ for (const pattern of patterns) {
521
+ if (!this.canAssign(emp, pattern)) continue;
522
+ assignableEmployees.add(emp.id);
523
+ break;
524
+ }
525
+ }
526
+
527
+ if (assignableEmployees.size === 0) {
528
+ recordBucketIssue(
529
+ bucketIssues,
530
+ {
531
+ key: "no_assignable",
532
+ severity: cov.priority === "MANDATORY" ? "impossible" : "warning",
533
+ reason: "no eligible team members can work overlapping shift patterns",
534
+ suggestions: [
535
+ "Adjust shift pattern role requirements",
536
+ "Add shift patterns that eligible team members can work",
537
+ ],
538
+ },
539
+ bucketStart,
540
+ );
541
+
542
+ if (cov.priority === "MANDATORY" && cov.targetCount > 0) {
543
+ const impossibleVar = `infeasible:coverage:${covKey}:${cov.day}:${bucketStart}`;
544
+ this.intVar(impossibleVar, 0, 0);
545
+ this.addLinear([{ var: impossibleVar, coeff: 1 }], ">=", cov.targetCount);
546
+ }
547
+ continue;
548
+ }
549
+
550
+ const availableEmployees = new Set<string>();
551
+ for (const employeeId of assignableEmployees) {
552
+ const exclusions = mandatoryExclusions.get(`${employeeId}:${cov.day}`) ?? [];
553
+ const blocked = exclusions.some((exclusion) =>
554
+ rangesOverlap(exclusion.startMinutes, exclusion.endMinutes, bucketStart, bucketEnd),
555
+ );
556
+ if (!blocked) {
557
+ availableEmployees.add(employeeId);
558
+ }
559
+ }
560
+
561
+ if (availableEmployees.size === 0) {
562
+ recordBucketIssue(
563
+ bucketIssues,
564
+ {
565
+ key: "mandatory_time_off",
566
+ severity: cov.priority === "MANDATORY" ? "impossible" : "warning",
567
+ reason: "all eligible team members are on mandatory time off",
568
+ suggestions: [
569
+ "Adjust mandatory time-off requests",
570
+ "Add more team members with the required role or skills",
571
+ ],
572
+ },
573
+ bucketStart,
574
+ );
575
+ } else if (availableEmployees.size < cov.targetCount) {
576
+ recordBucketIssue(
577
+ bucketIssues,
578
+ {
579
+ key: `insufficient:${availableEmployees.size}`,
580
+ severity: cov.priority === "MANDATORY" ? "impossible" : "warning",
581
+ reason: `only ${availableEmployees.size} team members available, need ${cov.targetCount}`,
582
+ suggestions: [
583
+ "Add more team members with the required role or skills",
584
+ `Reduce coverage target to ${availableEmployees.size}`,
585
+ ],
586
+ values: { required: cov.targetCount, available: availableEmployees.size },
587
+ },
588
+ bucketStart,
589
+ );
590
+ }
591
+
592
+ const coveringVarsSet = new Set<string>();
593
+ for (const pattern of patterns) {
594
+ for (const emp of eligibleEmployees) {
595
+ if (!this.canAssign(emp, pattern)) continue;
596
+ coveringVarsSet.add(this.assignment(emp.id, pattern.id, cov.day));
597
+ }
598
+ }
599
+
600
+ const coveringVars = [...coveringVarsSet];
601
+ if (coveringVars.length === 0) {
602
+ continue;
603
+ }
604
+
605
+ const terms = coveringVars.map((v) => ({ var: v, coeff: 1 }));
606
+
607
+ const constraintId = `coverage:${covKey}:${cov.day}:${bucketStart}`;
608
+
609
+ if (cov.priority === "MANDATORY") {
610
+ this.addLinear(terms, ">=", cov.targetCount);
611
+ } else {
612
+ this.addSoftLinear(
613
+ terms,
614
+ ">=",
615
+ cov.targetCount,
616
+ priorityToPenalty(cov.priority),
617
+ constraintId,
618
+ );
619
+ }
620
+
621
+ // Track all coverage constraints (mandatory and soft) for post-solve reporting
622
+ this.reporter.trackConstraint({
623
+ id: constraintId,
624
+ type: "coverage",
625
+ description: `${cov.targetCount}x ${covKey} on ${cov.day} at ${formatMinutes(bucketStart)}`,
626
+ targetValue: cov.targetCount,
627
+ comparator: ">=",
628
+ day: cov.day,
629
+ timeSlot: formatMinutes(bucketStart),
630
+ roleIds: cov.roleIds,
631
+ skillIds: cov.skillIds,
632
+ context: {
633
+ days: [cov.day],
634
+ employeeIds: eligibleEmployees.map((e) => e.id),
635
+ },
636
+ groupKey: cov.groupKey,
637
+ });
638
+ }
639
+
640
+ for (const issue of bucketIssues.values()) {
641
+ const ranges = bucketStartsToRanges(issue.bucketStarts, bucket, covEnd).map(
642
+ (range) => `${formatMinutes(range.start)}-${formatMinutes(range.end)}`,
643
+ );
644
+ if (ranges.length === 0) continue;
645
+
646
+ const reason = `Coverage for ${coverageLabel} on ${cov.day} (${ranges.join(", ")}) cannot be met: ${issue.reason}.`;
647
+
648
+ if (issue.severity === "impossible") {
649
+ this.reporter.reportCoverageError({
650
+ day: cov.day,
651
+ timeSlots: ranges,
652
+ roleIds: cov.roleIds,
653
+ skillIds: cov.skillIds,
654
+ reason,
655
+ suggestions: issue.suggestions,
656
+ groupKey: cov.groupKey,
657
+ });
658
+ }
659
+ // Note: soft coverage warnings are tracked via trackConstraint and reported post-solve
660
+ }
661
+ }
662
+
663
+ // 3. Default objective: shift minimization with optional fair distribution
664
+ //
665
+ // The objective has three components (see OBJECTIVE_WEIGHTS in utils.ts):
666
+ // a) Minimize number of active shift patterns (SHIFT_ACTIVE=1000)
667
+ // b) Fair distribution (FAIRNESS=5) - minimizes max shifts per person
668
+ // c) Minimize total assignments (ASSIGNMENT_BASE=1) - tiebreaker
669
+ //
670
+ // Weight hierarchy ensures:
671
+ // - SHIFT_ACTIVE >> ASSIGNMENT_PREFERENCE > FAIRNESS > ASSIGNMENT_BASE
672
+ // - Business preferences (±10/shift) override fairness (5 for max)
673
+ // - Fairness overrides pure tiebreaker behavior
674
+
675
+ // 3a. Minimize number of active shift patterns (reduces fragmentation)
676
+ for (const pattern of this.shiftPatterns) {
677
+ for (const day of this.days) {
678
+ if (!this.patternAvailableOnDay(pattern, day)) continue;
679
+ this.addPenalty(this.shiftActive(pattern.id, day), OBJECTIVE_WEIGHTS.SHIFT_ACTIVE);
680
+ }
681
+ }
682
+
683
+ // 3b. Fair distribution: minimize the maximum shifts any person works
684
+ //
685
+ // When enabled, we use the min-max approach: create an auxiliary variable
686
+ // representing the maximum shifts any person works, constrain each person's
687
+ // total to be <= this max, then minimize it.
688
+ //
689
+ // The FAIRNESS weight (5) is weaker than ASSIGNMENT_PREFERENCE (10), so explicit
690
+ // preferences like "prefer permanent staff over temps" will override fairness.
691
+ if (this.fairDistribution && this.employees.length > 1) {
692
+ const maxPossibleAssignments = this.days.length * this.shiftPatterns.length;
693
+ const maxAssignmentsVar = this.intVar("fairness:max_assignments", 0, maxPossibleAssignments);
694
+
695
+ for (const emp of this.employees) {
696
+ const terms: Term[] = [];
697
+ for (const pattern of this.shiftPatterns) {
698
+ if (!this.canAssign(emp, pattern)) continue;
699
+ for (const day of this.days) {
700
+ if (!this.patternAvailableOnDay(pattern, day)) continue;
701
+ terms.push({ var: this.assignment(emp.id, pattern.id, day), coeff: 1 });
702
+ }
703
+ }
704
+ if (terms.length > 0) {
705
+ // person's total assignments <= maxAssignmentsVar
706
+ terms.push({ var: maxAssignmentsVar, coeff: -1 });
707
+ this.addLinear(terms, "<=", 0);
708
+ }
709
+ }
710
+
711
+ this.addPenalty(maxAssignmentsVar, OBJECTIVE_WEIGHTS.FAIRNESS);
712
+ }
713
+
714
+ // 3c. Minimize total assignments (tiebreaker)
715
+ for (const emp of this.employees) {
716
+ for (const pattern of this.shiftPatterns) {
717
+ if (!this.canAssign(emp, pattern)) continue;
718
+ for (const day of this.days) {
719
+ if (!this.patternAvailableOnDay(pattern, day)) continue;
720
+ this.addPenalty(
721
+ this.assignment(emp.id, pattern.id, day),
722
+ OBJECTIVE_WEIGHTS.ASSIGNMENT_BASE,
723
+ );
724
+ }
725
+ }
726
+ }
727
+
728
+ this.#builtRequest = this.#buildRequest();
729
+ this.#builtCompilation = {
730
+ request: this.#builtRequest,
731
+ validation: this.reporter.getValidation(),
732
+ canSolve: !this.reporter.hasErrors(),
733
+ };
734
+ return this.#builtCompilation;
735
+ }
736
+
737
+ /**
738
+ * Run post-solve validation on all rules.
739
+ * Call this after solving with the resolved assignments.
740
+ */
741
+ validateSolution(assignments: ResolvedShiftAssignment[]): void {
742
+ const context: RuleValidationContext = {
743
+ employees: this.employees,
744
+ days: this.days,
745
+ shiftPatterns: this.shiftPatterns,
746
+ };
747
+
748
+ for (const rule of this.rules) {
749
+ rule.validate?.(assignments, this.reporter, context);
750
+ }
751
+ }
752
+
753
+ #buildRequest(): SolverRequest {
754
+ return {
755
+ variables: Array.from(this.#variables.values()),
756
+ constraints: this.#constraints,
757
+ objective:
758
+ this.#objective.length > 0 ? { sense: "minimize", terms: this.#objective } : undefined,
759
+ options: this.options,
760
+ };
761
+ }
762
+
763
+ #dayOffset(day: string): number {
764
+ const idx = this.#dayIndex.get(day);
765
+ if (idx === undefined) {
766
+ throw new Error(`Unknown day '${day}'`);
767
+ }
768
+ return idx * MINUTES_PER_DAY;
769
+ }
770
+ }
771
+
772
+ type BucketIssueGroup = {
773
+ key: string;
774
+ severity: "impossible" | "warning";
775
+ reason: string;
776
+ suggestions: string[];
777
+ bucketStarts: number[];
778
+ values?: Record<string, number>;
779
+ };
780
+
781
+ type ExclusionWindow = {
782
+ startMinutes: number;
783
+ endMinutes: number;
784
+ };
785
+
786
+ function recordBucketIssue(
787
+ bucketIssues: Map<string, BucketIssueGroup>,
788
+ issue: Omit<BucketIssueGroup, "bucketStarts">,
789
+ bucketStart: number,
790
+ ): void {
791
+ const existing = bucketIssues.get(issue.key);
792
+ if (existing) {
793
+ existing.bucketStarts.push(bucketStart);
794
+ return;
795
+ }
796
+ bucketIssues.set(issue.key, { ...issue, bucketStarts: [bucketStart] });
797
+ }
798
+
799
+ function buildExclusionLookup(exclusions: CoverageExclusion[]): Map<string, ExclusionWindow[]> {
800
+ const lookup = new Map<string, ExclusionWindow[]>();
801
+ for (const exclusion of exclusions) {
802
+ // If no time specified, exclude entire day
803
+ const startMinutes = exclusion.startTime ? timeOfDayToMinutes(exclusion.startTime) : 0;
804
+ const rawEndMinutes = exclusion.endTime
805
+ ? timeOfDayToMinutes(exclusion.endTime)
806
+ : MINUTES_PER_DAY;
807
+ const endMinutes = normalizeEndMinutes(startMinutes, rawEndMinutes);
808
+ const key = `${exclusion.employeeId}:${exclusion.day}`;
809
+ const existing = lookup.get(key);
810
+ const window = { startMinutes, endMinutes };
811
+ if (existing) {
812
+ existing.push(window);
813
+ } else {
814
+ lookup.set(key, [window]);
815
+ }
816
+ }
817
+ return lookup;
818
+ }
819
+
820
+ function rangesOverlap(startA: number, endA: number, startB: number, endB: number): boolean {
821
+ return Math.max(startA, startB) < Math.min(endA, endB);
822
+ }
823
+
824
+ function bucketStartsToRanges(
825
+ bucketStarts: number[],
826
+ bucketSize: number,
827
+ coverageEnd: number,
828
+ ): Array<{ start: number; end: number }> {
829
+ if (bucketStarts.length === 0) return [];
830
+ const sorted = [...bucketStarts].toSorted((a, b) => a - b);
831
+ const ranges: Array<{ start: number; end: number }> = [];
832
+ let currentStart = sorted[0] ?? 0;
833
+ let currentEnd = Math.min(currentStart + bucketSize, coverageEnd);
834
+
835
+ for (let i = 1; i < sorted.length; i++) {
836
+ const next = sorted[i];
837
+ if (next === undefined) continue;
838
+ if (next <= currentEnd) {
839
+ currentEnd = Math.min(next + bucketSize, coverageEnd);
840
+ } else {
841
+ ranges.push({ start: currentStart, end: currentEnd });
842
+ currentStart = next;
843
+ currentEnd = Math.min(next + bucketSize, coverageEnd);
844
+ }
845
+ }
846
+ ranges.push({ start: currentStart, end: currentEnd });
847
+ return ranges;
848
+ }
849
+
850
+ function formatMinutes(minutes: number): string {
851
+ const hours = Math.floor(minutes / 60);
852
+ const mins = minutes % 60;
853
+ return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
854
+ }
855
+
856
+ function formatTimeRange(start: number, end: number): string {
857
+ return `${formatMinutes(start)}-${formatMinutes(end)}`;
858
+ }