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,172 @@
1
+ /**
2
+ * Test utility for starting and managing the solver Docker container.
3
+ *
4
+ * This module provides a Node.js-compatible way to spin up the solver container
5
+ * for integration tests. It handles:
6
+ * - Building the Docker image (once per process)
7
+ * - Starting containers on dynamic ports
8
+ * - Health checking before returning
9
+ * - Cleanup on stop
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * import { startSolverContainer } from "dabke/testing";
14
+ *
15
+ * const solver = await startSolverContainer();
16
+ * // solver.client is ready to use
17
+ * const response = await solver.client.solve(request);
18
+ * solver.stop();
19
+ * ```
20
+ */
21
+
22
+ import { spawnSync } from "node:child_process";
23
+ import { createServer } from "node:net";
24
+ import { resolve } from "node:path";
25
+ import { fileURLToPath, URL as NodeURL } from "node:url";
26
+ import { setTimeout as delay } from "node:timers/promises";
27
+ import { HttpSolverClient } from "../client.js";
28
+
29
+ const __dirname = fileURLToPath(new NodeURL(".", import.meta.url));
30
+ // src/testing/ -> src/ -> package root -> solver/
31
+ const defaultSolverDir = resolve(__dirname, "..", "..", "solver");
32
+
33
+ let imageBuilt = false;
34
+
35
+ const allocatePort = (): Promise<number> =>
36
+ new Promise((resolvePort, reject) => {
37
+ const server = createServer();
38
+ server.unref();
39
+ server.on("error", (err) => {
40
+ server.close();
41
+ reject(err);
42
+ });
43
+ server.listen(0, () => {
44
+ const address = server.address();
45
+ if (typeof address === "object" && address) {
46
+ const { port } = address;
47
+ server.close(() => resolvePort(port));
48
+ } else {
49
+ server.close(() => reject(new Error("Could not allocate port")));
50
+ }
51
+ });
52
+ });
53
+
54
+ export interface SolverContainerOptions {
55
+ /** Port to expose the solver on. If not provided, a random available port is allocated. */
56
+ port?: number;
57
+ /** Directory containing the solver Dockerfile. Defaults to the solver/ directory in the package. */
58
+ solverDir?: string;
59
+ /** Docker image tag to use. Defaults to "dabke-solver:test". */
60
+ imageTag?: string;
61
+ /** Skip building the image (assume it already exists). */
62
+ skipBuild?: boolean;
63
+ }
64
+
65
+ export interface SolverContainer {
66
+ /** The port the solver is running on. */
67
+ port: number;
68
+ /** Pre-configured HTTP client for the solver. */
69
+ client: HttpSolverClient;
70
+ /** Stop and remove the container. */
71
+ stop: () => void;
72
+ /** Check if the solver is still healthy. Throws if not. */
73
+ ensureHealthy: () => Promise<void>;
74
+ }
75
+
76
+ const ensureSolverImage = (solverDir: string, imageTag: string) => {
77
+ if (imageBuilt) return;
78
+
79
+ const buildResult = spawnSync("docker", ["build", "-t", imageTag, "."], {
80
+ cwd: solverDir,
81
+ stdio: "inherit",
82
+ });
83
+
84
+ if (buildResult.error) {
85
+ throw new Error(`Docker build spawn error: ${buildResult.error.message}`);
86
+ }
87
+ if (buildResult.status !== 0) {
88
+ throw new Error(`Docker build failed with status ${buildResult.status}`);
89
+ }
90
+
91
+ imageBuilt = true;
92
+ };
93
+
94
+ /**
95
+ * Start a solver container for testing.
96
+ *
97
+ * Builds the Docker image if needed, starts a container on an available port,
98
+ * waits for the health endpoint to respond, and returns a client.
99
+ */
100
+ export const startSolverContainer = async (
101
+ options: SolverContainerOptions = {},
102
+ ): Promise<SolverContainer> => {
103
+ const {
104
+ port,
105
+ solverDir = defaultSolverDir,
106
+ imageTag = "dabke-solver:test",
107
+ skipBuild = false,
108
+ } = options;
109
+
110
+ if (!skipBuild) {
111
+ ensureSolverImage(solverDir, imageTag);
112
+ }
113
+
114
+ const resolvedPort = port ?? (await allocatePort());
115
+
116
+ const runResult = spawnSync("docker", [
117
+ "run",
118
+ "-d",
119
+ "--rm",
120
+ "-p",
121
+ `${resolvedPort}:8080`,
122
+ imageTag,
123
+ ]);
124
+
125
+ if (runResult.error) {
126
+ throw new Error(`Docker run spawn error: ${runResult.error.message}`);
127
+ }
128
+ if (runResult.status !== 0) {
129
+ throw new Error(`Docker run failed: ${runResult.stderr?.toString()}`);
130
+ }
131
+
132
+ const containerId = runResult.stdout.toString().trim();
133
+
134
+ const client = new HttpSolverClient(fetch, `http://localhost:${resolvedPort}`);
135
+
136
+ // Wait for container to be healthy (up to 30 seconds)
137
+ /* oxlint-disable no-await-in-loop -- Retry loop requires sequential attempts */
138
+ let attempts = 0;
139
+ while (attempts < 60) {
140
+ try {
141
+ await client.health?.();
142
+ break;
143
+ } catch {
144
+ attempts += 1;
145
+ await delay(500);
146
+ }
147
+ }
148
+ /* oxlint-enable no-await-in-loop */
149
+
150
+ if (attempts >= 60) {
151
+ // Clean up the container if health check never succeeded
152
+ spawnSync("docker", ["rm", "-f", containerId], { stdio: "ignore" });
153
+ throw new Error("Solver container failed to become healthy after 30 seconds");
154
+ }
155
+
156
+ return {
157
+ port: resolvedPort,
158
+ client,
159
+ stop: () => {
160
+ spawnSync("docker", ["rm", "-f", containerId], { stdio: "ignore" });
161
+ },
162
+ async ensureHealthy() {
163
+ try {
164
+ await client.health?.();
165
+ } catch (err) {
166
+ throw new Error(
167
+ `Solver container health check failed. Container may have crashed. Error: ${err}`,
168
+ );
169
+ }
170
+ },
171
+ };
172
+ };
package/src/types.ts ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Core scheduling types for the CP-SAT solver.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ import * as z from "zod";
8
+
9
+ // ============================================================================
10
+ // Time Primitives
11
+ // ============================================================================
12
+
13
+ export type DayOfWeek =
14
+ | "monday"
15
+ | "tuesday"
16
+ | "wednesday"
17
+ | "thursday"
18
+ | "friday"
19
+ | "saturday"
20
+ | "sunday";
21
+
22
+ /**
23
+ * Zod schema for {@link DayOfWeek}.
24
+ * Useful for rule configs that need to accept a day-of-week string.
25
+ */
26
+
27
+ export const DayOfWeekSchema = z.union([
28
+ z.literal("monday"),
29
+ z.literal("tuesday"),
30
+ z.literal("wednesday"),
31
+ z.literal("thursday"),
32
+ z.literal("friday"),
33
+ z.literal("saturday"),
34
+ z.literal("sunday"),
35
+ ]);
36
+
37
+ /**
38
+ * Time of day representation (hours and minutes, with optional seconds/nanos).
39
+ *
40
+ * Used for defining shift start/end times and semantic time boundaries.
41
+ * Hours are in 24-hour format (0-23).
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * const morningStart: TimeOfDay = {
46
+ * hours: 9,
47
+ * minutes: 0
48
+ * };
49
+ *
50
+ * const afternoonEnd: TimeOfDay = {
51
+ * hours: 17,
52
+ * minutes: 30
53
+ * };
54
+ * ```
55
+ */
56
+ export interface TimeOfDay {
57
+ hours: number;
58
+ minutes: number;
59
+ seconds?: number;
60
+ nanos?: number;
61
+ }
62
+
63
+ /**
64
+ * Calendar date representation (year, month, day).
65
+ *
66
+ * @example
67
+ * ```typescript
68
+ * const christmas: CalendarDate = {
69
+ * year: 2025,
70
+ * month: 12,
71
+ * day: 25
72
+ * };
73
+ * ```
74
+ */
75
+ export interface CalendarDate {
76
+ year: number;
77
+ month: number;
78
+ day: number;
79
+ }
80
+
81
+ /**
82
+ * Time horizon defining the start and end dates for scheduling.
83
+ *
84
+ * Specifies the date range over which the schedule should be generated.
85
+ * The range is inclusive of start date and exclusive of end date.
86
+ *
87
+ * @example
88
+ * ```typescript
89
+ * // One week schedule starting Monday, March 3, 2025
90
+ * const horizon: TimeHorizon = {
91
+ * start: new Date('2025-03-03'), // Monday
92
+ * end: new Date('2025-03-10') // Following Monday (exclusive)
93
+ * };
94
+ * ```
95
+ */
96
+ export interface TimeHorizon {
97
+ start: Date;
98
+ end: Date;
99
+ }
100
+
101
+ // ============================================================================
102
+ // DateTime Types
103
+ // ============================================================================
104
+
105
+ export interface DateTimeComponents extends Partial<CalendarDate>, Partial<TimeOfDay> {}
106
+
107
+ interface DateTimeWithUtcOffset extends DateTimeComponents {
108
+ utcOffset?: string;
109
+ timeZone?: never;
110
+ }
111
+
112
+ interface DateTimeWithTimeZone extends DateTimeComponents {
113
+ timeZone?: {
114
+ id: string;
115
+ version: string;
116
+ };
117
+ utcOffset?: never;
118
+ }
119
+
120
+ /**
121
+ * Date and time representation supporting both UTC offset and timezone-aware formats.
122
+ *
123
+ * Can be specified either with a UTC offset (e.g., "-08:00") or with a timezone ID
124
+ * (e.g., "America/Los_Angeles").
125
+ */
126
+ export type DateTime = DateTimeWithUtcOffset | DateTimeWithTimeZone;
127
+
128
+ /**
129
+ * Represents a time range with start and end DateTimes.
130
+ * Used for checking overlaps and scheduling constraints.
131
+ */
132
+ export interface DateTimeRange {
133
+ start: DateTime;
134
+ end: DateTime;
135
+ }
136
+
137
+ // ============================================================================
138
+ // Scheduling Period
139
+ // ============================================================================
140
+
141
+ /**
142
+ * Defines a scheduling period either as a date range or specific dates.
143
+ *
144
+ * Use this to specify when scheduling should occur. This is more expressive
145
+ * than a simple list of days because it can filter by day-of-week.
146
+ *
147
+ * @example Date range with day-of-week filtering (restaurant closed Mon/Tue)
148
+ * ```typescript
149
+ * const period: SchedulingPeriod = {
150
+ * dateRange: { start: '2025-02-03', end: '2025-02-09' },
151
+ * daysOfWeek: ['wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
152
+ * };
153
+ * ```
154
+ *
155
+ * @example Date range for all days
156
+ * ```typescript
157
+ * const period: SchedulingPeriod = {
158
+ * dateRange: { start: '2025-02-03', end: '2025-02-09' },
159
+ * };
160
+ * ```
161
+ *
162
+ * @example Specific dates (non-contiguous or custom selection)
163
+ * ```typescript
164
+ * const period: SchedulingPeriod = {
165
+ * specificDates: ['2025-02-05', '2025-02-07', '2025-02-10'],
166
+ * };
167
+ * ```
168
+ */
169
+ export type SchedulingPeriod =
170
+ | {
171
+ /**
172
+ * A contiguous date range (start and end are inclusive).
173
+ * Dates should be in YYYY-MM-DD format.
174
+ */
175
+ dateRange: { start: string; end: string };
176
+ /**
177
+ * Optional filter to include only specific days of the week.
178
+ * If omitted, all days in the range are included.
179
+ */
180
+ daysOfWeek?: DayOfWeek[];
181
+ specificDates?: never;
182
+ }
183
+ | {
184
+ /**
185
+ * A list of specific dates to schedule.
186
+ * Dates should be in YYYY-MM-DD format.
187
+ */
188
+ specificDates: string[];
189
+ dateRange?: never;
190
+ daysOfWeek?: never;
191
+ };
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Validation utilities for scheduling configuration.
3
+ *
4
+ * These functions help catch configuration errors early, before the ModelBuilder
5
+ * attempts to compile the scheduling problem. This is especially useful when
6
+ * configuration is generated by an LLM, which may hallucinate role or skill names.
7
+ */
8
+
9
+ import type { CoverageRequirement, SchedulingEmployee } from "./cpsat/types.js";
10
+
11
+ /**
12
+ * Result of coverage role validation.
13
+ */
14
+ export interface CoverageValidationResult {
15
+ valid: boolean;
16
+ /** Role IDs used in coverage that don't match any team member */
17
+ unknownRoles: string[];
18
+ /** Role IDs used in coverage that match team members */
19
+ knownRoles: string[];
20
+ }
21
+
22
+ /**
23
+ * Validates that all roleIds used in coverage requirements match the team.
24
+ *
25
+ * This catches a common LLM error where the model generates coverage requirements
26
+ * using role names that don't match any team member's roleIds. Without this validation,
27
+ * such mismatches would result in valid but semantically wrong schedules (e.g.,
28
+ * coverage requirements that no one can satisfy).
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * const employees = [
33
+ * { id: "alice", roleIds: ["cashier"] },
34
+ * { id: "bob", roleIds: ["stocker"] },
35
+ * ];
36
+ *
37
+ * const coverage = [
38
+ * { roleId: "cashier", targetCount: 1, ... }, // OK
39
+ * { roleId: "worker", targetCount: 1, ... }, // Unknown role!
40
+ * ];
41
+ *
42
+ * const result = validateCoverageRoles(coverage, employees);
43
+ * // result.valid = false
44
+ * // result.unknownRoles = ["worker"]
45
+ * // result.knownRoles = ["cashier"]
46
+ * ```
47
+ */
48
+ export function validateCoverageRoles(
49
+ coverage: CoverageRequirement[],
50
+ employees: SchedulingEmployee[],
51
+ ): CoverageValidationResult {
52
+ const employeeRoles = new Set(employees.flatMap((e) => e.roleIds));
53
+ const coverageRoles = new Set(coverage.flatMap((c) => c.roleIds ?? []));
54
+
55
+ const unknownRoles: string[] = [];
56
+ const knownRoles: string[] = [];
57
+
58
+ for (const role of coverageRoles) {
59
+ if (employeeRoles.has(role)) {
60
+ knownRoles.push(role);
61
+ } else {
62
+ unknownRoles.push(role);
63
+ }
64
+ }
65
+
66
+ return {
67
+ valid: unknownRoles.length === 0,
68
+ unknownRoles: unknownRoles.toSorted(),
69
+ knownRoles: knownRoles.toSorted(),
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Result of coverage skill validation.
75
+ */
76
+ export interface SkillValidationResult {
77
+ valid: boolean;
78
+ /** Skill IDs used in coverage that don't match any team member */
79
+ unknownSkills: string[];
80
+ /** Skill IDs used in coverage that match team members */
81
+ knownSkills: string[];
82
+ }
83
+
84
+ /**
85
+ * Validates that all skillIds used in coverage requirements match the team.
86
+ *
87
+ * Similar to role validation, this catches LLM hallucinations where skill names
88
+ * in coverage don't match any team member's skillIds.
89
+ *
90
+ * @example
91
+ * ```typescript
92
+ * const employees = [
93
+ * { id: "alice", roleIds: ["server"], skillIds: ["keyholder"] },
94
+ * { id: "bob", roleIds: ["server"] },
95
+ * ];
96
+ *
97
+ * const coverage = [
98
+ * { skillIds: ["keyholder"], targetCount: 1, ... }, // OK
99
+ * { skillIds: ["manager"], targetCount: 1, ... }, // Unknown skill!
100
+ * ];
101
+ *
102
+ * const result = validateCoverageSkills(coverage, employees);
103
+ * // result.valid = false
104
+ * // result.unknownSkills = ["manager"]
105
+ * ```
106
+ */
107
+ export function validateCoverageSkills(
108
+ coverage: CoverageRequirement[],
109
+ employees: SchedulingEmployee[],
110
+ ): SkillValidationResult {
111
+ const employeeSkills = new Set(employees.flatMap((e) => e.skillIds ?? []));
112
+ const coverageSkills = new Set(coverage.flatMap((c) => c.skillIds ?? []));
113
+
114
+ const unknownSkills: string[] = [];
115
+ const knownSkills: string[] = [];
116
+
117
+ for (const skill of coverageSkills) {
118
+ if (employeeSkills.has(skill)) {
119
+ knownSkills.push(skill);
120
+ } else {
121
+ unknownSkills.push(skill);
122
+ }
123
+ }
124
+
125
+ return {
126
+ valid: unknownSkills.length === 0,
127
+ unknownSkills: unknownSkills.toSorted(),
128
+ knownSkills: knownSkills.toSorted(),
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Combined validation result for coverage requirements.
134
+ */
135
+ export interface CoverageConfigValidationResult {
136
+ valid: boolean;
137
+ roles: CoverageValidationResult;
138
+ skills: SkillValidationResult;
139
+ /** Human-readable error messages */
140
+ errors: string[];
141
+ }
142
+
143
+ /**
144
+ * Validates coverage requirements against team roles and skills.
145
+ *
146
+ * This is the primary validation function to call before building a scheduling model.
147
+ * It checks both roles and skills, returning a combined result with error messages.
148
+ *
149
+ * @example
150
+ * ```typescript
151
+ * const result = validateCoverageConfig(coverage, employees);
152
+ * if (!result.valid) {
153
+ * throw new Error(result.errors.join("; "));
154
+ * }
155
+ * ```
156
+ */
157
+ export function validateCoverageConfig(
158
+ coverage: CoverageRequirement[],
159
+ employees: SchedulingEmployee[],
160
+ ): CoverageConfigValidationResult {
161
+ const roles = validateCoverageRoles(coverage, employees);
162
+ const skills = validateCoverageSkills(coverage, employees);
163
+
164
+ const errors: string[] = [];
165
+
166
+ if (!roles.valid) {
167
+ const availableRoles = [...new Set(employees.flatMap((e) => e.roleIds))].toSorted();
168
+ errors.push(
169
+ `Coverage uses unknown roles: ${roles.unknownRoles.join(", ")}. ` +
170
+ `Available roles: ${availableRoles.join(", ")}`,
171
+ );
172
+ }
173
+
174
+ if (!skills.valid) {
175
+ const availableSkills = [...new Set(employees.flatMap((e) => e.skillIds ?? []))].toSorted();
176
+ errors.push(
177
+ `Coverage uses unknown skills: ${skills.unknownSkills.join(", ")}. ` +
178
+ `Available skills: ${availableSkills.length > 0 ? availableSkills.join(", ") : "(none)"}`,
179
+ );
180
+ }
181
+
182
+ return {
183
+ valid: roles.valid && skills.valid,
184
+ roles,
185
+ skills,
186
+ errors,
187
+ };
188
+ }