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,105 @@
1
+ import { DAY_OF_WEEK_MAP } from "../datetime.utils.js";
2
+ import type { DayOfWeek, TimeOfDay } from "../types.js";
3
+ import type { Priority } from "./types.js";
4
+
5
+ export const MINUTES_PER_DAY = 24 * 60;
6
+
7
+ /**
8
+ * Standard objective weights for the scheduling solver.
9
+ *
10
+ * These weights define the relative importance of different objectives.
11
+ * Higher weights mean stronger preference. Rules can use these as reference
12
+ * points when adding their own penalties.
13
+ *
14
+ * Weight hierarchy (highest to lowest priority):
15
+ * - SHIFT_ACTIVE (1000): Minimize number of active shift patterns
16
+ * - ASSIGNMENT_PREFERENCE (10): Per-assignment preference (e.g., prefer permanent staff)
17
+ * - FAIRNESS (5): Fair distribution of shifts across team members
18
+ * - ASSIGNMENT_BASE (1): Tiebreaker - minimize total assignments
19
+ *
20
+ * @example Using weights in a custom rule
21
+ * ```ts
22
+ * import { OBJECTIVE_WEIGHTS } from "feasible";
23
+ *
24
+ * // Prefer senior staff with same weight as employee-assignment-priority
25
+ * b.addPenalty(assignment, -OBJECTIVE_WEIGHTS.ASSIGNMENT_PREFERENCE);
26
+ *
27
+ * // Strong preference (2x normal)
28
+ * b.addPenalty(assignment, -2 * OBJECTIVE_WEIGHTS.ASSIGNMENT_PREFERENCE);
29
+ * ```
30
+ */
31
+ export const OBJECTIVE_WEIGHTS = {
32
+ /** Weight for minimizing active shift patterns (reduces fragmentation) */
33
+ SHIFT_ACTIVE: 1000,
34
+ /** Weight for per-assignment preferences (e.g., prefer/avoid certain team members) */
35
+ ASSIGNMENT_PREFERENCE: 10,
36
+ /** Weight for fair distribution objective (minimizes max shifts per employee) */
37
+ FAIRNESS: 5,
38
+ /** Base weight per assignment (tiebreaker) */
39
+ ASSIGNMENT_BASE: 1,
40
+ } as const;
41
+
42
+ /**
43
+ * Parse a day string (YYYY-MM-DD) to a UTC Date.
44
+ * Used internally for day-of-week calculations and date comparisons.
45
+ */
46
+ export function parseDayString(day: string): Date {
47
+ return new Date(`${day}T00:00:00Z`);
48
+ }
49
+
50
+ export function timeOfDayToMinutes(time: TimeOfDay): number {
51
+ return time.hours * 60 + (time.minutes ?? 0);
52
+ }
53
+
54
+ export function normalizeEndMinutes(startMinutes: number, endMinutes: number): number {
55
+ if (endMinutes === startMinutes) return endMinutes + MINUTES_PER_DAY;
56
+ return endMinutes < startMinutes ? endMinutes + MINUTES_PER_DAY : endMinutes;
57
+ }
58
+
59
+ export function priorityToPenalty(priority: Priority): number {
60
+ switch (priority) {
61
+ case "HIGH":
62
+ return 25;
63
+ case "MEDIUM":
64
+ return 10;
65
+ case "LOW":
66
+ return 1;
67
+ case "MANDATORY":
68
+ return 0;
69
+ default:
70
+ return 0;
71
+ }
72
+ }
73
+
74
+ export function splitIntoWeeks(days: string[], weekStartsOn: DayOfWeek): string[][] {
75
+ if (days.length === 0) return [];
76
+
77
+ const weekStartIndex = DAY_OF_WEEK_MAP[weekStartsOn];
78
+ const result: string[][] = [];
79
+ let currentWeek: string[] = [];
80
+ let currentWeekStart: Date | null = null;
81
+
82
+ for (const day of days) {
83
+ const date = parseDayString(day);
84
+ const isWeekStartDay = date.getUTCDay() === weekStartIndex;
85
+ const isNewWeek =
86
+ isWeekStartDay && currentWeekStart !== null && date.getTime() !== currentWeekStart.getTime();
87
+
88
+ if (isNewWeek) {
89
+ result.push(currentWeek);
90
+ currentWeek = [];
91
+ currentWeekStart = null;
92
+ }
93
+
94
+ if (currentWeekStart === null) {
95
+ currentWeekStart = date;
96
+ }
97
+ currentWeek.push(day);
98
+ }
99
+
100
+ if (currentWeek.length > 0) {
101
+ result.push(currentWeek);
102
+ }
103
+
104
+ return result;
105
+ }
@@ -0,0 +1,366 @@
1
+ import type { SolverResponse } from "../client.types.js";
2
+ import {
3
+ groupKey,
4
+ type ScheduleError,
5
+ type ScheduleViolation,
6
+ type SchedulePassed,
7
+ type ScheduleValidation,
8
+ type TrackedConstraint,
9
+ type CoverageError,
10
+ type CoverageViolation,
11
+ type CoveragePassed,
12
+ type RuleError,
13
+ type RuleViolation,
14
+ type RulePassed,
15
+ type CoverageExclusion,
16
+ type ValidationSummary,
17
+ type GroupKey,
18
+ } from "./validation.types.js";
19
+
20
+ export interface ValidationReporter {
21
+ // Coverage exclusions (compile-time, for feasibility analysis)
22
+ excludeFromCoverage(exclusion: CoverageExclusion): void;
23
+
24
+ // Errors (block generation)
25
+ reportCoverageError(error: Omit<CoverageError, "type" | "id">): void;
26
+ reportRuleError(error: Omit<RuleError, "type" | "id">): void;
27
+ reportSolverError(reason: string): void;
28
+
29
+ // Violations (soft constraint issues)
30
+ reportCoverageViolation(violation: Omit<CoverageViolation, "type" | "id">): void;
31
+ reportRuleViolation(violation: Omit<RuleViolation, "type" | "id">): void;
32
+
33
+ // Passed (confidence builders)
34
+ reportCoveragePassed(passed: Omit<CoveragePassed, "type" | "id">): void;
35
+ reportRulePassed(passed: Omit<RulePassed, "type" | "id">): void;
36
+
37
+ // Constraint tracking for post-solve analysis
38
+ trackConstraint(constraint: TrackedConstraint): void;
39
+
40
+ // Query methods
41
+ hasErrors(): boolean;
42
+ getValidation(): ScheduleValidation;
43
+ getExclusions(): CoverageExclusion[];
44
+
45
+ // Post-solve analysis
46
+ analyzeSolution(response: SolverResponse): void;
47
+ }
48
+
49
+ /**
50
+ * Generates a deterministic ID for a coverage-based validation item.
51
+ * Format: {category}:coverage:{day}:{timeSlots}:{roleIds}:{skillIds}
52
+ */
53
+ function coverageId(
54
+ category: "error" | "violation" | "passed",
55
+ day: string,
56
+ timeSlots: readonly string[],
57
+ roleIds?: readonly string[],
58
+ skillIds?: readonly string[],
59
+ ): string {
60
+ const parts = [
61
+ category,
62
+ "coverage",
63
+ day,
64
+ [...timeSlots].toSorted().join(",") || "_",
65
+ roleIds && roleIds.length > 0 ? [...roleIds].toSorted().join(",") : "_",
66
+ skillIds ? [...skillIds].toSorted().join(",") : "_",
67
+ ];
68
+ return parts.join(":");
69
+ }
70
+
71
+ /**
72
+ * Generates a deterministic ID for a rule-based validation item.
73
+ * Format: {category}:rule:{rule}:{days}:{employeeIds}
74
+ */
75
+ function ruleId(
76
+ category: "error" | "violation" | "passed",
77
+ rule: string,
78
+ context: { days?: readonly string[]; employeeIds?: readonly string[] },
79
+ ): string {
80
+ const parts = [
81
+ category,
82
+ "rule",
83
+ rule,
84
+ context.days ? [...context.days].toSorted().join(",") : "_",
85
+ context.employeeIds ? [...context.employeeIds].toSorted().join(",") : "_",
86
+ ];
87
+ return parts.join(":");
88
+ }
89
+
90
+ export class ValidationReporterImpl implements ValidationReporter {
91
+ #errors: ScheduleError[] = [];
92
+ #violations: ScheduleViolation[] = [];
93
+ #passed: SchedulePassed[] = [];
94
+ #trackedConstraints = new Map<string, TrackedConstraint>();
95
+ #exclusions: CoverageExclusion[] = [];
96
+ #solverErrorCount = 0;
97
+
98
+ excludeFromCoverage(exclusion: CoverageExclusion): void {
99
+ this.#exclusions.push(exclusion);
100
+ }
101
+
102
+ reportCoverageError(error: Omit<CoverageError, "type" | "id">): void {
103
+ const id = coverageId("error", error.day, error.timeSlots, error.roleIds, error.skillIds);
104
+ this.#errors.push({ id, type: "coverage", ...error });
105
+ }
106
+
107
+ reportRuleError(error: Omit<RuleError, "type" | "id">): void {
108
+ const id = ruleId("error", error.rule, error.context);
109
+ this.#errors.push({ id, type: "rule", ...error });
110
+ }
111
+
112
+ reportSolverError(reason: string): void {
113
+ this.#solverErrorCount++;
114
+ const id = `error:solver:${this.#solverErrorCount}`;
115
+ this.#errors.push({ id, type: "solver", reason });
116
+ }
117
+
118
+ reportCoverageViolation(violation: Omit<CoverageViolation, "type" | "id">): void {
119
+ const id = coverageId(
120
+ "violation",
121
+ violation.day,
122
+ violation.timeSlots,
123
+ violation.roleIds,
124
+ violation.skillIds,
125
+ );
126
+ this.#violations.push({ id, type: "coverage", ...violation });
127
+ }
128
+
129
+ reportRuleViolation(violation: Omit<RuleViolation, "type" | "id">): void {
130
+ const id = ruleId("violation", violation.rule, violation.context);
131
+ this.#violations.push({ id, type: "rule", ...violation });
132
+ }
133
+
134
+ reportCoveragePassed(passed: Omit<CoveragePassed, "type" | "id">): void {
135
+ const id = coverageId("passed", passed.day, passed.timeSlots, passed.roleIds, passed.skillIds);
136
+ this.#passed.push({ id, type: "coverage", ...passed });
137
+ }
138
+
139
+ reportRulePassed(passed: Omit<RulePassed, "type" | "id">): void {
140
+ const id = ruleId("passed", passed.rule, passed.context);
141
+ this.#passed.push({ id, type: "rule", ...passed });
142
+ }
143
+
144
+ getExclusions(): CoverageExclusion[] {
145
+ return [...this.#exclusions];
146
+ }
147
+
148
+ trackConstraint(constraint: TrackedConstraint): void {
149
+ this.#trackedConstraints.set(constraint.id, constraint);
150
+ }
151
+
152
+ hasErrors(): boolean {
153
+ return this.#errors.length > 0;
154
+ }
155
+
156
+ getValidation(): ScheduleValidation {
157
+ return {
158
+ errors: [...this.#errors],
159
+ violations: [...this.#violations],
160
+ passed: [...this.#passed],
161
+ };
162
+ }
163
+
164
+ getTrackedConstraints(): TrackedConstraint[] {
165
+ return [...this.#trackedConstraints.values()];
166
+ }
167
+
168
+ analyzeSolution(response: SolverResponse): void {
169
+ if (response.status !== "OPTIMAL" && response.status !== "FEASIBLE") {
170
+ if (response.status === "INFEASIBLE") {
171
+ this.reportSolverError(response.solutionInfo ?? "Schedule is infeasible");
172
+ } else if (response.status === "TIMEOUT") {
173
+ this.reportSolverError("Solver timed out");
174
+ } else if (response.error) {
175
+ this.reportSolverError(response.error);
176
+ }
177
+ return;
178
+ }
179
+
180
+ const solverViolations = response.softViolations ?? [];
181
+
182
+ for (const violation of solverViolations) {
183
+ const tracked = this.#trackedConstraints.get(violation.constraintId);
184
+
185
+ if (tracked?.type === "coverage") {
186
+ this.reportCoverageViolation({
187
+ day: tracked.day ?? "",
188
+ timeSlots: tracked.timeSlot ? [tracked.timeSlot] : [],
189
+ roleIds: tracked.roleIds,
190
+ skillIds: tracked.skillIds,
191
+ targetCount: violation.targetValue,
192
+ actualCount: violation.actualValue,
193
+ shortfall: violation.violationAmount,
194
+ groupKey: tracked.groupKey,
195
+ });
196
+ } else if (tracked?.type === "rule") {
197
+ const isShortfall = tracked.comparator === ">=";
198
+ this.reportRuleViolation({
199
+ rule: tracked.rule ?? "unknown",
200
+ reason: `${tracked.description}: needed ${violation.targetValue}, got ${violation.actualValue}`,
201
+ context: tracked.context,
202
+ shortfall: isShortfall ? violation.violationAmount : undefined,
203
+ overflow: !isShortfall ? violation.violationAmount : undefined,
204
+ groupKey: tracked.groupKey,
205
+ });
206
+ } else {
207
+ // Unknown constraint - create generic rule violation
208
+ this.reportRuleViolation({
209
+ rule: "unknown",
210
+ reason: `Constraint ${violation.constraintId} violated by ${violation.violationAmount}`,
211
+ context: {},
212
+ });
213
+ }
214
+ }
215
+
216
+ // Mark tracked coverage constraints as passed if not violated
217
+ const violatedIds = new Set(solverViolations.map((v) => v.constraintId));
218
+ for (const tracked of this.#trackedConstraints.values()) {
219
+ if (violatedIds.has(tracked.id)) continue;
220
+
221
+ if (tracked.type === "coverage") {
222
+ this.reportCoveragePassed({
223
+ day: tracked.day ?? "",
224
+ timeSlots: tracked.timeSlot ? [tracked.timeSlot] : [],
225
+ roleIds: tracked.roleIds,
226
+ skillIds: tracked.skillIds,
227
+ description: tracked.description,
228
+ groupKey: tracked.groupKey,
229
+ });
230
+ }
231
+ }
232
+ }
233
+ }
234
+
235
+ // =============================================================================
236
+ // Validation Summary - pure function for aggregation
237
+ // =============================================================================
238
+
239
+ type ValidationItem = ScheduleError | ScheduleViolation | SchedulePassed;
240
+
241
+ /**
242
+ * Aggregates validation items by their groupKey into summaries.
243
+ * This is a pure function that doesn't modify the input.
244
+ *
245
+ * Items without a groupKey are grouped by their ID (ungrouped).
246
+ *
247
+ * @example
248
+ * ```typescript
249
+ * const validation = reporter.getValidation();
250
+ * const summaries = summarizeValidation(validation);
251
+ * // summaries[0] = {
252
+ * // groupKey: "2x waiter during lunch",
253
+ * // status: "passed",
254
+ * // passedCount: 180,
255
+ * // days: ["2026-02-02", "2026-02-03", ...]
256
+ * // }
257
+ * ```
258
+ */
259
+ export function summarizeValidation(validation: ScheduleValidation): readonly ValidationSummary[] {
260
+ const groups = new Map<
261
+ GroupKey,
262
+ {
263
+ type: "coverage" | "rule";
264
+ items: ValidationItem[];
265
+ days: Set<string>;
266
+ passedCount: number;
267
+ violatedCount: number;
268
+ errorCount: number;
269
+ }
270
+ >();
271
+
272
+ const getOrCreateGroup = (key: GroupKey, type: "coverage" | "rule") => {
273
+ if (!groups.has(key)) {
274
+ groups.set(key, {
275
+ type,
276
+ items: [],
277
+ days: new Set(),
278
+ passedCount: 0,
279
+ violatedCount: 0,
280
+ errorCount: 0,
281
+ });
282
+ }
283
+ return groups.get(key)!;
284
+ };
285
+
286
+ // Group passed items
287
+ for (const item of validation.passed) {
288
+ const key = item.groupKey ?? groupKey(`ungrouped:${item.id}`);
289
+ const group = getOrCreateGroup(key, item.type);
290
+ group.items.push(item);
291
+ group.passedCount++;
292
+ if (item.type === "coverage" && item.day) {
293
+ group.days.add(item.day);
294
+ }
295
+ }
296
+
297
+ // Group violations
298
+ for (const item of validation.violations) {
299
+ const key = item.groupKey ?? groupKey(`ungrouped:${item.id}`);
300
+ const group = getOrCreateGroup(key, item.type);
301
+ group.items.push(item);
302
+ group.violatedCount++;
303
+ if (item.type === "coverage" && item.day) {
304
+ group.days.add(item.day);
305
+ }
306
+ }
307
+
308
+ // Group errors (except solver errors which don't have groupKey)
309
+ for (const item of validation.errors) {
310
+ if (item.type === "solver") continue;
311
+ const key = item.groupKey ?? groupKey(`ungrouped:${item.id}`);
312
+ const group = getOrCreateGroup(key, item.type);
313
+ group.items.push(item);
314
+ group.errorCount++;
315
+ if (item.type === "coverage" && item.day) {
316
+ group.days.add(item.day);
317
+ }
318
+ }
319
+
320
+ // Build summaries
321
+ const summaries: ValidationSummary[] = [];
322
+ for (const [key, group] of groups) {
323
+ const status: ValidationSummary["status"] =
324
+ group.errorCount > 0 ? "failed" : group.violatedCount > 0 ? "partial" : "passed";
325
+
326
+ summaries.push({
327
+ groupKey: key,
328
+ type: group.type,
329
+ description: inferDescription(key, group.items),
330
+ days: [...group.days].sort(),
331
+ status,
332
+ passedCount: group.passedCount,
333
+ violatedCount: group.violatedCount,
334
+ errorCount: group.errorCount,
335
+ });
336
+ }
337
+
338
+ return summaries;
339
+ }
340
+
341
+ /**
342
+ * Infers a human-readable description from the groupKey or items.
343
+ */
344
+ function inferDescription(key: GroupKey, items: ValidationItem[]): string {
345
+ // If the key looks like a human-readable description, use it
346
+ if (!key.startsWith("ungrouped:")) {
347
+ return key;
348
+ }
349
+
350
+ // Try to infer from the first item with a description
351
+ for (const item of items) {
352
+ if ("description" in item && item.description) {
353
+ // Extract the meaningful part (e.g., "2x waiter" from "2x waiter on 2026-02-03 at 09:00")
354
+ const match = item.description.match(/^(\d+x \w+)/);
355
+ if (match?.[1]) {
356
+ return match[1];
357
+ }
358
+ return item.description;
359
+ }
360
+ if ("reason" in item && item.reason) {
361
+ return item.reason;
362
+ }
363
+ }
364
+
365
+ return key;
366
+ }
@@ -0,0 +1,185 @@
1
+ import type { TimeOfDay } from "../types.js";
2
+
3
+ // =============================================================================
4
+ // Group Key - for aggregating related validation items
5
+ // =============================================================================
6
+
7
+ declare const GroupKeyBrand: unique symbol;
8
+
9
+ /**
10
+ * Branded type for validation group keys.
11
+ * Groups related validation items that originated from the same instruction.
12
+ */
13
+ export type GroupKey = string & { readonly [GroupKeyBrand]: never };
14
+
15
+ /**
16
+ * Creates a GroupKey from a description string.
17
+ * Use this to create keys that group related validation items together.
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * const key = groupKey("2x waiter during lunch");
22
+ * coverage.groupKey = key;
23
+ * ```
24
+ */
25
+ export function groupKey(description: string): GroupKey {
26
+ return description as GroupKey;
27
+ }
28
+
29
+ /**
30
+ * Context shared across validation results for grouping/display.
31
+ */
32
+ export interface ValidationContext {
33
+ days?: string[];
34
+ timeSlots?: string[];
35
+ employeeIds?: string[];
36
+ }
37
+
38
+ // =============================================================================
39
+ // Errors - block schedule generation
40
+ // =============================================================================
41
+
42
+ export interface CoverageError {
43
+ readonly id: string;
44
+ readonly type: "coverage";
45
+ readonly day: string;
46
+ readonly timeSlots: readonly string[];
47
+ readonly roleIds?: string[];
48
+ readonly skillIds?: readonly string[];
49
+ readonly reason: string;
50
+ readonly suggestions?: readonly string[];
51
+ readonly groupKey?: GroupKey;
52
+ }
53
+
54
+ export interface RuleError {
55
+ readonly id: string;
56
+ readonly type: "rule";
57
+ readonly rule: string;
58
+ readonly reason: string;
59
+ readonly context: ValidationContext;
60
+ readonly suggestions?: readonly string[];
61
+ readonly groupKey?: GroupKey;
62
+ }
63
+
64
+ export interface SolverError {
65
+ readonly id: string;
66
+ readonly type: "solver";
67
+ readonly reason: string;
68
+ }
69
+
70
+ export type ScheduleError = CoverageError | RuleError | SolverError;
71
+
72
+ // =============================================================================
73
+ // Violations - soft constraints not met, but schedule generated
74
+ // =============================================================================
75
+
76
+ export interface CoverageViolation {
77
+ readonly id: string;
78
+ readonly type: "coverage";
79
+ readonly day: string;
80
+ readonly timeSlots: readonly string[];
81
+ readonly roleIds?: string[];
82
+ readonly skillIds?: readonly string[];
83
+ readonly targetCount: number;
84
+ readonly actualCount: number;
85
+ readonly shortfall: number;
86
+ readonly groupKey?: GroupKey;
87
+ }
88
+
89
+ export interface RuleViolation {
90
+ readonly id: string;
91
+ readonly type: "rule";
92
+ readonly rule: string;
93
+ readonly reason: string;
94
+ readonly context: ValidationContext;
95
+ readonly shortfall?: number;
96
+ readonly overflow?: number;
97
+ readonly groupKey?: GroupKey;
98
+ }
99
+
100
+ export type ScheduleViolation = CoverageViolation | RuleViolation;
101
+
102
+ // =============================================================================
103
+ // Passed - confidence builders showing what was honored
104
+ // =============================================================================
105
+
106
+ export interface CoveragePassed {
107
+ readonly id: string;
108
+ readonly type: "coverage";
109
+ readonly day: string;
110
+ readonly timeSlots: readonly string[];
111
+ readonly roleIds?: string[];
112
+ readonly skillIds?: readonly string[];
113
+ readonly description: string;
114
+ readonly groupKey?: GroupKey;
115
+ }
116
+
117
+ export interface RulePassed {
118
+ readonly id: string;
119
+ readonly type: "rule";
120
+ readonly rule: string;
121
+ readonly description: string;
122
+ readonly context: ValidationContext;
123
+ readonly groupKey?: GroupKey;
124
+ }
125
+
126
+ export type SchedulePassed = CoveragePassed | RulePassed;
127
+
128
+ // =============================================================================
129
+ // Complete validation result
130
+ // =============================================================================
131
+
132
+ export interface ScheduleValidation {
133
+ readonly errors: readonly ScheduleError[];
134
+ readonly violations: readonly ScheduleViolation[];
135
+ readonly passed: readonly SchedulePassed[];
136
+ }
137
+
138
+ // =============================================================================
139
+ // Validation Summary - aggregated view for display
140
+ // =============================================================================
141
+
142
+ /**
143
+ * Summary of validation items grouped by their source instruction.
144
+ * Use `summarizeValidation()` to create these from a ScheduleValidation.
145
+ */
146
+ export interface ValidationSummary {
147
+ readonly groupKey: GroupKey;
148
+ readonly type: "coverage" | "rule";
149
+ readonly description: string;
150
+ readonly days: readonly string[];
151
+ readonly status: "passed" | "partial" | "failed";
152
+ readonly passedCount: number;
153
+ readonly violatedCount: number;
154
+ readonly errorCount: number;
155
+ }
156
+
157
+ // =============================================================================
158
+ // Internal tracking types (used during model building)
159
+ // =============================================================================
160
+
161
+ export interface TrackedConstraint {
162
+ readonly id: string;
163
+ readonly type: "coverage" | "rule";
164
+ readonly rule?: string;
165
+ readonly description: string;
166
+ readonly targetValue: number;
167
+ readonly comparator: "<=" | ">=";
168
+ readonly day?: string;
169
+ readonly timeSlot?: string;
170
+ readonly roleIds?: string[];
171
+ readonly skillIds?: readonly string[];
172
+ readonly context: ValidationContext;
173
+ readonly groupKey?: GroupKey;
174
+ }
175
+
176
+ /**
177
+ * Coverage exclusion - indicates a team member is unavailable for coverage during a time period.
178
+ * Used during compile-time to determine coverage feasibility.
179
+ */
180
+ export interface CoverageExclusion {
181
+ employeeId: string;
182
+ day: string;
183
+ startTime?: TimeOfDay;
184
+ endTime?: TimeOfDay;
185
+ }