dabke 0.82.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +6 -3
  3. package/dist/cpsat/rules/index.d.ts +3 -0
  4. package/dist/cpsat/rules/index.d.ts.map +1 -1
  5. package/dist/cpsat/rules/index.js +3 -0
  6. package/dist/cpsat/rules/index.js.map +1 -1
  7. package/dist/cpsat/rules/max-days-week.d.ts +44 -0
  8. package/dist/cpsat/rules/max-days-week.d.ts.map +1 -0
  9. package/dist/cpsat/rules/max-days-week.js +95 -0
  10. package/dist/cpsat/rules/max-days-week.js.map +1 -0
  11. package/dist/cpsat/rules/min-days-week.d.ts +34 -0
  12. package/dist/cpsat/rules/min-days-week.d.ts.map +1 -0
  13. package/dist/cpsat/rules/min-days-week.js +84 -0
  14. package/dist/cpsat/rules/min-days-week.js.map +1 -0
  15. package/dist/cpsat/rules/must-assign.d.ts +49 -0
  16. package/dist/cpsat/rules/must-assign.d.ts.map +1 -0
  17. package/dist/cpsat/rules/must-assign.js +86 -0
  18. package/dist/cpsat/rules/must-assign.js.map +1 -0
  19. package/dist/cpsat/rules/registry.d.ts +4 -1
  20. package/dist/cpsat/rules/registry.d.ts.map +1 -1
  21. package/dist/cpsat/rules/registry.js +4 -1
  22. package/dist/cpsat/rules/registry.js.map +1 -1
  23. package/dist/cpsat/rules/rules.types.d.ts +3 -0
  24. package/dist/cpsat/rules/rules.types.d.ts.map +1 -1
  25. package/dist/cpsat/rules/scope.types.d.ts +1 -1
  26. package/dist/index.d.ts +5 -3
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +4 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/schedule/cost.d.ts +204 -0
  31. package/dist/schedule/cost.d.ts.map +1 -0
  32. package/dist/schedule/cost.js +187 -0
  33. package/dist/schedule/cost.js.map +1 -0
  34. package/dist/schedule/coverage.d.ts +85 -0
  35. package/dist/schedule/coverage.d.ts.map +1 -0
  36. package/dist/schedule/coverage.js +33 -0
  37. package/dist/schedule/coverage.js.map +1 -0
  38. package/dist/schedule/definition.d.ts +227 -0
  39. package/dist/schedule/definition.d.ts.map +1 -0
  40. package/dist/{schedule.js → schedule/definition.js} +9 -673
  41. package/dist/schedule/definition.js.map +1 -0
  42. package/dist/schedule/index.d.ts +67 -0
  43. package/dist/schedule/index.d.ts.map +1 -0
  44. package/dist/schedule/index.js +69 -0
  45. package/dist/schedule/index.js.map +1 -0
  46. package/dist/schedule/rules.d.ts +353 -0
  47. package/dist/schedule/rules.d.ts.map +1 -0
  48. package/dist/schedule/rules.js +352 -0
  49. package/dist/schedule/rules.js.map +1 -0
  50. package/dist/schedule/shift-patterns.d.ts +34 -0
  51. package/dist/schedule/shift-patterns.d.ts.map +1 -0
  52. package/dist/schedule/shift-patterns.js +41 -0
  53. package/dist/schedule/shift-patterns.js.map +1 -0
  54. package/dist/schedule/time-periods.d.ts +69 -0
  55. package/dist/schedule/time-periods.d.ts.map +1 -0
  56. package/dist/schedule/time-periods.js +91 -0
  57. package/dist/schedule/time-periods.js.map +1 -0
  58. package/package.json +4 -9
  59. package/src/cpsat/rules/index.ts +3 -0
  60. package/src/cpsat/rules/max-days-week.ts +143 -0
  61. package/src/cpsat/rules/min-days-week.ts +120 -0
  62. package/src/cpsat/rules/must-assign.ts +108 -0
  63. package/src/cpsat/rules/registry.ts +6 -0
  64. package/src/cpsat/rules/rules.types.ts +3 -0
  65. package/src/cpsat/rules/scope.types.ts +1 -1
  66. package/src/index.ts +8 -3
  67. package/src/schedule/cost.ts +242 -0
  68. package/src/schedule/coverage.ts +135 -0
  69. package/src/schedule/definition.ts +958 -0
  70. package/src/schedule/index.ts +112 -0
  71. package/src/schedule/rules.ts +529 -0
  72. package/src/schedule/shift-patterns.ts +46 -0
  73. package/src/schedule/time-periods.ts +110 -0
  74. package/dist/llms.d.ts +0 -2
  75. package/dist/llms.d.ts.map +0 -1
  76. package/dist/llms.js +0 -3
  77. package/dist/llms.js.map +0 -1
  78. package/dist/schedule.d.ts +0 -917
  79. package/dist/schedule.d.ts.map +0 -1
  80. package/dist/schedule.js.map +0 -1
  81. package/llms.txt +0 -758
  82. package/src/llms.ts +0 -3
  83. package/src/schedule.ts +0 -1960
package/src/schedule.ts DELETED
@@ -1,1960 +0,0 @@
1
- /**
2
- * High-level schedule definition API.
3
- *
4
- * Small, composable factory functions that produce a complete scheduling
5
- * configuration. Designed for LLM code generation: each concept is a single
6
- * function call with per-call type safety.
7
- *
8
- * @example
9
- * ```typescript
10
- * import {
11
- * schedule, t, time, cover, shift,
12
- * maxHoursPerDay, maxHoursPerWeek, minRestBetweenShifts,
13
- * weekdays, weekend,
14
- * } from "dabke";
15
- *
16
- * const venue = schedule({
17
- * roleIds: ["cashier", "floor_lead", "stocker"],
18
- * skillIds: ["keyholder"],
19
- *
20
- * times: {
21
- * opening: time({ startTime: t(8), endTime: t(10) }),
22
- * peak_hours: time(
23
- * { startTime: t(11), endTime: t(14) },
24
- * { startTime: t(10), endTime: t(15), dayOfWeek: weekend },
25
- * ),
26
- * closing: time({ startTime: t(20), endTime: t(22) }),
27
- * },
28
- *
29
- * coverage: [
30
- * cover("opening", "keyholder", 1),
31
- * cover("peak_hours", "cashier", 3, { dayOfWeek: weekdays }),
32
- * cover("peak_hours", "cashier", 5, { dayOfWeek: weekend }),
33
- * cover("closing", "floor_lead", 1),
34
- * ],
35
- *
36
- * shiftPatterns: [
37
- * shift("morning", t(8), t(14)),
38
- * shift("afternoon", t(14), t(22)),
39
- * ],
40
- *
41
- * rules: [
42
- * maxHoursPerDay(10),
43
- * maxHoursPerWeek(48),
44
- * minRestBetweenShifts(10),
45
- * ],
46
- * });
47
- *
48
- * const result = await venue
49
- * .with([
50
- * { id: "alice", roleIds: ["cashier"], skillIds: ["keyholder"] },
51
- * ])
52
- * .solve(client, { dateRange: { start: "2025-03-03", end: "2025-03-09" } });
53
- * ```
54
- *
55
- * @module
56
- */
57
-
58
- import type { DayOfWeek, SchedulingPeriod, TimeOfDay } from "./types.js";
59
- import type {
60
- SemanticTimeDef,
61
- SemanticTimeVariant,
62
- SemanticTimeEntry,
63
- MixedCoverageRequirement,
64
- CoverageVariant,
65
- } from "./cpsat/semantic-time.js";
66
-
67
- export type { CoverageVariant } from "./cpsat/semantic-time.js";
68
- import { defineSemanticTimes } from "./cpsat/semantic-time.js";
69
- import { resolveDaysFromPeriod } from "./datetime.utils.js";
70
- import type { ModelBuilderConfig, CompilationResult } from "./cpsat/model-builder.js";
71
- import { ModelBuilder } from "./cpsat/model-builder.js";
72
- import type { SchedulingMember, ShiftPattern, Priority } from "./cpsat/types.js";
73
- import type {
74
- CpsatRuleName,
75
- CpsatRuleConfigEntry,
76
- CreateCpsatRuleFunction,
77
- } from "./cpsat/rules/rules.types.js";
78
- import { builtInCpsatRuleFactories } from "./cpsat/rules/registry.js";
79
- import type { RecurringPeriod } from "./cpsat/rules/scope.types.js";
80
- import type { OvertimeTier } from "./cpsat/rules/overtime-tiered-multiplier.js";
81
- import type { SolverClient, SolverResponse } from "./client.types.js";
82
- import { parseSolverResponse, resolveAssignments } from "./cpsat/response.js";
83
- import type { ShiftAssignment } from "./cpsat/response.js";
84
- import type { ScheduleValidation } from "./cpsat/validation.types.js";
85
- import { calculateScheduleCost } from "./cpsat/cost.js";
86
- import type { CostBreakdown } from "./cpsat/cost.js";
87
-
88
- // ============================================================================
89
- // Primitives
90
- // ============================================================================
91
-
92
- /**
93
- * Creates a {@link TimeOfDay} value.
94
- *
95
- * @param hours - Hour component (0-23)
96
- * @param minutes - Minute component (0-59)
97
- *
98
- * @example Hours only
99
- * ```ts
100
- * t(9) // { hours: 9, minutes: 0 }
101
- * ```
102
- *
103
- * @example Hours and minutes
104
- * ```ts
105
- * t(17, 30) // { hours: 17, minutes: 30 }
106
- * ```
107
- *
108
- * @category Time Periods
109
- */
110
- export function t(hours: number, minutes = 0): TimeOfDay {
111
- return { hours, minutes };
112
- }
113
-
114
- /**
115
- * Monday through Friday.
116
- *
117
- * @category Time Periods
118
- */
119
- export const weekdays = [
120
- "monday",
121
- "tuesday",
122
- "wednesday",
123
- "thursday",
124
- "friday",
125
- ] as const satisfies readonly [DayOfWeek, ...DayOfWeek[]];
126
-
127
- /**
128
- * Saturday and Sunday.
129
- *
130
- * @category Time Periods
131
- */
132
- export const weekend = ["saturday", "sunday"] as const satisfies readonly [
133
- DayOfWeek,
134
- ...DayOfWeek[],
135
- ];
136
-
137
- // ============================================================================
138
- // Semantic Times
139
- // ============================================================================
140
-
141
- /**
142
- * Define a named semantic time period.
143
- *
144
- * @remarks
145
- * Each entry has `startTime`/`endTime` and optional `dayOfWeek` or `dates`
146
- * scoping. Entries without scoping are the default.
147
- *
148
- * @example
149
- * ```typescript
150
- * times: {
151
- * // Simple: same times every day
152
- * lunch: time({ startTime: t(12), endTime: t(15) }),
153
- *
154
- * // Variants: different times on weekends
155
- * dinner: time(
156
- * { startTime: t(17), endTime: t(21) },
157
- * { startTime: t(18), endTime: t(22), dayOfWeek: weekend },
158
- * ),
159
- *
160
- * // Point-in-time window (keyholder at opening)
161
- * opening: time({ startTime: t(8, 30), endTime: t(9) }),
162
- * }
163
- * ```
164
- *
165
- * @privateRemarks
166
- * Resolution precedence: `dates` > `dayOfWeek` > default.
167
- *
168
- * @category Time Periods
169
- */
170
- export function time(
171
- ...entries: [SemanticTimeVariant, ...SemanticTimeVariant[]]
172
- ): SemanticTimeEntry {
173
- // Validate: at most one default (no dayOfWeek and no dates)
174
- const defaults = entries.filter((e) => !e.dayOfWeek && !e.dates);
175
- if (defaults.length > 1) {
176
- throw new Error(
177
- "time() accepts at most one default entry (without dayOfWeek or dates). " +
178
- `Found ${defaults.length} default entries.`,
179
- );
180
- }
181
-
182
- // Single entry without scoping: simple SemanticTimeDef
183
- if (entries.length === 1 && !entries[0].dayOfWeek && !entries[0].dates) {
184
- return {
185
- startTime: entries[0].startTime,
186
- endTime: entries[0].endTime,
187
- } satisfies SemanticTimeDef;
188
- }
189
-
190
- // Multiple entries or scoped entries: shallow-copy to decouple from caller
191
- return entries.map((entry) => Object.assign({}, entry));
192
- }
193
-
194
- // ============================================================================
195
- // Coverage
196
- // ============================================================================
197
-
198
- /**
199
- * Options for a {@link cover} call.
200
- *
201
- * @remarks
202
- * Day/date scoping controls which days this coverage entry applies to.
203
- * An entry without `dayOfWeek` or `dates` applies every day in the
204
- * scheduling period.
205
- *
206
- * @category Coverage
207
- */
208
- export interface CoverageOptions {
209
- /** Additional skill ID filter (AND logic with the target role). */
210
- skillIds?: [string, ...string[]];
211
- /** Restrict to specific days of the week. */
212
- dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
213
- /** Restrict to specific dates (YYYY-MM-DD). */
214
- dates?: string[];
215
- /** Defaults to `"MANDATORY"`. */
216
- priority?: Priority;
217
- }
218
-
219
- /**
220
- * A coverage entry returned by {@link cover}.
221
- *
222
- * @remarks
223
- * Carries the semantic time name and target type information for
224
- * compile-time validation by {@link schedule}. This is an opaque
225
- * token; pass it directly into the `coverage` array.
226
- */
227
- export interface CoverageEntry<T extends string = string, R extends string = string> {
228
- /** @internal */ readonly _type: "coverage";
229
- /** @internal */ readonly timeName: T;
230
- /** @internal */ readonly target: R | R[];
231
- /** @internal */ readonly count: number;
232
- /** @internal */ readonly options: CoverageOptions;
233
- /** @internal When present, this entry uses variant-based resolution. */
234
- readonly variants?: readonly CoverageVariant[];
235
- }
236
-
237
- /**
238
- * Defines a staffing requirement for a semantic time period.
239
- *
240
- * @remarks
241
- * Entries for the same time and role **stack additively**.
242
- * For weekday vs weekend staffing, use mutually exclusive `dayOfWeek`
243
- * on both entries.
244
- *
245
- * @param timeName - Name of a declared semantic time
246
- * @param target - Role name (string), array of role names (OR logic), or skill name
247
- * @param count - Number of people needed
248
- * @param opts - Options: `skillIds` (AND filter), `dayOfWeek`, `dates`, `priority`
249
- *
250
- * @example
251
- * ```typescript
252
- * coverage: [
253
- * // 2 waiters during lunch
254
- * cover("lunch", "waiter", 2),
255
- *
256
- * // 1 manager OR supervisor during dinner
257
- * cover("dinner", ["manager", "supervisor"], 1),
258
- *
259
- * // 1 person with keyholder skill at opening
260
- * cover("opening", "keyholder", 1),
261
- *
262
- * // 1 senior waiter (role + skill AND)
263
- * cover("lunch", "waiter", 1, { skillIds: ["senior"] }),
264
- *
265
- * // Different counts by day (mutually exclusive dayOfWeek!)
266
- * cover("lunch", "waiter", 2, { dayOfWeek: weekdays }),
267
- * cover("lunch", "waiter", 3, { dayOfWeek: weekend }),
268
- * ]
269
- * ```
270
- *
271
- * @category Coverage
272
- */
273
- export function cover<T extends string, R extends string>(
274
- timeName: T,
275
- target: R | [R, ...R[]],
276
- count: number,
277
- opts?: CoverageOptions,
278
- ): CoverageEntry<T, R>;
279
- export function cover<T extends string, R extends string>(
280
- timeName: T,
281
- target: R | [R, ...R[]],
282
- ...variants: [CoverageVariant, ...CoverageVariant[]]
283
- ): CoverageEntry<T, R>;
284
- export function cover<T extends string, R extends string>(
285
- timeName: T,
286
- target: R | [R, ...R[]],
287
- countOrFirstVariant: number | CoverageVariant,
288
- ...rest: unknown[]
289
- ): CoverageEntry<T, R> {
290
- if (typeof countOrFirstVariant === "number") {
291
- // Simple form: cover(time, target, count, opts?)
292
- return {
293
- _type: "coverage",
294
- timeName,
295
- target,
296
- count: countOrFirstVariant,
297
- options: (rest[0] as CoverageOptions | undefined) ?? {},
298
- };
299
- }
300
-
301
- // Variant form: cover(time, target, ...variants)
302
- const variants = [countOrFirstVariant, ...(rest as CoverageVariant[])];
303
-
304
- const defaults = variants.filter((v) => !v.dayOfWeek && !v.dates);
305
- if (defaults.length > 1) {
306
- throw new Error(
307
- "cover() accepts at most one default variant (without dayOfWeek or dates). " +
308
- `Found ${defaults.length} default variants.`,
309
- );
310
- }
311
-
312
- return {
313
- _type: "coverage",
314
- timeName,
315
- target,
316
- count: 0,
317
- options: {},
318
- variants,
319
- };
320
- }
321
-
322
- // ============================================================================
323
- // Shift Patterns
324
- // ============================================================================
325
-
326
- /**
327
- * Define a shift pattern: a time slot available for employee assignment.
328
- *
329
- * @remarks
330
- * Each pattern repeats daily unless filtered by `dayOfWeek`.
331
- *
332
- * @example
333
- * ```typescript
334
- * shiftPatterns: [
335
- * shift("morning", t(11, 30), t(15)),
336
- * shift("evening", t(17), t(22)),
337
- *
338
- * // Role-restricted shift
339
- * shift("kitchen", t(6), t(14), { roleIds: ["chef", "prep_cook"] }),
340
- *
341
- * // Day-restricted shift
342
- * shift("saturday_short", t(9), t(14), { dayOfWeek: ["saturday"] }),
343
- *
344
- * // Location-specific shift
345
- * shift("terrace_lunch", t(12), t(16), { locationId: "terrace" }),
346
- * ]
347
- * ```
348
- *
349
- * @category Shift Patterns
350
- */
351
- export function shift(
352
- id: string,
353
- startTime: TimeOfDay,
354
- endTime: TimeOfDay,
355
- opts?: Pick<ShiftPattern, "roleIds" | "dayOfWeek" | "locationId">,
356
- ): ShiftPattern {
357
- const pattern: ShiftPattern = { id, startTime, endTime };
358
- if (opts?.roleIds) pattern.roleIds = opts.roleIds;
359
- if (opts?.dayOfWeek) pattern.dayOfWeek = opts.dayOfWeek;
360
- if (opts?.locationId) pattern.locationId = opts.locationId;
361
- return pattern;
362
- }
363
-
364
- // ============================================================================
365
- // Rules
366
- // ============================================================================
367
-
368
- /**
369
- * Scoping options shared by most rule functions.
370
- *
371
- * @remarks
372
- * Default priority is `MANDATORY`. Use `appliesTo` to scope to a
373
- * role, skill, or member ID. Use time scoping options (`dayOfWeek`,
374
- * `dateRange`, `dates`) to limit when the rule applies.
375
- * Not all rules support all scoping options. Entity-only rules
376
- * (e.g., {@link maxConsecutiveDays}) ignore time scoping.
377
- *
378
- * @category Rules
379
- */
380
- export interface RuleOptions {
381
- /** Who this rule applies to (role name, skill name, or member ID). */
382
- appliesTo?: string | string[];
383
- /** Restrict to specific days of the week. */
384
- dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
385
- /** Restrict to a date range. */
386
- dateRange?: { start: string; end: string };
387
- /** Restrict to specific dates (YYYY-MM-DD). */
388
- dates?: string[];
389
- /** Restrict to recurring calendar periods. */
390
- recurringPeriods?: [RecurringPeriod, ...RecurringPeriod[]];
391
- /** Defaults to `"MANDATORY"`. */
392
- priority?: Priority;
393
- }
394
-
395
- /**
396
- * Options for rules that support entity scoping only (no time scoping).
397
- *
398
- * @remarks
399
- * Used by rules whose semantics are inherently per-day or per-week
400
- * (e.g., {@link minHoursPerDay}, {@link maxConsecutiveDays}) and cannot
401
- * be meaningfully restricted to a date range or day of week.
402
- *
403
- * @category Rules
404
- */
405
- export interface EntityOnlyRuleOptions {
406
- /** Who this rule applies to (role name, skill name, or member ID). */
407
- appliesTo?: string | string[];
408
- /** Defaults to `"MANDATORY"`. */
409
- priority?: Priority;
410
- }
411
-
412
- /**
413
- * Options for {@link timeOff}.
414
- *
415
- * @remarks
416
- * At least one time scoping field is required (`dayOfWeek`, `dateRange`,
417
- * `dates`, or `recurringPeriods`). Use `from`/`until` to block only part
418
- * of a day.
419
- *
420
- * @category Rules
421
- */
422
- export interface TimeOffOptions {
423
- /** Who this rule applies to (role name, skill name, or member ID). */
424
- appliesTo?: string | string[];
425
- /** Off from this time until end of day. */
426
- from?: TimeOfDay;
427
- /** Off from start of day until this time. */
428
- until?: TimeOfDay;
429
- /** Restrict to specific days of the week. */
430
- dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
431
- /** Restrict to a date range. */
432
- dateRange?: { start: string; end: string };
433
- /** Restrict to specific dates (YYYY-MM-DD). */
434
- dates?: string[];
435
- /** Restrict to recurring calendar periods. */
436
- recurringPeriods?: [RecurringPeriod, ...RecurringPeriod[]];
437
- /** Defaults to `"MANDATORY"`. */
438
- priority?: Priority;
439
- }
440
-
441
- /**
442
- * Options for {@link assignTogether}.
443
- *
444
- * @category Rules
445
- */
446
- export interface AssignTogetherOptions {
447
- /** Defaults to `"MANDATORY"`. */
448
- priority?: Priority;
449
- }
450
-
451
- /**
452
- * Options for cost rules.
453
- *
454
- * Cost rules are objective terms, not constraints. The `priority` field from
455
- * {@link RuleOptions} does not apply.
456
- *
457
- * @category Cost Optimization
458
- */
459
- export interface CostRuleOptions {
460
- /** Who this rule applies to (role name, skill name, or member ID). */
461
- appliesTo?: string | string[];
462
- /** Restrict to specific days of the week. */
463
- dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
464
- /** Restrict to a date range. */
465
- dateRange?: { start: string; end: string };
466
- /** Restrict to specific dates (YYYY-MM-DD). */
467
- dates?: string[];
468
- /** Restrict to recurring calendar periods. */
469
- recurringPeriods?: [RecurringPeriod, ...RecurringPeriod[]];
470
- }
471
-
472
- /**
473
- * Context passed to a rule's resolve function during compilation.
474
- *
475
- * Contains the declared roles, skills, and member IDs so the resolver
476
- * can translate user-facing fields (like `appliesTo`) into internal
477
- * scoping fields.
478
- *
479
- * @category Rules
480
- */
481
- export interface RuleResolveContext {
482
- readonly roles: ReadonlySet<string>;
483
- readonly skills: ReadonlySet<string>;
484
- readonly memberIds: ReadonlySet<string>;
485
- }
486
-
487
- // Internal rule entry type
488
- interface RuleEntryBase {
489
- readonly _type: "rule";
490
- readonly _rule: string;
491
- /**
492
- * Optional custom resolver. When present, `resolveRules()` calls this
493
- * instead of the default translation path. Built-in rules that need
494
- * special field mapping (e.g., `timeOff`, `assignTogether`) attach one;
495
- * all other rules use the default resolver.
496
- */
497
- readonly _resolve?: (ctx: RuleResolveContext) => Record<string, unknown> & { name: string };
498
- }
499
-
500
- /**
501
- * An opaque rule entry returned by rule functions.
502
- *
503
- * @remarks
504
- * Pass these directly into the `rules` array of {@link ScheduleConfig}.
505
- * The internal fields are resolved during compilation.
506
- */
507
- export type RuleEntry = RuleEntryBase & Record<string, unknown>;
508
-
509
- /**
510
- * Creates a rule entry for use in {@link ScheduleConfig.rules}.
511
- *
512
- * Built-in rules use the helpers (`maxHoursPerDay`, `timeOff`, etc.).
513
- * Custom rules can use `defineRule` to create entries that plug into the
514
- * same resolution and compilation pipeline.
515
- *
516
- * @param name - Rule name. Must match a key in the rule factory registry.
517
- * @param fields - Rule-specific configuration fields.
518
- * @param resolve - Optional custom resolver. When omitted, the default
519
- * resolution applies: `appliesTo` is mapped to `roleIds`/`skillIds`/`memberIds`,
520
- * `dates` is renamed to `specificDates`, and all other fields pass through.
521
- *
522
- * @category Rules
523
- */
524
- export function defineRule(
525
- name: string,
526
- fields: Record<string, unknown>,
527
- resolve?: (ctx: RuleResolveContext) => Record<string, unknown> & { name: string },
528
- ): RuleEntry {
529
- const { _type: _, _rule: __, ...safeFields } = fields;
530
- const entry: RuleEntry = { _type: "rule", _rule: name, ...safeFields };
531
- if (resolve) {
532
- Object.defineProperty(entry, "_resolve", { value: resolve, enumerable: false });
533
- }
534
- return entry;
535
- }
536
-
537
- function makeRule(rule: CpsatRuleName, fields: Record<string, unknown>): RuleEntry {
538
- return defineRule(rule, fields);
539
- }
540
-
541
- /**
542
- * Limits hours per day.
543
- *
544
- * @example
545
- * ```typescript
546
- * maxHoursPerDay(10)
547
- * maxHoursPerDay(4, { appliesTo: "student", dayOfWeek: weekdays })
548
- * ```
549
- *
550
- * @category Rules
551
- */
552
- export function maxHoursPerDay(hours: number, opts?: RuleOptions): RuleEntry {
553
- return makeRule("max-hours-day", { hours, ...opts });
554
- }
555
-
556
- /**
557
- * Limits hours per scheduling week.
558
- *
559
- * @example
560
- * ```typescript
561
- * maxHoursPerWeek(48)
562
- * maxHoursPerWeek(20, { appliesTo: "student" })
563
- * ```
564
- *
565
- * @category Rules
566
- */
567
- export function maxHoursPerWeek(hours: number, opts?: RuleOptions): RuleEntry {
568
- return makeRule("max-hours-week", { hours, ...opts });
569
- }
570
-
571
- /**
572
- * Minimum hours when assigned on a day.
573
- *
574
- * @example
575
- * ```typescript
576
- * minHoursPerDay(4)
577
- * ```
578
- *
579
- * @category Rules
580
- */
581
- export function minHoursPerDay(hours: number, opts?: EntityOnlyRuleOptions): RuleEntry {
582
- return makeRule("min-hours-day", { hours, ...opts });
583
- }
584
-
585
- /**
586
- * Minimum hours per scheduling week.
587
- *
588
- * @example
589
- * ```typescript
590
- * minHoursPerWeek(20, { priority: "HIGH" })
591
- * ```
592
- *
593
- * @category Rules
594
- */
595
- export function minHoursPerWeek(hours: number, opts?: EntityOnlyRuleOptions): RuleEntry {
596
- return makeRule("min-hours-week", { hours, ...opts });
597
- }
598
-
599
- /**
600
- * Maximum distinct shifts per day.
601
- *
602
- * @example
603
- * ```typescript
604
- * maxShiftsPerDay(1)
605
- * maxShiftsPerDay(2, { appliesTo: "student", dayOfWeek: weekend })
606
- * ```
607
- *
608
- * @category Rules
609
- */
610
- export function maxShiftsPerDay(shifts: number, opts?: RuleOptions): RuleEntry {
611
- return makeRule("max-shifts-day", { shifts, ...opts });
612
- }
613
-
614
- /**
615
- * Maximum consecutive working days.
616
- *
617
- * @example
618
- * ```typescript
619
- * maxConsecutiveDays(5)
620
- * ```
621
- *
622
- * @category Rules
623
- */
624
- export function maxConsecutiveDays(days: number, opts?: EntityOnlyRuleOptions): RuleEntry {
625
- return makeRule("max-consecutive-days", { days, ...opts });
626
- }
627
-
628
- /**
629
- * Once working, continue for at least this many consecutive days.
630
- *
631
- * @example
632
- * ```typescript
633
- * minConsecutiveDays(2, { priority: "HIGH" })
634
- * ```
635
- *
636
- * @category Rules
637
- */
638
- export function minConsecutiveDays(days: number, opts?: EntityOnlyRuleOptions): RuleEntry {
639
- return makeRule("min-consecutive-days", { days, ...opts });
640
- }
641
-
642
- /**
643
- * Minimum rest hours between shifts.
644
- *
645
- * @example
646
- * ```typescript
647
- * minRestBetweenShifts(10)
648
- * ```
649
- *
650
- * @category Rules
651
- */
652
- export function minRestBetweenShifts(hours: number, opts?: EntityOnlyRuleOptions): RuleEntry {
653
- return makeRule("min-rest-between-shifts", { hours, ...opts });
654
- }
655
-
656
- /**
657
- * Prefer (`"high"`) or avoid (`"low"`) assigning. Requires `appliesTo`.
658
- *
659
- * @example
660
- * ```typescript
661
- * preference("high", { appliesTo: "waiter" })
662
- * preference("low", { appliesTo: "student", dayOfWeek: weekdays })
663
- * ```
664
- *
665
- * @category Rules
666
- */
667
- export function preference(level: "high" | "low", opts?: Omit<RuleOptions, "priority">): RuleEntry {
668
- return makeRule("assignment-priority", { preference: level, ...opts });
669
- }
670
-
671
- /**
672
- * Prefer assigning to shifts at a specific location. Requires `appliesTo`.
673
- *
674
- * @example
675
- * ```typescript
676
- * preferLocation("terrace", { appliesTo: "alice" })
677
- * ```
678
- *
679
- * @category Rules
680
- */
681
- export function preferLocation(locationId: string, opts?: EntityOnlyRuleOptions): RuleEntry {
682
- return makeRule("location-preference", { locationId, ...opts });
683
- }
684
-
685
- /**
686
- * Tells the solver to minimize total labor cost.
687
- *
688
- * @remarks
689
- * Without this rule, cost modifiers only affect post-solve calculation.
690
- * When present, the solver actively prefers cheaper assignments.
691
- *
692
- * For hourly members, penalizes each assignment proportionally to cost.
693
- * For salaried members, adds a fixed weekly salary cost when they have
694
- * any assignment that week (zero marginal cost up to contracted hours).
695
- *
696
- * Cost modifiers adjust the calculation:
697
- * - `dayMultiplier(factor, opts?)` - multiply base rate on specific days
698
- * - `daySurcharge(amount, opts?)` - flat extra per hour on specific days
699
- * - `timeSurcharge(amount, window, opts?)` - flat extra per hour during a time window
700
- * - `overtimeMultiplier({ after, factor }, opts?)` - weekly overtime multiplier
701
- * - `overtimeSurcharge({ after, amount }, opts?)` - weekly overtime surcharge
702
- * - `dailyOvertimeMultiplier({ after, factor }, opts?)` - daily overtime multiplier
703
- * - `dailyOvertimeSurcharge({ after, amount }, opts?)` - daily overtime surcharge
704
- * - `tieredOvertimeMultiplier(tiers, opts?)` - multiple overtime thresholds
705
- *
706
- * @example
707
- * ```ts
708
- * minimizeCost()
709
- * ```
710
- *
711
- * @category Cost Optimization
712
- */
713
- export function minimizeCost(opts?: CostRuleOptions): RuleEntry {
714
- return makeRule("minimize-cost", { ...opts });
715
- }
716
-
717
- /**
718
- * Multiplies the base rate for assignments on specified days.
719
- *
720
- * @remarks
721
- * The base cost (1x) is already counted by {@link minimizeCost};
722
- * this rule adds only the extra portion above 1x.
723
- *
724
- * @category Cost Optimization
725
- *
726
- * @example Weekend multiplier
727
- * ```typescript
728
- * dayMultiplier(1.5, { dayOfWeek: weekend })
729
- * ```
730
- */
731
- export function dayMultiplier(factor: number, opts?: CostRuleOptions): RuleEntry {
732
- return makeRule("day-cost-multiplier", { factor, ...opts });
733
- }
734
-
735
- /**
736
- * Adds a flat extra amount per hour for assignments on specified days.
737
- *
738
- * @remarks
739
- * The surcharge is independent of the member's base rate.
740
- *
741
- * @category Cost Optimization
742
- *
743
- * @example Weekend surcharge
744
- * ```typescript
745
- * daySurcharge(500, { dayOfWeek: weekend })
746
- * ```
747
- */
748
- export function daySurcharge(amountPerHour: number, opts?: CostRuleOptions): RuleEntry {
749
- return makeRule("day-cost-surcharge", { amountPerHour, ...opts });
750
- }
751
-
752
- /**
753
- * Adds a flat surcharge per hour for the portion of a shift that overlaps a time-of-day window.
754
- *
755
- * @remarks
756
- * The window supports overnight spans (e.g., 22:00-06:00). The surcharge
757
- * is independent of the member's base rate.
758
- *
759
- * @param amountPerHour - Flat surcharge per hour in smallest currency unit
760
- * @param window - Time-of-day window
761
- * @param opts - Entity and time scoping
762
- *
763
- * @category Cost Optimization
764
- *
765
- * @example Night differential
766
- * ```typescript
767
- * timeSurcharge(200, { from: t(22), until: t(6) })
768
- * ```
769
- */
770
- export function timeSurcharge(
771
- amountPerHour: number,
772
- window: { from: TimeOfDay; until: TimeOfDay },
773
- opts?: CostRuleOptions,
774
- ): RuleEntry {
775
- return makeRule("time-cost-surcharge", { amountPerHour, window, ...opts });
776
- }
777
-
778
- /**
779
- * Applies a multiplier to hours beyond a weekly threshold.
780
- *
781
- * @remarks
782
- * Only the extra portion above 1x is added (the base cost is already
783
- * counted by {@link minimizeCost}).
784
- *
785
- * @category Cost Optimization
786
- *
787
- * @example
788
- * ```typescript
789
- * overtimeMultiplier({ after: 40, factor: 1.5 })
790
- * ```
791
- */
792
- export function overtimeMultiplier(
793
- opts: { after: number; factor: number } & CostRuleOptions,
794
- ): RuleEntry {
795
- return makeRule("overtime-weekly-multiplier", { ...opts });
796
- }
797
-
798
- /**
799
- * Adds a flat surcharge per hour beyond a weekly threshold.
800
- *
801
- * @remarks
802
- * The surcharge is independent of the member's base rate.
803
- *
804
- * @category Cost Optimization
805
- *
806
- * @example
807
- * ```typescript
808
- * overtimeSurcharge({ after: 40, amount: 1000 })
809
- * ```
810
- */
811
- export function overtimeSurcharge(
812
- opts: { after: number; amount: number } & CostRuleOptions,
813
- ): RuleEntry {
814
- return makeRule("overtime-weekly-surcharge", { ...opts });
815
- }
816
-
817
- /**
818
- * Applies a multiplier to hours beyond a daily threshold.
819
- *
820
- * @remarks
821
- * Only the extra portion above 1x is added (the base cost is already
822
- * counted by {@link minimizeCost}).
823
- *
824
- * @category Cost Optimization
825
- *
826
- * @example
827
- * ```typescript
828
- * dailyOvertimeMultiplier({ after: 8, factor: 1.5 })
829
- * ```
830
- */
831
- export function dailyOvertimeMultiplier(
832
- opts: { after: number; factor: number } & CostRuleOptions,
833
- ): RuleEntry {
834
- return makeRule("overtime-daily-multiplier", { ...opts });
835
- }
836
-
837
- /**
838
- * Adds a flat surcharge per hour beyond a daily threshold.
839
- *
840
- * @remarks
841
- * The surcharge is independent of the member's base rate.
842
- *
843
- * @category Cost Optimization
844
- *
845
- * @example
846
- * ```typescript
847
- * dailyOvertimeSurcharge({ after: 8, amount: 500 })
848
- * ```
849
- */
850
- export function dailyOvertimeSurcharge(
851
- opts: { after: number; amount: number } & CostRuleOptions,
852
- ): RuleEntry {
853
- return makeRule("overtime-daily-surcharge", { ...opts });
854
- }
855
-
856
- /**
857
- * Applies multiple overtime thresholds with increasing multipliers.
858
- *
859
- * @remarks
860
- * Each tier applies only to the hours between its threshold and the next.
861
- * Tiers must be sorted by threshold ascending.
862
- *
863
- * @category Cost Optimization
864
- *
865
- * @example
866
- * ```typescript
867
- * // Hours 0-40: base rate
868
- * // Hours 40-48: 1.5x
869
- * // Hours 48+: 2.0x
870
- * tieredOvertimeMultiplier([
871
- * { after: 40, factor: 1.5 },
872
- * { after: 48, factor: 2.0 },
873
- * ])
874
- * ```
875
- */
876
- export function tieredOvertimeMultiplier(
877
- tiers: [OvertimeTier, ...OvertimeTier[]],
878
- opts?: CostRuleOptions,
879
- ): RuleEntry {
880
- return makeRule("overtime-tiered-multiplier", { tiers, ...opts });
881
- }
882
-
883
- /**
884
- * Block assignments during specified periods.
885
- * Requires at least one time scope (`dayOfWeek`, `dateRange`, `dates`, or `from`/`until`).
886
- *
887
- * @example
888
- * ```typescript
889
- * // Full days off
890
- * timeOff({ appliesTo: "alice", dateRange: { start: "2024-02-01", end: "2024-02-05" } })
891
- *
892
- * // Every weekend off
893
- * timeOff({ appliesTo: "mauro", dayOfWeek: weekend })
894
- *
895
- * // Wednesday afternoons off
896
- * timeOff({ appliesTo: "student", dayOfWeek: ["wednesday"], from: t(14) })
897
- * ```
898
- *
899
- * @category Rules
900
- */
901
- export function timeOff(opts: TimeOffOptions): RuleEntry {
902
- const { from, until, ...rest } = opts;
903
- return defineRule("time-off", { from, until, ...rest }, (ctx) => {
904
- if (!rest.dayOfWeek && !rest.dateRange && !rest.dates && !rest.recurringPeriods) {
905
- throw new Error(
906
- "timeOff() requires at least one time scope (dayOfWeek, dateRange, dates, or recurringPeriods).",
907
- );
908
- }
909
-
910
- const { appliesTo, dates, ...passthrough } = rest;
911
- const entityScope = resolveAppliesTo(appliesTo, ctx.roles, ctx.skills, ctx.memberIds);
912
- const resolvedDates = dates ? { specificDates: dates } : {};
913
-
914
- const partialDay: Record<string, unknown> = {};
915
- if (from && until) {
916
- partialDay.startTime = from;
917
- partialDay.endTime = until;
918
- } else if (from) {
919
- partialDay.startTime = from;
920
- partialDay.endTime = { hours: 23, minutes: 59 };
921
- } else if (until) {
922
- partialDay.startTime = { hours: 0, minutes: 0 };
923
- partialDay.endTime = until;
924
- }
925
-
926
- return {
927
- name: "time-off",
928
- ...passthrough,
929
- ...entityScope,
930
- ...resolvedDates,
931
- ...partialDay,
932
- } as CpsatRuleConfigEntry;
933
- });
934
- }
935
-
936
- /**
937
- * Members work the same shifts on days they are both assigned.
938
- *
939
- * @example
940
- * ```typescript
941
- * assignTogether(["alice", "bob"])
942
- * assignTogether(["alice", "bob", "charlie"], { priority: "HIGH" })
943
- * ```
944
- *
945
- * @category Rules
946
- */
947
- export function assignTogether(
948
- memberIds: [string, string, ...string[]],
949
- opts?: AssignTogetherOptions,
950
- ): RuleEntry {
951
- return defineRule("assign-together", { members: memberIds, ...opts }, (ctx) => {
952
- for (const member of memberIds) {
953
- if (!ctx.memberIds.has(member)) {
954
- throw new Error(
955
- `assignTogether references unknown member "${member}". ` +
956
- `Known member IDs: ${[...ctx.memberIds].join(", ")}`,
957
- );
958
- }
959
- }
960
- return {
961
- name: "assign-together",
962
- groupMemberIds: memberIds,
963
- ...opts,
964
- } as CpsatRuleConfigEntry;
965
- });
966
- }
967
-
968
- /** A value that can be passed to {@link Schedule.with}. */
969
- type WithArg = Schedule | SchedulingMember[];
970
-
971
- // ============================================================================
972
- // SolveResult
973
- // ============================================================================
974
-
975
- /** Status of a solve attempt, using idiomatic lowercase TypeScript literals. */
976
- export type SolveStatus = "optimal" | "feasible" | "infeasible" | "no_solution";
977
-
978
- /**
979
- * Result of {@link Schedule.solve}.
980
- *
981
- * @category Schedule Definition
982
- */
983
- export interface SolveResult {
984
- /** Outcome of the solve attempt. */
985
- status: SolveStatus;
986
- /** Shift assignments (empty when infeasible or no solution). */
987
- assignments: ShiftAssignment[];
988
- /** Validation diagnostics from compilation. */
989
- validation: ScheduleValidation;
990
- /** Cost breakdown (present when cost rules are used and a solution is found). */
991
- cost?: CostBreakdown;
992
- }
993
-
994
- /**
995
- * Options for {@link Schedule.solve} and {@link Schedule.compile}.
996
- *
997
- * @category Schedule Definition
998
- */
999
- export interface SolveOptions {
1000
- /** The date range to schedule. */
1001
- dateRange: { start: string; end: string };
1002
- /**
1003
- * Fixed assignments from a prior solve (e.g., rolling schedule).
1004
- * These are injected as fixed variables in the solver.
1005
- *
1006
- * Not yet implemented. Providing pinned assignments throws an error.
1007
- */
1008
- pinned?: ShiftAssignment[];
1009
- }
1010
-
1011
- // ============================================================================
1012
- // Schedule Configuration
1013
- // ============================================================================
1014
-
1015
- /**
1016
- * Configuration for {@link schedule}.
1017
- *
1018
- * @remarks
1019
- * Coverage entries for the same semantic time and target stack additively.
1020
- * An unscoped entry applies every day; adding a weekend-only entry on top
1021
- * doubles the count on those days. Use mutually exclusive `dayOfWeek` on
1022
- * both entries to avoid stacking. See {@link cover} for details.
1023
- *
1024
- * `roleIds`, `times`, `coverage`, and `shiftPatterns` are required.
1025
- * These four fields form the minimum solvable schedule.
1026
- *
1027
- * @category Schedule Definition
1028
- */
1029
- export interface ScheduleConfig<
1030
- R extends readonly string[] = readonly string[],
1031
- S extends readonly string[] = readonly string[],
1032
- T extends Record<string, SemanticTimeEntry> = Record<string, SemanticTimeEntry>,
1033
- > {
1034
- /** Declared role IDs. */
1035
- roleIds: R;
1036
- /** Declared skill IDs. */
1037
- skillIds?: S;
1038
- /** Named semantic time periods. */
1039
- times: T;
1040
- /** Staffing requirements per time period (entries stack additively). */
1041
- coverage: CoverageEntry<keyof T & string, R[number] | NonNullable<S>[number]>[];
1042
- /** Available shift patterns. */
1043
- shiftPatterns: ShiftPattern[];
1044
- /** Scheduling rules and constraints. */
1045
- rules?: RuleEntry[];
1046
- /**
1047
- * Custom rule factories. Keys are rule names, values are functions
1048
- * that take a config object and return a {@link CompilationRule}.
1049
- * Built-in rule names cannot be overridden.
1050
- */
1051
- ruleFactories?: Record<string, CreateCpsatRuleFunction>;
1052
- /** Team members (typically added via `.with()` at runtime). */
1053
- members?: SchedulingMember[];
1054
- /** Days of the week the business operates (inclusion filter). */
1055
- dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
1056
- /** Which day starts the week for weekly rules. Defaults to `"monday"`. */
1057
- weekStartsOn?: DayOfWeek;
1058
- }
1059
-
1060
- // ============================================================================
1061
- // Internal merged config
1062
- // ============================================================================
1063
-
1064
- /** Internal representation of a fully merged schedule. */
1065
- interface MergedScheduleConfig {
1066
- roleIds: string[];
1067
- skillIds: string[];
1068
- times: Record<string, SemanticTimeEntry>;
1069
- coverage: CoverageEntry[];
1070
- shiftPatterns: ShiftPattern[];
1071
- rules: RuleEntry[];
1072
- ruleFactories: Record<string, CreateCpsatRuleFunction>;
1073
- members: SchedulingMember[];
1074
- dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]];
1075
- weekStartsOn?: DayOfWeek;
1076
- }
1077
-
1078
- // ============================================================================
1079
- // Schedule class
1080
- // ============================================================================
1081
-
1082
- /**
1083
- * An immutable schedule definition.
1084
- *
1085
- * Created by {@link schedule}, composed via {@link Schedule.with},
1086
- * and solved via {@link Schedule.solve}.
1087
- *
1088
- * @category Schedule Definition
1089
- */
1090
- export class Schedule {
1091
- readonly #config: Readonly<MergedScheduleConfig>;
1092
-
1093
- /** @internal */
1094
- constructor(config: MergedScheduleConfig) {
1095
- this.#config = config;
1096
- }
1097
-
1098
- /** @internal Returns a defensive copy of the config for merging. */
1099
- _getConfig(): MergedScheduleConfig {
1100
- return {
1101
- ...this.#config,
1102
- roleIds: [...this.#config.roleIds],
1103
- skillIds: [...this.#config.skillIds],
1104
- times: { ...this.#config.times },
1105
- coverage: [...this.#config.coverage],
1106
- shiftPatterns: [...this.#config.shiftPatterns],
1107
- rules: [...this.#config.rules],
1108
- ruleFactories: { ...this.#config.ruleFactories },
1109
- members: [...this.#config.members],
1110
- };
1111
- }
1112
-
1113
- // --------------------------------------------------------------------------
1114
- // Inspection
1115
- // --------------------------------------------------------------------------
1116
-
1117
- /** Declared role IDs. */
1118
- get roleIds(): readonly string[] {
1119
- return this.#config.roleIds;
1120
- }
1121
-
1122
- /** Declared skill IDs. */
1123
- get skillIds(): readonly string[] {
1124
- return this.#config.skillIds;
1125
- }
1126
-
1127
- /** Names of declared semantic times. */
1128
- get timeNames(): readonly string[] {
1129
- return Object.keys(this.#config.times);
1130
- }
1131
-
1132
- /** Shift pattern IDs. */
1133
- get shiftPatternIds(): readonly string[] {
1134
- return this.#config.shiftPatterns.map((sp) => sp.id);
1135
- }
1136
-
1137
- /** Internal rule identifiers in kebab-case. */
1138
- get ruleNames(): readonly string[] {
1139
- return this.#config.rules.map((r) => r._rule);
1140
- }
1141
-
1142
- // --------------------------------------------------------------------------
1143
- // Composition
1144
- // --------------------------------------------------------------------------
1145
-
1146
- /**
1147
- * Merges schedules or members onto this schedule, returning a new
1148
- * immutable `Schedule`. The original is untouched.
1149
- *
1150
- * Accepts any mix of `Schedule` instances and `SchedulingMember[]` arrays.
1151
- *
1152
- * Merge semantics (when merging schedules):
1153
- * - Roles: union (additive)
1154
- * - Skills: union (additive)
1155
- * - Times: additive; error on name collision
1156
- * - Coverage: additive
1157
- * - Shift patterns: additive; error on ID collision
1158
- * - Rules: additive
1159
- * - Members: additive; error on duplicate ID
1160
- *
1161
- * Validation runs eagerly: role/skill disjointness, coverage targets
1162
- * referencing declared roles/skills, member role references, etc.
1163
- */
1164
- with(...args: WithArg[]): Schedule {
1165
- const merged = mergeConfig(this.#config, args);
1166
- return new Schedule(merged);
1167
- }
1168
-
1169
- // --------------------------------------------------------------------------
1170
- // Solve / compile
1171
- // --------------------------------------------------------------------------
1172
-
1173
- /**
1174
- * Compiles, validates, solves, and parses in one call.
1175
- *
1176
- * @param client - Solver client (e.g., `new HttpSolverClient(fetch, url)`)
1177
- * @param options - Date range and optional pinned assignments
1178
- */
1179
- async solve(client: SolverClient, options: SolveOptions): Promise<SolveResult> {
1180
- const compiled = this.compile(options);
1181
- if (!compiled.canSolve) {
1182
- return {
1183
- status: "infeasible",
1184
- assignments: [],
1185
- validation: compiled.validation,
1186
- };
1187
- }
1188
-
1189
- const response = await client.solve(compiled.request);
1190
- return buildSolveResult(response, compiled, this.#config);
1191
- }
1192
-
1193
- /**
1194
- * Diagnostic escape hatch. Compiles the schedule without solving.
1195
- *
1196
- * @param options - Date range and optional pinned assignments
1197
- */
1198
- compile(options: SolveOptions): CompilationResult & { builder: ModelBuilder } {
1199
- if (options.pinned && options.pinned.length > 0) {
1200
- throw new Error("Pinned assignments are not yet supported.");
1201
- }
1202
-
1203
- const modelConfig = resolveToModelConfig(this.#config, options);
1204
- const builder = new ModelBuilder(modelConfig);
1205
- const result = builder.compile();
1206
- return { ...result, builder };
1207
- }
1208
- }
1209
-
1210
- // ============================================================================
1211
- // schedule() factory
1212
- // ============================================================================
1213
-
1214
- /**
1215
- * Create a schedule definition.
1216
- *
1217
- * Returns an immutable {@link Schedule} that can be composed via `.with()`
1218
- * and solved via `.solve()`.
1219
- *
1220
- * @example
1221
- * ```typescript
1222
- * const venue = schedule({
1223
- * roleIds: ["waiter", "runner", "manager"],
1224
- * skillIds: ["senior"],
1225
- * times: {
1226
- * lunch: time({ startTime: t(12), endTime: t(15) }),
1227
- * dinner: time(
1228
- * { startTime: t(17), endTime: t(21) },
1229
- * { startTime: t(18), endTime: t(22), dayOfWeek: weekend },
1230
- * ),
1231
- * },
1232
- * coverage: [
1233
- * cover("lunch", "waiter", 2),
1234
- * cover("dinner", "waiter", 4, { dayOfWeek: weekdays }),
1235
- * cover("dinner", "waiter", 5, { dayOfWeek: weekend }),
1236
- * cover("dinner", "manager", 1),
1237
- * ],
1238
- * shiftPatterns: [
1239
- * shift("lunch_shift", t(11, 30), t(15)),
1240
- * shift("evening", t(17), t(22)),
1241
- * ],
1242
- * rules: [
1243
- * maxHoursPerDay(10),
1244
- * maxHoursPerWeek(48),
1245
- * minRestBetweenShifts(11),
1246
- * ],
1247
- * });
1248
- * ```
1249
- *
1250
- * @category Schedule Definition
1251
- */
1252
- export function schedule<
1253
- const R extends readonly string[],
1254
- const S extends readonly string[],
1255
- const T extends Record<string, SemanticTimeEntry>,
1256
- >(config: ScheduleConfig<R, S, T>): Schedule {
1257
- const merged = buildMergedConfig(config as unknown as ScheduleConfig);
1258
- validateConfig(merged);
1259
- return new Schedule(merged);
1260
- }
1261
-
1262
- /**
1263
- * Create a partial schedule for composition via `.with()`.
1264
- *
1265
- * Unlike {@link schedule}, all fields are optional. Use this for
1266
- * schedules that layer rules, coverage, or other config onto a
1267
- * complete base schedule.
1268
- *
1269
- * @example
1270
- * ```typescript
1271
- * const companyPolicy = partialSchedule({
1272
- * rules: [maxHoursPerWeek(40), minRestBetweenShifts(11)],
1273
- * });
1274
- *
1275
- * const ready = venue.with(companyPolicy, teamMembers);
1276
- * ```
1277
- *
1278
- * @category Schedule Definition
1279
- */
1280
- export function partialSchedule(config: Partial<ScheduleConfig>): Schedule {
1281
- const merged = buildMergedConfig({
1282
- roleIds: [],
1283
- times: {},
1284
- coverage: [],
1285
- shiftPatterns: [],
1286
- ...config,
1287
- } as ScheduleConfig);
1288
- validateConfig(merged);
1289
- return new Schedule(merged);
1290
- }
1291
-
1292
- // ============================================================================
1293
- // Internal: Build merged config from user input
1294
- // ============================================================================
1295
-
1296
- function buildMergedConfig(config: ScheduleConfig): MergedScheduleConfig {
1297
- return {
1298
- roleIds: [...config.roleIds],
1299
- skillIds: [...(config.skillIds ?? [])],
1300
- times: { ...config.times },
1301
- coverage: [...config.coverage],
1302
- shiftPatterns: [...config.shiftPatterns],
1303
- rules: [...(config.rules ?? [])],
1304
- ruleFactories: config.ruleFactories ? { ...config.ruleFactories } : {},
1305
- members: [...(config.members ?? [])],
1306
- dayOfWeek: config.dayOfWeek,
1307
- weekStartsOn: config.weekStartsOn,
1308
- };
1309
- }
1310
-
1311
- // ============================================================================
1312
- // Internal: Validate merged config
1313
- // ============================================================================
1314
-
1315
- function validateConfig(config: MergedScheduleConfig): void {
1316
- const roles = new Set<string>(config.roleIds);
1317
- const skills = new Set<string>(config.skillIds);
1318
-
1319
- // Validate custom rule factories don't override built-in names
1320
- for (const name of Object.keys(config.ruleFactories)) {
1321
- if (name in builtInCpsatRuleFactories) {
1322
- throw new Error(
1323
- `Custom rule factory "${name}" conflicts with a built-in rule. Choose a different name.`,
1324
- );
1325
- }
1326
- }
1327
-
1328
- // Validate role/skill disjointness
1329
- for (const skill of skills) {
1330
- if (roles.has(skill)) {
1331
- throw new Error(
1332
- `"${skill}" is declared as both a role and a skill. Roles and skills must be disjoint.`,
1333
- );
1334
- }
1335
- }
1336
-
1337
- // Validate shift pattern role references
1338
- for (const sp of config.shiftPatterns) {
1339
- if (sp.roleIds) {
1340
- for (const role of sp.roleIds) {
1341
- if (!roles.has(role)) {
1342
- throw new Error(
1343
- `Shift pattern "${sp.id}" references unknown role "${role}". ` +
1344
- `Declared roles: ${[...roles].join(", ")}`,
1345
- );
1346
- }
1347
- }
1348
- }
1349
- }
1350
-
1351
- // Validate coverage entries
1352
- for (const entry of config.coverage) {
1353
- validateCoverageEntry(entry, roles, skills);
1354
- }
1355
-
1356
- // Validate member references
1357
- const memberIds = new Set<string>();
1358
- for (const member of config.members) {
1359
- if (memberIds.has(member.id)) {
1360
- throw new Error(`Duplicate member ID "${member.id}".`);
1361
- }
1362
- memberIds.add(member.id);
1363
-
1364
- if (roles.has(member.id)) {
1365
- throw new Error(`Member ID "${member.id}" collides with a declared role name.`);
1366
- }
1367
- if (skills.has(member.id)) {
1368
- throw new Error(`Member ID "${member.id}" collides with a declared skill name.`);
1369
- }
1370
-
1371
- for (const role of member.roleIds) {
1372
- if (!roles.has(role)) {
1373
- throw new Error(
1374
- `Member "${member.id}" references unknown role "${role}". ` +
1375
- `Declared roles: ${[...roles].join(", ")}`,
1376
- );
1377
- }
1378
- }
1379
- if (member.skillIds) {
1380
- for (const skill of member.skillIds) {
1381
- if (!skills.has(skill)) {
1382
- throw new Error(
1383
- `Member "${member.id}" references unknown skill "${skill}". ` +
1384
- `Declared skills: ${[...skills].join(", ")}`,
1385
- );
1386
- }
1387
- }
1388
- }
1389
- }
1390
- }
1391
-
1392
- function validateCoverageEntry(
1393
- entry: CoverageEntry,
1394
- roles: Set<string>,
1395
- skills: Set<string>,
1396
- ): void {
1397
- const targets = Array.isArray(entry.target) ? entry.target : [entry.target];
1398
- if (Array.isArray(entry.target)) {
1399
- for (const target of targets) {
1400
- if (!roles.has(target)) {
1401
- throw new Error(
1402
- `Coverage for "${entry.timeName}" references "${target}" in a role OR group, ` +
1403
- `but it is not a declared role. Declared roles: ${[...roles].join(", ")}`,
1404
- );
1405
- }
1406
- }
1407
- } else {
1408
- if (!roles.has(entry.target) && !skills.has(entry.target)) {
1409
- throw new Error(
1410
- `Coverage for "${entry.timeName}" references unknown target "${entry.target}". ` +
1411
- `Declared roles: ${[...roles].join(", ")}. ` +
1412
- `Declared skills: ${[...skills].join(", ")}`,
1413
- );
1414
- }
1415
- }
1416
- if (entry.options.skillIds) {
1417
- for (const s of entry.options.skillIds) {
1418
- if (!skills.has(s)) {
1419
- throw new Error(
1420
- `Coverage for "${entry.timeName}" uses skill filter "${s}" ` +
1421
- `which is not a declared skill. Declared skills: ${[...skills].join(", ")}`,
1422
- );
1423
- }
1424
- }
1425
- }
1426
- }
1427
-
1428
- // ============================================================================
1429
- // Internal: Merge logic
1430
- // ============================================================================
1431
-
1432
- function mergeConfig(base: Readonly<MergedScheduleConfig>, args: WithArg[]): MergedScheduleConfig {
1433
- const result: MergedScheduleConfig = {
1434
- roleIds: [...base.roleIds],
1435
- skillIds: [...base.skillIds],
1436
- times: { ...base.times },
1437
- coverage: [...base.coverage],
1438
- shiftPatterns: [...base.shiftPatterns],
1439
- rules: [...base.rules],
1440
- ruleFactories: { ...base.ruleFactories },
1441
- members: [...base.members],
1442
- dayOfWeek: base.dayOfWeek,
1443
- weekStartsOn: base.weekStartsOn,
1444
- };
1445
-
1446
- for (const arg of args) {
1447
- if (arg instanceof Schedule) {
1448
- mergeScheduleFragment(result, arg);
1449
- } else if (Array.isArray(arg)) {
1450
- mergeMembers(result, arg);
1451
- } else {
1452
- throw new Error(
1453
- `Unexpected argument passed to .with(): expected Schedule or SchedulingMember[], got ${typeof arg}`,
1454
- );
1455
- }
1456
- }
1457
-
1458
- // Validate the merged result
1459
- validateConfig(result);
1460
- return result;
1461
- }
1462
-
1463
- function mergeScheduleFragment(result: MergedScheduleConfig, s: Schedule): void {
1464
- const other = s._getConfig();
1465
-
1466
- // dayOfWeek: error on conflict (semantics of union vs intersection are ambiguous)
1467
- if (other.dayOfWeek !== undefined) {
1468
- if (result.dayOfWeek !== undefined) {
1469
- const baseSet = new Set(result.dayOfWeek);
1470
- const same =
1471
- result.dayOfWeek.length === other.dayOfWeek.length &&
1472
- other.dayOfWeek.every((d) => baseSet.has(d));
1473
- if (!same) {
1474
- throw new Error(
1475
- "Cannot merge schedules with different dayOfWeek filters. " +
1476
- `Base has [${result.dayOfWeek.join(", ")}], ` +
1477
- `incoming has [${other.dayOfWeek.join(", ")}].`,
1478
- );
1479
- }
1480
- } else {
1481
- result.dayOfWeek = other.dayOfWeek;
1482
- }
1483
- }
1484
-
1485
- // weekStartsOn: error on conflict (only one week boundary for weekly rules)
1486
- if (other.weekStartsOn !== undefined) {
1487
- if (result.weekStartsOn !== undefined && result.weekStartsOn !== other.weekStartsOn) {
1488
- throw new Error(
1489
- "Cannot merge schedules with different weekStartsOn values. " +
1490
- `Base has "${result.weekStartsOn}", incoming has "${other.weekStartsOn}".`,
1491
- );
1492
- }
1493
- result.weekStartsOn = other.weekStartsOn;
1494
- }
1495
-
1496
- // Roles: union
1497
- for (const role of other.roleIds) {
1498
- if (!result.roleIds.includes(role)) {
1499
- result.roleIds.push(role);
1500
- }
1501
- }
1502
-
1503
- // Skills: union
1504
- for (const skill of other.skillIds) {
1505
- if (!result.skillIds.includes(skill)) {
1506
- result.skillIds.push(skill);
1507
- }
1508
- }
1509
-
1510
- // Times: additive, error on collision
1511
- for (const [name, entry] of Object.entries(other.times)) {
1512
- if (name in result.times) {
1513
- throw new Error(
1514
- `Time name "${name}" already exists. Cannot merge schedules with colliding time names.`,
1515
- );
1516
- }
1517
- result.times[name] = entry;
1518
- }
1519
-
1520
- // Coverage: additive
1521
- result.coverage.push(...other.coverage);
1522
-
1523
- // Shift patterns: additive, error on ID collision
1524
- const existingIds = new Set(result.shiftPatterns.map((sp) => sp.id));
1525
- for (const sp of other.shiftPatterns) {
1526
- if (existingIds.has(sp.id)) {
1527
- throw new Error(
1528
- `Shift pattern ID "${sp.id}" already exists. Cannot merge schedules with colliding shift pattern IDs.`,
1529
- );
1530
- }
1531
- result.shiftPatterns.push(sp);
1532
- existingIds.add(sp.id);
1533
- }
1534
-
1535
- // Rules: additive
1536
- result.rules.push(...other.rules);
1537
-
1538
- // Rule factories: merge, error on collision
1539
- for (const [name, factory] of Object.entries(other.ruleFactories)) {
1540
- if (name in result.ruleFactories && result.ruleFactories[name] !== factory) {
1541
- throw new Error(
1542
- `Rule factory "${name}" already registered. Cannot merge schedules with colliding rule factories.`,
1543
- );
1544
- }
1545
- result.ruleFactories[name] = factory;
1546
- }
1547
-
1548
- // Members: additive, error on duplicate ID
1549
- const existingMemberIds = new Set(result.members.map((m) => m.id));
1550
- for (const member of other.members) {
1551
- if (existingMemberIds.has(member.id)) {
1552
- throw new Error(
1553
- `Duplicate member ID "${member.id}". Cannot merge schedules with colliding member IDs.`,
1554
- );
1555
- }
1556
- result.members.push(member);
1557
- existingMemberIds.add(member.id);
1558
- }
1559
- }
1560
-
1561
- function mergeMembers(result: MergedScheduleConfig, incoming: SchedulingMember[]): void {
1562
- const existingIds = new Set(result.members.map((m) => m.id));
1563
- for (const member of incoming) {
1564
- if (existingIds.has(member.id)) {
1565
- throw new Error(
1566
- `Duplicate member ID "${member.id}". Cannot merge members with colliding IDs.`,
1567
- );
1568
- }
1569
- result.members.push(member);
1570
- existingIds.add(member.id);
1571
- }
1572
- }
1573
-
1574
- // ============================================================================
1575
- // Internal: Resolve to ModelBuilderConfig
1576
- // ============================================================================
1577
-
1578
- function resolveToModelConfig(
1579
- config: Readonly<MergedScheduleConfig>,
1580
- options: SolveOptions,
1581
- ): ModelBuilderConfig {
1582
- const roles = new Set<string>(config.roleIds);
1583
- const skills = new Set<string>(config.skillIds);
1584
- const memberIds = new Set<string>(config.members.map((m) => m.id));
1585
-
1586
- // Build semantic time context
1587
- const semanticTimes = defineSemanticTimes(config.times);
1588
-
1589
- // Convert coverage entries to semantic coverage requirements
1590
- const coverageReqs = buildCoverageRequirements(config.coverage, roles, skills);
1591
-
1592
- // Resolve scheduling period with dayOfWeek filter
1593
- const schedulingPeriod: SchedulingPeriod = {
1594
- dateRange: options.dateRange,
1595
- };
1596
- const resolvedPeriod = applyDaysFilter(schedulingPeriod, config.dayOfWeek);
1597
- const days = resolveDaysFromPeriod(resolvedPeriod);
1598
-
1599
- // Resolve coverage
1600
- const resolvedCoverage = semanticTimes.resolve(coverageReqs, days);
1601
-
1602
- // Resolve rules
1603
- const allRules = [...config.rules];
1604
-
1605
- // Validate pay data when cost rules are present
1606
- const costRuleNames = new Set([
1607
- "minimize-cost",
1608
- "day-cost-multiplier",
1609
- "day-cost-surcharge",
1610
- "time-cost-surcharge",
1611
- "overtime-weekly-multiplier",
1612
- "overtime-weekly-surcharge",
1613
- "overtime-daily-multiplier",
1614
- "overtime-daily-surcharge",
1615
- "overtime-tiered-multiplier",
1616
- ]);
1617
- const hasCostRules = allRules.some((r) => costRuleNames.has(r._rule));
1618
- if (hasCostRules) {
1619
- const missingPay = config.members.filter((m) => !m.pay).map((m) => m.id);
1620
- if (missingPay.length > 0) {
1621
- throw new Error(
1622
- `Cost rules require pay data on all members. Missing pay: ${missingPay.join(", ")}`,
1623
- );
1624
- }
1625
- }
1626
-
1627
- // Sort rules so minimize-cost compiles before modifier rules
1628
- const sortedRules = sortCostRulesFirst(allRules);
1629
- const ruleConfigs = resolveRules(sortedRules, roles, skills, memberIds);
1630
-
1631
- return {
1632
- members: config.members,
1633
- shiftPatterns: config.shiftPatterns,
1634
- schedulingPeriod: resolvedPeriod,
1635
- coverage: resolvedCoverage,
1636
- ruleConfigs,
1637
- ruleFactories:
1638
- Object.keys(config.ruleFactories).length > 0
1639
- ? { ...builtInCpsatRuleFactories, ...config.ruleFactories }
1640
- : undefined,
1641
- weekStartsOn: config.weekStartsOn,
1642
- };
1643
- }
1644
-
1645
- // ============================================================================
1646
- // Internal: Build SolveResult from solver response
1647
- // ============================================================================
1648
-
1649
- function mapSolverStatus(solverStatus: SolverResponse["status"]): SolveStatus {
1650
- switch (solverStatus) {
1651
- case "OPTIMAL":
1652
- return "optimal";
1653
- case "FEASIBLE":
1654
- return "feasible";
1655
- case "INFEASIBLE":
1656
- return "infeasible";
1657
- case "TIMEOUT":
1658
- case "ERROR":
1659
- return "no_solution";
1660
- default:
1661
- return "no_solution";
1662
- }
1663
- }
1664
-
1665
- function buildSolveResult(
1666
- response: SolverResponse,
1667
- compiled: CompilationResult & { builder: ModelBuilder },
1668
- config: Readonly<MergedScheduleConfig>,
1669
- ): SolveResult {
1670
- const status = mapSolverStatus(response.status);
1671
- const parsed = parseSolverResponse(response);
1672
-
1673
- // Run post-solve validation when a solution exists
1674
- if (parsed.assignments.length > 0 && (status === "optimal" || status === "feasible")) {
1675
- const resolved = resolveAssignments(parsed.assignments, compiled.builder.shiftPatterns);
1676
- compiled.builder.reporter.analyzeSolution(response);
1677
- compiled.builder.validateSolution(resolved);
1678
- }
1679
-
1680
- const validation = compiled.builder.reporter.getValidation();
1681
-
1682
- const result: SolveResult = {
1683
- status,
1684
- assignments: parsed.assignments,
1685
- validation,
1686
- };
1687
-
1688
- // Compute cost breakdown when cost rules are present and a solution was found
1689
- if (parsed.assignments.length > 0 && (status === "optimal" || status === "feasible")) {
1690
- const hasCostRules = config.rules.some((r) => r._rule === "minimize-cost");
1691
- if (hasCostRules) {
1692
- result.cost = calculateScheduleCost(parsed.assignments, {
1693
- members: config.members,
1694
- shiftPatterns: config.shiftPatterns,
1695
- rules: compiled.builder.rules,
1696
- });
1697
- }
1698
- }
1699
-
1700
- return result;
1701
- }
1702
-
1703
- // ============================================================================
1704
- // Internal: Coverage Translation
1705
- // ============================================================================
1706
-
1707
- function buildCoverageRequirements<T extends string>(
1708
- entries: CoverageEntry<T, string>[],
1709
- roles: Set<string>,
1710
- skills: Set<string>,
1711
- ): MixedCoverageRequirement<T>[] {
1712
- return entries.map((entry) => {
1713
- // Variant form: produce a VariantCoverageRequirement
1714
- if (entry.variants) {
1715
- return buildVariantCoverageRequirement(entry, roles, skills);
1716
- }
1717
-
1718
- // Simple form: produce a SemanticCoverageRequirement
1719
- const base: {
1720
- semanticTime: T;
1721
- targetCount: number;
1722
- priority?: Priority;
1723
- dayOfWeek?: [DayOfWeek, ...DayOfWeek[]];
1724
- dates?: string[];
1725
- } = {
1726
- semanticTime: entry.timeName,
1727
- targetCount: entry.count,
1728
- };
1729
-
1730
- if (entry.options.priority) base.priority = entry.options.priority;
1731
- if (entry.options.dayOfWeek && entry.options.dayOfWeek.length > 0) {
1732
- base.dayOfWeek = entry.options.dayOfWeek as [DayOfWeek, ...DayOfWeek[]];
1733
- }
1734
- if (entry.options.dates) base.dates = entry.options.dates;
1735
-
1736
- return buildSimpleCoverageTarget(entry, base, roles, skills);
1737
- }) as MixedCoverageRequirement<T>[];
1738
- }
1739
-
1740
- /**
1741
- * Resolve the target (role/skill) for a simple coverage entry.
1742
- */
1743
- function buildSimpleCoverageTarget<T extends string>(
1744
- entry: CoverageEntry<T, string>,
1745
- base: {
1746
- semanticTime: T;
1747
- targetCount: number;
1748
- priority?: Priority;
1749
- dayOfWeek?: [DayOfWeek, ...DayOfWeek[]];
1750
- dates?: string[];
1751
- },
1752
- roles: Set<string>,
1753
- skills: Set<string>,
1754
- ): MixedCoverageRequirement<T> {
1755
- if (Array.isArray(entry.target)) {
1756
- return {
1757
- ...base,
1758
- roleIds: entry.target as [string, ...string[]],
1759
- } satisfies MixedCoverageRequirement<T>;
1760
- }
1761
-
1762
- const singleTarget = entry.target as string;
1763
- if (roles.has(singleTarget)) {
1764
- if (entry.options.skillIds) {
1765
- return {
1766
- ...base,
1767
- roleIds: [singleTarget] as [string, ...string[]],
1768
- skillIds: entry.options.skillIds,
1769
- } satisfies MixedCoverageRequirement<T>;
1770
- }
1771
- return {
1772
- ...base,
1773
- roleIds: [singleTarget] as [string, ...string[]],
1774
- } satisfies MixedCoverageRequirement<T>;
1775
- }
1776
-
1777
- if (skills.has(singleTarget)) {
1778
- return {
1779
- ...base,
1780
- skillIds: [singleTarget] as [string, ...string[]],
1781
- } satisfies MixedCoverageRequirement<T>;
1782
- }
1783
-
1784
- throw new Error(`Coverage target "${singleTarget}" is not a declared role or skill.`);
1785
- }
1786
-
1787
- /**
1788
- * Build a VariantCoverageRequirement from a variant-form CoverageEntry.
1789
- */
1790
- function buildVariantCoverageRequirement<T extends string>(
1791
- entry: CoverageEntry<T, string>,
1792
- roles: Set<string>,
1793
- skills: Set<string>,
1794
- ): MixedCoverageRequirement<T> {
1795
- const variants = entry.variants! as unknown as [CoverageVariant, ...CoverageVariant[]];
1796
-
1797
- const resolveTarget = (): {
1798
- roleIds?: [string, ...string[]];
1799
- skillIds?: [string, ...string[]];
1800
- } => {
1801
- if (Array.isArray(entry.target)) {
1802
- return { roleIds: entry.target as [string, ...string[]] };
1803
- }
1804
- const singleTarget = entry.target as string;
1805
- if (roles.has(singleTarget)) {
1806
- return { roleIds: [singleTarget] as [string, ...string[]] };
1807
- }
1808
- if (skills.has(singleTarget)) {
1809
- return { skillIds: [singleTarget] as [string, ...string[]] };
1810
- }
1811
- throw new Error(`Coverage target "${singleTarget}" is not a declared role or skill.`);
1812
- };
1813
-
1814
- return {
1815
- semanticTime: entry.timeName,
1816
- variants,
1817
- ...resolveTarget(),
1818
- } as MixedCoverageRequirement<T>;
1819
- }
1820
-
1821
- // ============================================================================
1822
- // Internal: Rule Translation
1823
- // ============================================================================
1824
-
1825
- /**
1826
- * Resolves an `appliesTo` value into entity scope fields.
1827
- *
1828
- * Each target string is checked against roles, skills, then member IDs.
1829
- * If all targets resolve to the same namespace, they are combined into one
1830
- * scope field. If they span namespaces, an error is thrown; the caller
1831
- * should use separate rule entries instead.
1832
- */
1833
- function resolveAppliesTo(
1834
- appliesTo: string | string[] | undefined,
1835
- roles: ReadonlySet<string>,
1836
- skills: ReadonlySet<string>,
1837
- memberIds: ReadonlySet<string>,
1838
- ): {
1839
- memberIds?: [string, ...string[]];
1840
- roleIds?: [string, ...string[]];
1841
- skillIds?: [string, ...string[]];
1842
- } {
1843
- if (!appliesTo) return {};
1844
-
1845
- const targets = Array.isArray(appliesTo) ? appliesTo : [appliesTo];
1846
- if (targets.length === 0) return {};
1847
-
1848
- const resolvedRoles: string[] = [];
1849
- const resolvedSkills: string[] = [];
1850
- const resolvedMembers: string[] = [];
1851
-
1852
- for (const target of targets) {
1853
- if (roles.has(target)) {
1854
- resolvedRoles.push(target);
1855
- } else if (skills.has(target)) {
1856
- resolvedSkills.push(target);
1857
- } else if (memberIds.has(target)) {
1858
- resolvedMembers.push(target);
1859
- } else {
1860
- throw new Error(`appliesTo target "${target}" is not a declared role, skill, or member ID.`);
1861
- }
1862
- }
1863
-
1864
- // Count how many namespaces were used
1865
- const namespacesUsed = [resolvedRoles, resolvedSkills, resolvedMembers].filter(
1866
- (arr) => arr.length > 0,
1867
- ).length;
1868
-
1869
- if (namespacesUsed > 1) {
1870
- throw new Error(
1871
- `appliesTo targets span multiple namespaces (roles: [${resolvedRoles.join(", ")}], ` +
1872
- `skills: [${resolvedSkills.join(", ")}], members: [${resolvedMembers.join(", ")}]). ` +
1873
- `Use separate rule entries for each namespace.`,
1874
- );
1875
- }
1876
-
1877
- if (resolvedRoles.length > 0) {
1878
- return { roleIds: resolvedRoles as [string, ...string[]] };
1879
- }
1880
- if (resolvedSkills.length > 0) {
1881
- return { skillIds: resolvedSkills as [string, ...string[]] };
1882
- }
1883
- if (resolvedMembers.length > 0) {
1884
- return { memberIds: resolvedMembers as [string, ...string[]] };
1885
- }
1886
- return {};
1887
- }
1888
-
1889
- function resolveRules(
1890
- rules: RuleEntry[],
1891
- roles: Set<string>,
1892
- skills: Set<string>,
1893
- memberIds: Set<string>,
1894
- ): CpsatRuleConfigEntry[] {
1895
- const ctx: RuleResolveContext = { roles, skills, memberIds };
1896
-
1897
- return rules.map((rule) => {
1898
- // Rules with custom resolvers handle their own translation
1899
- if (rule._resolve) {
1900
- return rule._resolve(ctx) as CpsatRuleConfigEntry;
1901
- }
1902
-
1903
- // Default resolution: appliesTo → entity scope, dates → specificDates
1904
- const { _type, _rule, _resolve, appliesTo, dates, ...passthrough } = rule as RuleEntry & {
1905
- appliesTo?: string | string[];
1906
- dates?: string[];
1907
- };
1908
-
1909
- const entityScope = resolveAppliesTo(appliesTo, roles, skills, memberIds);
1910
- const resolvedDates = dates ? { specificDates: dates } : {};
1911
-
1912
- return {
1913
- name: _rule,
1914
- ...passthrough,
1915
- ...entityScope,
1916
- ...resolvedDates,
1917
- } as CpsatRuleConfigEntry;
1918
- }) as CpsatRuleConfigEntry[];
1919
- }
1920
-
1921
- // ============================================================================
1922
- // Internal: Cost Rule Ordering
1923
- // ============================================================================
1924
-
1925
- /**
1926
- * Sorts rules so that `minimize-cost` compiles before cost modifier rules.
1927
- *
1928
- * The `minimize-cost` rule must be compiled first because modifier rules
1929
- * (multipliers, surcharges) reference cost variables it creates.
1930
- * Non-cost rules retain their original relative order.
1931
- */
1932
- function sortCostRulesFirst(rules: RuleEntry[]): RuleEntry[] {
1933
- return rules.toSorted((a, b) => {
1934
- const aIsCostBase = a._rule === "minimize-cost" ? 0 : 1;
1935
- const bIsCostBase = b._rule === "minimize-cost" ? 0 : 1;
1936
- return aIsCostBase - bIsCostBase;
1937
- });
1938
- }
1939
-
1940
- // ============================================================================
1941
- // Internal: Scheduling Period Helpers
1942
- // ============================================================================
1943
-
1944
- function applyDaysFilter(
1945
- schedulingPeriod: SchedulingPeriod,
1946
- dayOfWeek?: readonly [DayOfWeek, ...DayOfWeek[]],
1947
- ): SchedulingPeriod {
1948
- if (!dayOfWeek || dayOfWeek.length === 0) {
1949
- return schedulingPeriod;
1950
- }
1951
-
1952
- const existingDays = schedulingPeriod.dayOfWeek;
1953
- if (!existingDays || existingDays.length === 0) {
1954
- return { ...schedulingPeriod, dayOfWeek: [...dayOfWeek] };
1955
- }
1956
-
1957
- const existingSet = new Set(existingDays);
1958
- const intersected = dayOfWeek.filter((day) => existingSet.has(day));
1959
- return { ...schedulingPeriod, dayOfWeek: intersected };
1960
- }