dabke 0.83.0 → 0.84.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 (330) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +22 -27
  3. package/dist/client.d.ts +39 -3
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js.map +1 -1
  6. package/dist/client.schemas.d.ts +118 -18
  7. package/dist/client.schemas.d.ts.map +1 -1
  8. package/dist/client.schemas.js +110 -6
  9. package/dist/client.schemas.js.map +1 -1
  10. package/dist/client.types.d.ts +63 -7
  11. package/dist/client.types.d.ts.map +1 -1
  12. package/dist/client.types.js.map +1 -1
  13. package/dist/cpsat/cost.d.ts +5 -2
  14. package/dist/cpsat/cost.d.ts.map +1 -1
  15. package/dist/cpsat/cost.js +13 -6
  16. package/dist/cpsat/cost.js.map +1 -1
  17. package/dist/cpsat/model-builder.d.ts +42 -61
  18. package/dist/cpsat/model-builder.d.ts.map +1 -1
  19. package/dist/cpsat/model-builder.js +451 -56
  20. package/dist/cpsat/model-builder.js.map +1 -1
  21. package/dist/cpsat/response.d.ts +19 -7
  22. package/dist/cpsat/response.d.ts.map +1 -1
  23. package/dist/cpsat/response.js +48 -9
  24. package/dist/cpsat/response.js.map +1 -1
  25. package/dist/cpsat/rule-descriptor.d.ts +250 -0
  26. package/dist/cpsat/rule-descriptor.d.ts.map +1 -0
  27. package/dist/cpsat/rule-descriptor.js +51 -0
  28. package/dist/cpsat/rule-descriptor.js.map +1 -0
  29. package/dist/cpsat/rules/artifacts.d.ts +9 -0
  30. package/dist/cpsat/rules/artifacts.d.ts.map +1 -0
  31. package/dist/cpsat/rules/artifacts.js +31 -0
  32. package/dist/cpsat/rules/artifacts.js.map +1 -0
  33. package/dist/cpsat/rules/assign-together.d.ts +18 -20
  34. package/dist/cpsat/rules/assign-together.d.ts.map +1 -1
  35. package/dist/cpsat/rules/assign-together.js +90 -54
  36. package/dist/cpsat/rules/assign-together.js.map +1 -1
  37. package/dist/cpsat/rules/assigned-day.d.ts +12 -0
  38. package/dist/cpsat/rules/assigned-day.d.ts.map +1 -0
  39. package/dist/cpsat/rules/assigned-day.js +43 -0
  40. package/dist/cpsat/rules/assigned-day.js.map +1 -0
  41. package/dist/cpsat/rules/assignment-priority.d.ts +15 -29
  42. package/dist/cpsat/rules/assignment-priority.d.ts.map +1 -1
  43. package/dist/cpsat/rules/assignment-priority.js +47 -50
  44. package/dist/cpsat/rules/assignment-priority.js.map +1 -1
  45. package/dist/cpsat/rules/assignment-terms.d.ts +6 -0
  46. package/dist/cpsat/rules/assignment-terms.d.ts.map +1 -0
  47. package/dist/cpsat/rules/assignment-terms.js +19 -0
  48. package/dist/cpsat/rules/assignment-terms.js.map +1 -0
  49. package/dist/cpsat/rules/concurrent-intervals.d.ts +19 -0
  50. package/dist/cpsat/rules/concurrent-intervals.d.ts.map +1 -0
  51. package/dist/cpsat/rules/concurrent-intervals.js +57 -0
  52. package/dist/cpsat/rules/concurrent-intervals.js.map +1 -0
  53. package/dist/cpsat/rules/cost-utils.d.ts +1 -3
  54. package/dist/cpsat/rules/cost-utils.d.ts.map +1 -1
  55. package/dist/cpsat/rules/cost-utils.js +0 -7
  56. package/dist/cpsat/rules/cost-utils.js.map +1 -1
  57. package/dist/cpsat/rules/day-cost-multiplier.d.ts +10 -10
  58. package/dist/cpsat/rules/day-cost-multiplier.d.ts.map +1 -1
  59. package/dist/cpsat/rules/day-cost-multiplier.js +88 -77
  60. package/dist/cpsat/rules/day-cost-multiplier.js.map +1 -1
  61. package/dist/cpsat/rules/day-cost-surcharge.d.ts +10 -10
  62. package/dist/cpsat/rules/day-cost-surcharge.d.ts.map +1 -1
  63. package/dist/cpsat/rules/day-cost-surcharge.js +76 -65
  64. package/dist/cpsat/rules/day-cost-surcharge.js.map +1 -1
  65. package/dist/cpsat/rules/index.d.ts +30 -24
  66. package/dist/cpsat/rules/index.d.ts.map +1 -1
  67. package/dist/cpsat/rules/index.js +30 -24
  68. package/dist/cpsat/rules/index.js.map +1 -1
  69. package/dist/cpsat/rules/location-preference.d.ts +14 -21
  70. package/dist/cpsat/rules/location-preference.d.ts.map +1 -1
  71. package/dist/cpsat/rules/location-preference.js +45 -45
  72. package/dist/cpsat/rules/location-preference.js.map +1 -1
  73. package/dist/cpsat/rules/max-concurrent-assignments.d.ts +46 -0
  74. package/dist/cpsat/rules/max-concurrent-assignments.d.ts.map +1 -0
  75. package/dist/cpsat/rules/max-concurrent-assignments.js +121 -0
  76. package/dist/cpsat/rules/max-concurrent-assignments.js.map +1 -0
  77. package/dist/cpsat/rules/max-consecutive-days.d.ts +18 -17
  78. package/dist/cpsat/rules/max-consecutive-days.d.ts.map +1 -1
  79. package/dist/cpsat/rules/max-consecutive-days.js +67 -66
  80. package/dist/cpsat/rules/max-consecutive-days.js.map +1 -1
  81. package/dist/cpsat/rules/max-days-of-week-per-period.d.ts +45 -0
  82. package/dist/cpsat/rules/max-days-of-week-per-period.d.ts.map +1 -0
  83. package/dist/cpsat/rules/max-days-of-week-per-period.js +95 -0
  84. package/dist/cpsat/rules/max-days-of-week-per-period.js.map +1 -0
  85. package/dist/cpsat/rules/max-days-week.d.ts +17 -31
  86. package/dist/cpsat/rules/max-days-week.d.ts.map +1 -1
  87. package/dist/cpsat/rules/max-days-week.js +69 -81
  88. package/dist/cpsat/rules/max-days-week.js.map +1 -1
  89. package/dist/cpsat/rules/max-hours-day.d.ts +14 -31
  90. package/dist/cpsat/rules/max-hours-day.d.ts.map +1 -1
  91. package/dist/cpsat/rules/max-hours-day.js +56 -72
  92. package/dist/cpsat/rules/max-hours-day.js.map +1 -1
  93. package/dist/cpsat/rules/max-hours-week.d.ts +15 -31
  94. package/dist/cpsat/rules/max-hours-week.d.ts.map +1 -1
  95. package/dist/cpsat/rules/max-hours-week.js +60 -75
  96. package/dist/cpsat/rules/max-hours-week.js.map +1 -1
  97. package/dist/cpsat/rules/max-shifts-day.d.ts +17 -33
  98. package/dist/cpsat/rules/max-shifts-day.d.ts.map +1 -1
  99. package/dist/cpsat/rules/max-shifts-day.js +57 -73
  100. package/dist/cpsat/rules/max-shifts-day.js.map +1 -1
  101. package/dist/cpsat/rules/min-consecutive-days.d.ts +18 -18
  102. package/dist/cpsat/rules/min-consecutive-days.d.ts.map +1 -1
  103. package/dist/cpsat/rules/min-consecutive-days.js +123 -94
  104. package/dist/cpsat/rules/min-consecutive-days.js.map +1 -1
  105. package/dist/cpsat/rules/min-days-of-week-per-period.d.ts +35 -0
  106. package/dist/cpsat/rules/min-days-of-week-per-period.d.ts.map +1 -0
  107. package/dist/cpsat/rules/min-days-of-week-per-period.js +108 -0
  108. package/dist/cpsat/rules/min-days-of-week-per-period.js.map +1 -0
  109. package/dist/cpsat/rules/min-days-week.d.ts +13 -26
  110. package/dist/cpsat/rules/min-days-week.d.ts.map +1 -1
  111. package/dist/cpsat/rules/min-days-week.js +85 -74
  112. package/dist/cpsat/rules/min-days-week.js.map +1 -1
  113. package/dist/cpsat/rules/min-hours-day.d.ts +14 -17
  114. package/dist/cpsat/rules/min-hours-day.d.ts.map +1 -1
  115. package/dist/cpsat/rules/min-hours-day.js +72 -54
  116. package/dist/cpsat/rules/min-hours-day.js.map +1 -1
  117. package/dist/cpsat/rules/min-hours-week.d.ts +15 -18
  118. package/dist/cpsat/rules/min-hours-week.d.ts.map +1 -1
  119. package/dist/cpsat/rules/min-hours-week.js +75 -57
  120. package/dist/cpsat/rules/min-hours-week.js.map +1 -1
  121. package/dist/cpsat/rules/min-rest-between-shifts.d.ts +14 -17
  122. package/dist/cpsat/rules/min-rest-between-shifts.d.ts.map +1 -1
  123. package/dist/cpsat/rules/min-rest-between-shifts.js +148 -138
  124. package/dist/cpsat/rules/min-rest-between-shifts.js.map +1 -1
  125. package/dist/cpsat/rules/minimize-cost.d.ts +2 -18
  126. package/dist/cpsat/rules/minimize-cost.d.ts.map +1 -1
  127. package/dist/cpsat/rules/minimize-cost.js +151 -163
  128. package/dist/cpsat/rules/minimize-cost.js.map +1 -1
  129. package/dist/cpsat/rules/must-assign.d.ts +5 -42
  130. package/dist/cpsat/rules/must-assign.d.ts.map +1 -1
  131. package/dist/cpsat/rules/must-assign.js +43 -75
  132. package/dist/cpsat/rules/must-assign.js.map +1 -1
  133. package/dist/cpsat/rules/overtime-daily-multiplier.d.ts +6 -10
  134. package/dist/cpsat/rules/overtime-daily-multiplier.d.ts.map +1 -1
  135. package/dist/cpsat/rules/overtime-daily-multiplier.js +118 -112
  136. package/dist/cpsat/rules/overtime-daily-multiplier.js.map +1 -1
  137. package/dist/cpsat/rules/overtime-daily-surcharge.d.ts +6 -11
  138. package/dist/cpsat/rules/overtime-daily-surcharge.d.ts.map +1 -1
  139. package/dist/cpsat/rules/overtime-daily-surcharge.js +104 -99
  140. package/dist/cpsat/rules/overtime-daily-surcharge.js.map +1 -1
  141. package/dist/cpsat/rules/overtime-tiered-multiplier.d.ts +9 -21
  142. package/dist/cpsat/rules/overtime-tiered-multiplier.d.ts.map +1 -1
  143. package/dist/cpsat/rules/overtime-tiered-multiplier.js +181 -199
  144. package/dist/cpsat/rules/overtime-tiered-multiplier.js.map +1 -1
  145. package/dist/cpsat/rules/overtime-weekly-multiplier.d.ts +13 -12
  146. package/dist/cpsat/rules/overtime-weekly-multiplier.d.ts.map +1 -1
  147. package/dist/cpsat/rules/overtime-weekly-multiplier.js +143 -143
  148. package/dist/cpsat/rules/overtime-weekly-multiplier.js.map +1 -1
  149. package/dist/cpsat/rules/overtime-weekly-surcharge.d.ts +7 -11
  150. package/dist/cpsat/rules/overtime-weekly-surcharge.d.ts.map +1 -1
  151. package/dist/cpsat/rules/overtime-weekly-surcharge.js +124 -119
  152. package/dist/cpsat/rules/overtime-weekly-surcharge.js.map +1 -1
  153. package/dist/cpsat/rules/pattern-eligibility.d.ts +6 -0
  154. package/dist/cpsat/rules/pattern-eligibility.d.ts.map +1 -0
  155. package/dist/cpsat/rules/pattern-eligibility.js +16 -0
  156. package/dist/cpsat/rules/pattern-eligibility.js.map +1 -0
  157. package/dist/cpsat/rules/pattern-time.d.ts +5 -0
  158. package/dist/cpsat/rules/pattern-time.d.ts.map +1 -0
  159. package/dist/cpsat/rules/pattern-time.js +20 -0
  160. package/dist/cpsat/rules/pattern-time.js.map +1 -0
  161. package/dist/cpsat/rules/registry.d.ts +217 -30
  162. package/dist/cpsat/rules/registry.d.ts.map +1 -1
  163. package/dist/cpsat/rules/registry.js +98 -34
  164. package/dist/cpsat/rules/registry.js.map +1 -1
  165. package/dist/cpsat/rules/resolver.d.ts +9 -7
  166. package/dist/cpsat/rules/resolver.d.ts.map +1 -1
  167. package/dist/cpsat/rules/resolver.js +19 -18
  168. package/dist/cpsat/rules/resolver.js.map +1 -1
  169. package/dist/cpsat/rules/role-preference.d.ts +24 -0
  170. package/dist/cpsat/rules/role-preference.d.ts.map +1 -0
  171. package/dist/cpsat/rules/role-preference.js +72 -0
  172. package/dist/cpsat/rules/role-preference.js.map +1 -0
  173. package/dist/cpsat/rules/rules.types.d.ts +90 -17
  174. package/dist/cpsat/rules/rules.types.d.ts.map +1 -1
  175. package/dist/cpsat/rules/scope.types.d.ts +24 -11
  176. package/dist/cpsat/rules/scope.types.d.ts.map +1 -1
  177. package/dist/cpsat/rules/scope.types.js +14 -19
  178. package/dist/cpsat/rules/scope.types.js.map +1 -1
  179. package/dist/cpsat/rules/target-days-week.d.ts +31 -0
  180. package/dist/cpsat/rules/target-days-week.d.ts.map +1 -0
  181. package/dist/cpsat/rules/target-days-week.js +80 -0
  182. package/dist/cpsat/rules/target-days-week.js.map +1 -0
  183. package/dist/cpsat/rules/target-peak-concurrent-assignments.d.ts +33 -0
  184. package/dist/cpsat/rules/target-peak-concurrent-assignments.d.ts.map +1 -0
  185. package/dist/cpsat/rules/target-peak-concurrent-assignments.js +146 -0
  186. package/dist/cpsat/rules/target-peak-concurrent-assignments.js.map +1 -0
  187. package/dist/cpsat/rules/time-cost-surcharge.d.ts +15 -14
  188. package/dist/cpsat/rules/time-cost-surcharge.d.ts.map +1 -1
  189. package/dist/cpsat/rules/time-cost-surcharge.js +82 -96
  190. package/dist/cpsat/rules/time-cost-surcharge.js.map +1 -1
  191. package/dist/cpsat/rules/time-off.d.ts +21 -57
  192. package/dist/cpsat/rules/time-off.d.ts.map +1 -1
  193. package/dist/cpsat/rules/time-off.js +114 -127
  194. package/dist/cpsat/rules/time-off.js.map +1 -1
  195. package/dist/cpsat/rules/variables.d.ts +4 -0
  196. package/dist/cpsat/rules/variables.d.ts.map +1 -0
  197. package/dist/cpsat/rules/variables.js +10 -0
  198. package/dist/cpsat/rules/variables.js.map +1 -0
  199. package/dist/cpsat/rules.d.ts +20 -3
  200. package/dist/cpsat/rules.d.ts.map +1 -1
  201. package/dist/cpsat/rules.js +19 -2
  202. package/dist/cpsat/rules.js.map +1 -1
  203. package/dist/cpsat/semantic-time.d.ts +7 -6
  204. package/dist/cpsat/semantic-time.d.ts.map +1 -1
  205. package/dist/cpsat/semantic-time.js +15 -28
  206. package/dist/cpsat/semantic-time.js.map +1 -1
  207. package/dist/cpsat/types.d.ts +47 -4
  208. package/dist/cpsat/types.d.ts.map +1 -1
  209. package/dist/cpsat/types.js +24 -1
  210. package/dist/cpsat/types.js.map +1 -1
  211. package/dist/cpsat/utils.d.ts +23 -6
  212. package/dist/cpsat/utils.d.ts.map +1 -1
  213. package/dist/cpsat/utils.js +76 -15
  214. package/dist/cpsat/utils.js.map +1 -1
  215. package/dist/cpsat/validation-reporter.d.ts +6 -2
  216. package/dist/cpsat/validation-reporter.d.ts.map +1 -1
  217. package/dist/cpsat/validation-reporter.js +122 -10
  218. package/dist/cpsat/validation-reporter.js.map +1 -1
  219. package/dist/cpsat/validation.types.d.ts +21 -10
  220. package/dist/cpsat/validation.types.d.ts.map +1 -1
  221. package/dist/datetime.utils.d.ts +7 -13
  222. package/dist/datetime.utils.d.ts.map +1 -1
  223. package/dist/datetime.utils.js +29 -36
  224. package/dist/datetime.utils.js.map +1 -1
  225. package/dist/index.d.ts +12 -8
  226. package/dist/index.d.ts.map +1 -1
  227. package/dist/index.js +8 -3
  228. package/dist/index.js.map +1 -1
  229. package/dist/schedule/cost.d.ts +48 -60
  230. package/dist/schedule/cost.d.ts.map +1 -1
  231. package/dist/schedule/cost.js +15 -51
  232. package/dist/schedule/cost.js.map +1 -1
  233. package/dist/schedule/coverage.d.ts +57 -28
  234. package/dist/schedule/coverage.d.ts.map +1 -1
  235. package/dist/schedule/coverage.js +24 -1
  236. package/dist/schedule/coverage.js.map +1 -1
  237. package/dist/schedule/definition.d.ts +138 -19
  238. package/dist/schedule/definition.d.ts.map +1 -1
  239. package/dist/schedule/definition.js +99 -90
  240. package/dist/schedule/definition.js.map +1 -1
  241. package/dist/schedule/index.d.ts +4 -4
  242. package/dist/schedule/index.d.ts.map +1 -1
  243. package/dist/schedule/index.js +2 -2
  244. package/dist/schedule/index.js.map +1 -1
  245. package/dist/schedule/rules.d.ts +438 -113
  246. package/dist/schedule/rules.d.ts.map +1 -1
  247. package/dist/schedule/rules.js +310 -108
  248. package/dist/schedule/rules.js.map +1 -1
  249. package/dist/schedule/shift-patterns.d.ts +0 -17
  250. package/dist/schedule/shift-patterns.d.ts.map +1 -1
  251. package/dist/schedule/shift-patterns.js +0 -17
  252. package/dist/schedule/shift-patterns.js.map +1 -1
  253. package/dist/schedule/time-periods.d.ts +37 -27
  254. package/dist/schedule/time-periods.d.ts.map +1 -1
  255. package/dist/schedule/time-periods.js +37 -27
  256. package/dist/schedule/time-periods.js.map +1 -1
  257. package/dist/types.d.ts +67 -3
  258. package/dist/types.d.ts.map +1 -1
  259. package/dist/types.js +64 -0
  260. package/dist/types.js.map +1 -1
  261. package/package.json +8 -9
  262. package/solver/src/solver/models.py +41 -4
  263. package/solver/src/solver/solver.py +546 -157
  264. package/solver/tests/test_solver.py +363 -7
  265. package/src/client.schemas.ts +127 -10
  266. package/src/client.ts +3 -0
  267. package/src/client.types.ts +73 -7
  268. package/src/cpsat/cost.ts +17 -7
  269. package/src/cpsat/model-builder.ts +614 -129
  270. package/src/cpsat/response.ts +57 -13
  271. package/src/cpsat/rule-descriptor.ts +311 -0
  272. package/src/cpsat/rules/artifacts.ts +56 -0
  273. package/src/cpsat/rules/assign-together.ts +104 -69
  274. package/src/cpsat/rules/assigned-day.ts +70 -0
  275. package/src/cpsat/rules/assignment-priority.ts +54 -58
  276. package/src/cpsat/rules/assignment-terms.ts +43 -0
  277. package/src/cpsat/rules/concurrent-intervals.ts +93 -0
  278. package/src/cpsat/rules/cost-utils.ts +1 -9
  279. package/src/cpsat/rules/day-cost-multiplier.ts +101 -81
  280. package/src/cpsat/rules/day-cost-surcharge.ts +91 -70
  281. package/src/cpsat/rules/index.ts +30 -24
  282. package/src/cpsat/rules/location-preference.ts +47 -52
  283. package/src/cpsat/rules/max-concurrent-assignments.ts +170 -0
  284. package/src/cpsat/rules/max-consecutive-days.ts +74 -91
  285. package/src/cpsat/rules/max-days-of-week-per-period.ts +127 -0
  286. package/src/cpsat/rules/max-days-week.ts +89 -115
  287. package/src/cpsat/rules/max-hours-day.ts +64 -84
  288. package/src/cpsat/rules/max-hours-week.ts +74 -90
  289. package/src/cpsat/rules/max-shifts-day.ts +65 -85
  290. package/src/cpsat/rules/min-consecutive-days.ts +161 -127
  291. package/src/cpsat/rules/min-days-of-week-per-period.ts +144 -0
  292. package/src/cpsat/rules/min-days-week.ts +93 -91
  293. package/src/cpsat/rules/min-hours-day.ts +79 -58
  294. package/src/cpsat/rules/min-hours-week.ts +87 -62
  295. package/src/cpsat/rules/min-rest-between-shifts.ts +191 -178
  296. package/src/cpsat/rules/minimize-cost.ts +175 -188
  297. package/src/cpsat/rules/must-assign.ts +44 -81
  298. package/src/cpsat/rules/overtime-daily-multiplier.ts +158 -140
  299. package/src/cpsat/rules/overtime-daily-surcharge.ts +141 -126
  300. package/src/cpsat/rules/overtime-tiered-multiplier.ts +234 -253
  301. package/src/cpsat/rules/overtime-weekly-multiplier.ts +183 -175
  302. package/src/cpsat/rules/overtime-weekly-surcharge.ts +161 -150
  303. package/src/cpsat/rules/pattern-eligibility.ts +26 -0
  304. package/src/cpsat/rules/pattern-time.ts +30 -0
  305. package/src/cpsat/rules/registry.ts +140 -62
  306. package/src/cpsat/rules/resolver.ts +58 -30
  307. package/src/cpsat/rules/role-preference.ts +92 -0
  308. package/src/cpsat/rules/rules.types.ts +89 -17
  309. package/src/cpsat/rules/scope.types.ts +31 -26
  310. package/src/cpsat/rules/target-days-week.ts +105 -0
  311. package/src/cpsat/rules/target-peak-concurrent-assignments.ts +212 -0
  312. package/src/cpsat/rules/time-cost-surcharge.ts +116 -122
  313. package/src/cpsat/rules/time-off.ts +154 -163
  314. package/src/cpsat/rules/variables.ts +11 -0
  315. package/src/cpsat/rules.ts +43 -17
  316. package/src/cpsat/semantic-time.ts +29 -43
  317. package/src/cpsat/types.ts +53 -4
  318. package/src/cpsat/utils.ts +92 -20
  319. package/src/cpsat/validation-reporter.ts +170 -19
  320. package/src/cpsat/validation.types.ts +22 -11
  321. package/src/datetime.utils.ts +45 -44
  322. package/src/index.ts +64 -12
  323. package/src/schedule/cost.ts +40 -61
  324. package/src/schedule/coverage.ts +57 -28
  325. package/src/schedule/definition.ts +565 -76
  326. package/src/schedule/index.ts +23 -3
  327. package/src/schedule/rules.ts +631 -132
  328. package/src/schedule/shift-patterns.ts +0 -17
  329. package/src/schedule/time-periods.ts +37 -27
  330. package/src/types.ts +114 -2
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass
6
+ from time import perf_counter
6
7
  from typing import Iterable
7
8
 
8
9
  from ortools.sat.python import cp_model
@@ -11,13 +12,27 @@ from .models import (
11
12
  Constraint,
12
13
  Objective,
13
14
  Options,
15
+ SolverObjectiveStage,
14
16
  SolverRequest,
15
17
  SolverResponse,
16
- SoftConstraintViolation,
18
+ SolverHardConstraintConflict,
19
+ SolverSoftConstraintViolation,
20
+ SolverStageResult,
21
+ SolverStatus,
17
22
  Term,
18
23
  Variable,
19
24
  )
20
25
 
26
+ _SUCCESSFUL_STATUSES = (cp_model.OPTIMAL, cp_model.FEASIBLE)
27
+ _STATUS_MAP: dict[int, str] = {
28
+ int(cp_model.OPTIMAL): "OPTIMAL",
29
+ int(cp_model.FEASIBLE): "FEASIBLE",
30
+ int(cp_model.INFEASIBLE): "INFEASIBLE",
31
+ int(cp_model.MODEL_INVALID): "ERROR",
32
+ int(cp_model.UNKNOWN): "TIMEOUT",
33
+ }
34
+ _ObjectiveLike = Objective | SolverObjectiveStage
35
+
21
36
 
22
37
  @dataclass
23
38
  class _VariableBounds:
@@ -38,6 +53,26 @@ class _TrackedSoftConstraint:
38
53
  terms: list[Term]
39
54
 
40
55
 
56
+ @dataclass
57
+ class _TrackedHardConstraint:
58
+ """Tracks hard constraints guarded by CP-SAT assumptions."""
59
+
60
+ constraint_id: str
61
+ assumption_var: cp_model.IntVar
62
+
63
+
64
+ @dataclass
65
+ class _BuiltModel:
66
+ """CP-SAT model and translation metadata."""
67
+
68
+ model: cp_model.CpModel
69
+ vars_map: dict[str, cp_model.IntVar]
70
+ penalty_terms: list[cp_model.LinearExpr]
71
+ penalty_terms_by_stage: dict[str, list[cp_model.LinearExpr]]
72
+ tracked_soft_constraints: list[_TrackedSoftConstraint]
73
+ tracked_hard_constraints: list[_TrackedHardConstraint]
74
+
75
+
41
76
  def _collect_bounds(variables: Iterable[Variable]) -> dict[str, _VariableBounds]:
42
77
  bounds: dict[str, _VariableBounds] = {}
43
78
  for var in variables:
@@ -83,20 +118,40 @@ def _sum_expr(exprs: list[cp_model.LinearExpr]) -> cp_model.LinearExpr | None:
83
118
  return total
84
119
 
85
120
 
86
- def _add_linear_constraint(model: cp_model.CpModel, constraint: Constraint, vars_map: dict[str, cp_model.IntVar]) -> None:
121
+ def _add_linear_constraint(
122
+ model: cp_model.CpModel,
123
+ constraint: Constraint,
124
+ vars_map: dict[str, cp_model.IntVar],
125
+ tracked_hard_constraints: list[_TrackedHardConstraint],
126
+ constraint_index: int,
127
+ diagnose_hard_conflicts: bool,
128
+ ) -> None:
87
129
  if not constraint.terms or constraint.op is None or constraint.rhs is None:
88
130
  raise ValueError("Linear constraint requires terms, op, and rhs")
89
131
  expr = sum(vars_map[t.var] * t.coeff for t in constraint.terms)
132
+ constraint_builder: cp_model.Constraint
90
133
  match constraint.op:
91
134
  case "<=":
92
- model.Add(expr <= constraint.rhs)
135
+ constraint_builder = model.Add(expr <= constraint.rhs)
93
136
  case ">=":
94
- model.Add(expr >= constraint.rhs)
137
+ constraint_builder = model.Add(expr >= constraint.rhs)
95
138
  case "==":
96
- model.Add(expr == constraint.rhs)
139
+ constraint_builder = model.Add(expr == constraint.rhs)
97
140
  case _:
98
141
  raise ValueError(f"Unsupported linear operator {constraint.op}")
99
142
 
143
+ if not diagnose_hard_conflicts:
144
+ return
145
+ if constraint.id is None:
146
+ return
147
+
148
+ assumption = model.NewBoolVar(f"assume_{constraint_index}_{constraint.id}")
149
+ constraint_builder.OnlyEnforceIf(assumption)
150
+ model.AddAssumption(assumption)
151
+ tracked_hard_constraints.append(
152
+ _TrackedHardConstraint(constraint_id=constraint.id, assumption_var=assumption)
153
+ )
154
+
100
155
 
101
156
  def _add_soft_linear_constraint(
102
157
  model: cp_model.CpModel,
@@ -104,13 +159,10 @@ def _add_soft_linear_constraint(
104
159
  vars_map: dict[str, cp_model.IntVar],
105
160
  bounds: dict[str, _VariableBounds],
106
161
  penalty_terms: list[cp_model.LinearExpr],
107
- tracked_constraints: list[_TrackedSoftConstraint],
162
+ tracked_soft_constraints: list[_TrackedSoftConstraint],
108
163
  constraint_index: int,
109
164
  ) -> None:
110
- if not constraint.terms or constraint.op is None or constraint.rhs is None or constraint.penalty is None:
111
- raise ValueError("Soft linear constraint requires terms, op, rhs, and penalty")
112
-
113
- min_expr, max_expr = _expression_range(constraint.terms, bounds)
165
+ min_expr, max_expr = _validate_soft_linear_constraint(constraint, bounds)
114
166
  max_violation = 0
115
167
  violation: cp_model.IntVar | None = None
116
168
  expr = sum(vars_map[t.var] * t.coeff for t in constraint.terms)
@@ -133,7 +185,7 @@ def _add_soft_linear_constraint(
133
185
  if max_violation > 0:
134
186
  penalty_terms.append(violation * constraint.penalty)
135
187
  if constraint.id is not None:
136
- tracked_constraints.append(
188
+ tracked_soft_constraints.append(
137
189
  _TrackedSoftConstraint(
138
190
  constraint_id=constraint_id,
139
191
  violation_var=violation,
@@ -144,6 +196,17 @@ def _add_soft_linear_constraint(
144
196
  )
145
197
 
146
198
 
199
+ def _validate_soft_linear_constraint(
200
+ constraint: Constraint,
201
+ bounds: dict[str, _VariableBounds],
202
+ ) -> tuple[int, int]:
203
+ if not constraint.terms or constraint.op is None or constraint.rhs is None or constraint.penalty is None:
204
+ raise ValueError("Soft linear constraint requires terms, op, rhs, and penalty")
205
+ if constraint.op not in ("<=", ">="):
206
+ raise ValueError(f"Unsupported soft linear operator {constraint.op}")
207
+ return _expression_range(constraint.terms, bounds)
208
+
209
+
147
210
  def _add_exactly_one(model: cp_model.CpModel, constraint: Constraint, vars_map: dict[str, cp_model.IntVar]) -> None:
148
211
  if not constraint.vars:
149
212
  raise ValueError("Exactly one constraint requires vars")
@@ -189,13 +252,122 @@ def _add_no_overlap(
189
252
  model.AddNoOverlap(intervals)
190
253
 
191
254
 
192
- def _build_objective(
193
- model: cp_model.CpModel,
194
- objective: Objective | None,
255
+ def _build_model(
256
+ request: SolverRequest,
257
+ *,
258
+ ignore_soft_constraints: bool = False,
259
+ diagnose_hard_conflicts: bool = False,
260
+ stage_ids: set[str] | None = None,
261
+ ) -> _BuiltModel:
262
+ model = cp_model.CpModel()
263
+
264
+ var_bounds = _collect_bounds(request.variables)
265
+ vars_map: dict[str, cp_model.IntVar] = {}
266
+ intervals_map: dict[str, cp_model.IntervalVar] = {}
267
+
268
+ for var in request.variables:
269
+ if var.type == "bool":
270
+ vars_map[var.name] = model.NewBoolVar(var.name)
271
+ elif var.type == "int":
272
+ if var.min is None or var.max is None:
273
+ raise ValueError(f"Int variable {var.name} requires min and max")
274
+ vars_map[var.name] = model.NewIntVar(var.min, var.max, var.name)
275
+ elif var.type == "interval":
276
+ if var.start is None or var.end is None or var.size is None:
277
+ raise ValueError(f"Interval variable {var.name} requires start, end, and size")
278
+ if var.end - var.start != var.size:
279
+ raise ValueError(
280
+ f"Interval variable {var.name} inconsistent: end-start={var.end - var.start} != size={var.size}"
281
+ )
282
+
283
+ start = model.NewConstant(var.start)
284
+ end = model.NewConstant(var.end)
285
+ size = var.size
286
+
287
+ if var.presenceVar is None:
288
+ intervals_map[var.name] = model.NewIntervalVar(start, size, end, var.name)
289
+ else:
290
+ if var.presenceVar not in vars_map:
291
+ raise ValueError(f"Interval variable {var.name} references unknown presenceVar {var.presenceVar}")
292
+ presence = vars_map[var.presenceVar]
293
+ intervals_map[var.name] = model.NewOptionalIntervalVar(start, size, end, presence, var.name)
294
+ else:
295
+ raise ValueError(f"Unsupported variable type {var.type}")
296
+
297
+ penalty_terms: list[cp_model.LinearExpr] = []
298
+ penalty_terms_by_stage: dict[str, list[cp_model.LinearExpr]] = (
299
+ {stage_id: [] for stage_id in stage_ids} if stage_ids is not None else {}
300
+ )
301
+ tracked_soft_constraints: list[_TrackedSoftConstraint] = []
302
+ tracked_hard_constraints: list[_TrackedHardConstraint] = []
303
+ constraint_index = 0
304
+
305
+ for constraint in request.constraints:
306
+ match constraint.type:
307
+ case "linear":
308
+ _add_linear_constraint(
309
+ model,
310
+ constraint,
311
+ vars_map,
312
+ tracked_hard_constraints,
313
+ constraint_index,
314
+ diagnose_hard_conflicts,
315
+ )
316
+ constraint_index += 1
317
+ case "soft_linear":
318
+ if ignore_soft_constraints:
319
+ _validate_soft_linear_constraint(constraint, var_bounds)
320
+ constraint_index += 1
321
+ continue
322
+ if stage_ids is not None:
323
+ if constraint.stage is None:
324
+ raise ValueError("Soft linear constraint requires stage when objectiveStages are used")
325
+ if constraint.stage not in stage_ids:
326
+ raise ValueError(f"Soft linear constraint stage {constraint.stage} is not declared")
327
+ target_penalty_terms = penalty_terms_by_stage[constraint.stage]
328
+ else:
329
+ target_penalty_terms = penalty_terms
330
+ _add_soft_linear_constraint(
331
+ model,
332
+ constraint,
333
+ vars_map,
334
+ var_bounds,
335
+ target_penalty_terms,
336
+ tracked_soft_constraints,
337
+ constraint_index,
338
+ )
339
+ constraint_index += 1
340
+ case "exactly_one":
341
+ _add_exactly_one(model, constraint, vars_map)
342
+ case "at_most_one":
343
+ _add_at_most_one(model, constraint, vars_map)
344
+ case "implication":
345
+ _add_implication(model, constraint, vars_map)
346
+ case "bool_or":
347
+ _add_bool_or(model, constraint, vars_map)
348
+ case "bool_and":
349
+ _add_bool_and(model, constraint, vars_map)
350
+ case "no_overlap":
351
+ _add_no_overlap(model, constraint, intervals_map)
352
+ case _:
353
+ raise ValueError(f"Unsupported constraint type {constraint.type}")
354
+
355
+ return _BuiltModel(
356
+ model=model,
357
+ vars_map=vars_map,
358
+ penalty_terms=penalty_terms,
359
+ penalty_terms_by_stage=penalty_terms_by_stage,
360
+ tracked_soft_constraints=tracked_soft_constraints,
361
+ tracked_hard_constraints=tracked_hard_constraints,
362
+ )
363
+
364
+
365
+ def _compose_objective_expression(
366
+ objective: _ObjectiveLike | None,
195
367
  vars_map: dict[str, cp_model.IntVar],
196
368
  penalty_terms: list[cp_model.LinearExpr],
197
- ) -> bool:
198
- penalty_expr: cp_model.LinearExpr | None = _sum_expr(penalty_terms)
369
+ ) -> cp_model.LinearExpr | None:
370
+ penalty_expr = _sum_expr(penalty_terms)
199
371
 
200
372
  if objective:
201
373
  objective_terms: list[cp_model.LinearExpr] = (
@@ -203,160 +375,377 @@ def _build_objective(
203
375
  )
204
376
  obj_expr: cp_model.LinearExpr | None = _sum_expr(objective_terms)
205
377
  if penalty_expr is not None and obj_expr is not None:
206
- expr = obj_expr + penalty_expr if objective.sense == "minimize" else obj_expr - penalty_expr
207
- elif obj_expr is not None:
208
- expr = obj_expr
209
- elif penalty_expr is not None:
210
- expr = penalty_expr if objective.sense == "minimize" else -penalty_expr
211
- else:
212
- return False
378
+ return obj_expr + penalty_expr if objective.sense == "minimize" else obj_expr - penalty_expr
379
+ if obj_expr is not None:
380
+ return obj_expr
381
+ if penalty_expr is not None:
382
+ return penalty_expr if objective.sense == "minimize" else -penalty_expr
383
+ return None
213
384
 
214
- if objective.sense == "maximize":
215
- model.Maximize(expr)
216
- else:
217
- model.Minimize(expr)
218
- return True
385
+ return penalty_expr
219
386
 
220
- if penalty_expr is not None:
221
- model.Minimize(penalty_expr)
222
- return True
223
387
 
224
- return False
388
+ def _build_objective(
389
+ model: cp_model.CpModel,
390
+ objective: _ObjectiveLike | None,
391
+ vars_map: dict[str, cp_model.IntVar],
392
+ penalty_terms: list[cp_model.LinearExpr],
393
+ ) -> tuple[bool, cp_model.LinearExpr | None]:
394
+ expr = _compose_objective_expression(objective, vars_map, penalty_terms)
395
+ if expr is None:
396
+ return False, None
225
397
 
398
+ if objective is not None and objective.sense == "maximize":
399
+ model.Maximize(expr)
400
+ else:
401
+ model.Minimize(expr)
402
+ return True, expr
226
403
 
227
- def solve_request(request: SolverRequest) -> SolverResponse:
228
- """Solve a scheduling request."""
229
- try:
230
- model = cp_model.CpModel()
231
-
232
- var_bounds = _collect_bounds(request.variables)
233
- vars_map: dict[str, cp_model.IntVar] = {}
234
- intervals_map: dict[str, cp_model.IntervalVar] = {}
235
-
236
- # Variable creation
237
- for var in request.variables:
238
- if var.type == "bool":
239
- vars_map[var.name] = model.NewBoolVar(var.name)
240
- elif var.type == "int":
241
- if var.min is None or var.max is None:
242
- raise ValueError(f"Int variable {var.name} requires min and max")
243
- vars_map[var.name] = model.NewIntVar(var.min, var.max, var.name)
244
- elif var.type == "interval":
245
- if var.start is None or var.end is None or var.size is None:
246
- raise ValueError(f"Interval variable {var.name} requires start, end, and size")
247
- if var.end - var.start != var.size:
248
- raise ValueError(
249
- f"Interval variable {var.name} inconsistent: end-start={var.end - var.start} != size={var.size}"
250
- )
251
-
252
- start = model.NewConstant(var.start)
253
- end = model.NewConstant(var.end)
254
- size = var.size
255
-
256
- if var.presenceVar is None:
257
- intervals_map[var.name] = model.NewIntervalVar(start, size, end, var.name)
258
- else:
259
- if var.presenceVar not in vars_map:
260
- raise ValueError(
261
- f"Interval variable {var.name} references unknown presenceVar {var.presenceVar}"
262
- )
263
- presence = vars_map[var.presenceVar]
264
- intervals_map[var.name] = model.NewOptionalIntervalVar(start, size, end, presence, var.name)
265
- else:
266
- raise ValueError(f"Unsupported variable type {var.type}")
267
-
268
- penalty_terms: list[cp_model.LinearExpr] = []
269
- tracked_constraints: list[_TrackedSoftConstraint] = []
270
- constraint_index = 0
271
-
272
- for constraint in request.constraints:
273
- match constraint.type:
274
- case "linear":
275
- _add_linear_constraint(model, constraint, vars_map)
276
- case "soft_linear":
277
- _add_soft_linear_constraint(
278
- model,
279
- constraint,
280
- vars_map,
281
- var_bounds,
282
- penalty_terms,
283
- tracked_constraints,
284
- constraint_index,
285
- )
286
- constraint_index += 1
287
- case "exactly_one":
288
- _add_exactly_one(model, constraint, vars_map)
289
- case "at_most_one":
290
- _add_at_most_one(model, constraint, vars_map)
291
- case "implication":
292
- _add_implication(model, constraint, vars_map)
293
- case "bool_or":
294
- _add_bool_or(model, constraint, vars_map)
295
- case "bool_and":
296
- _add_bool_and(model, constraint, vars_map)
297
- case "no_overlap":
298
- _add_no_overlap(model, constraint, intervals_map)
299
- case _:
300
- raise ValueError(f"Unsupported constraint type {constraint.type}")
301
-
302
- has_objective = _build_objective(model, request.objective, vars_map, penalty_terms)
303
404
 
304
- solver = cp_model.CpSolver()
305
- options: Options = request.options or Options()
306
- solver.parameters.max_time_in_seconds = options.timeLimitSeconds
307
- if options.solutionLimit == 1:
308
- solver.parameters.stop_after_first_solution = True
309
-
310
- status = solver.Solve(model)
311
-
312
- status_map: dict[int, str] = {
313
- cp_model.OPTIMAL: "OPTIMAL",
314
- cp_model.FEASIBLE: "FEASIBLE",
315
- cp_model.INFEASIBLE: "INFEASIBLE",
316
- cp_model.MODEL_INVALID: "ERROR",
317
- cp_model.UNKNOWN: "TIMEOUT",
318
- }
319
- status_text = status_map.get(int(status), "ERROR")
320
-
321
- if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
322
- solution_info = solver.response_proto.solution_info
323
- return SolverResponse(
324
- status=status_text,
325
- **({"solutionInfo": solution_info} if solution_info else {}),
326
- )
405
+ def _status_text(status: int) -> str:
406
+ return _STATUS_MAP.get(int(status), "ERROR")
327
407
 
328
- statistics: dict[str, int | float] = {
329
- "solveTimeMs": int(solver.wall_time * 1000),
330
- "conflicts": solver.num_conflicts,
331
- "branches": solver.num_branches,
332
- }
333
408
 
334
- if has_objective:
335
- statistics["objectiveValue"] = solver.objective_value
336
- statistics["bestObjectiveBound"] = solver.best_objective_bound
409
+ def _new_solver(options: Options, time_limit_seconds: float) -> cp_model.CpSolver:
410
+ solver = cp_model.CpSolver()
411
+ solver.parameters.max_time_in_seconds = time_limit_seconds
412
+ if options.solutionLimit == 1:
413
+ solver.parameters.stop_after_first_solution = True
414
+ return solver
337
415
 
338
- soft_violations: list[SoftConstraintViolation] = []
339
- for tracked in tracked_constraints:
340
- violation_amount = int(solver.value(tracked.violation_var))
341
- if violation_amount <= 0:
342
- continue
343
- actual_value = sum(int(solver.value(vars_map[t.var])) * t.coeff for t in tracked.terms)
344
- soft_violations.append(
345
- SoftConstraintViolation(
346
- constraintId=tracked.constraint_id,
347
- violationAmount=violation_amount,
348
- targetValue=tracked.target_value,
349
- actualValue=actual_value,
350
- )
416
+
417
+ def _solver_statistics(solver: cp_model.CpSolver, has_objective: bool) -> dict[str, int | float]:
418
+ statistics: dict[str, int | float] = {
419
+ "solveTimeMs": int(solver.wall_time * 1000),
420
+ "conflicts": solver.num_conflicts,
421
+ "branches": solver.num_branches,
422
+ }
423
+
424
+ if has_objective:
425
+ statistics["objectiveValue"] = solver.objective_value
426
+ statistics["bestObjectiveBound"] = solver.best_objective_bound
427
+
428
+ return statistics
429
+
430
+
431
+ def _solution_values(vars_map: dict[str, cp_model.IntVar], solver: cp_model.CpSolver) -> dict[str, int]:
432
+ return {name: int(solver.value(var)) for name, var in vars_map.items()}
433
+
434
+
435
+ def _collect_soft_constraint_violations(
436
+ tracked_soft_constraints: list[_TrackedSoftConstraint],
437
+ vars_map: dict[str, cp_model.IntVar],
438
+ solver: cp_model.CpSolver,
439
+ ) -> list[SolverSoftConstraintViolation]:
440
+ soft_constraint_violations: list[SolverSoftConstraintViolation] = []
441
+ for tracked in tracked_soft_constraints:
442
+ violation_amount = int(solver.value(tracked.violation_var))
443
+ if violation_amount <= 0:
444
+ continue
445
+ actual_value = sum(int(solver.value(vars_map[t.var])) * t.coeff for t in tracked.terms)
446
+ soft_constraint_violations.append(
447
+ SolverSoftConstraintViolation(
448
+ constraintId=tracked.constraint_id,
449
+ violationAmount=violation_amount,
450
+ targetValue=tracked.target_value,
451
+ actualValue=actual_value,
351
452
  )
453
+ )
454
+ return soft_constraint_violations
455
+
456
+
457
+ def _collect_hard_constraint_conflicts(
458
+ tracked_hard_constraints: list[_TrackedHardConstraint],
459
+ solver: cp_model.CpSolver,
460
+ ) -> list[SolverHardConstraintConflict]:
461
+ assumption_index_to_id = {
462
+ tracked.assumption_var.Index(): tracked.constraint_id for tracked in tracked_hard_constraints
463
+ }
464
+ return [
465
+ SolverHardConstraintConflict(constraintId=constraint_id)
466
+ for assumption_index in solver.SufficientAssumptionsForInfeasibility()
467
+ if (constraint_id := assumption_index_to_id.get(assumption_index)) is not None
468
+ ]
469
+
470
+
471
+ def _hard_constraint_conflicts_for_status(
472
+ status: int,
473
+ built: _BuiltModel,
474
+ solver: cp_model.CpSolver,
475
+ ) -> list[SolverHardConstraintConflict]:
476
+ if status != cp_model.INFEASIBLE:
477
+ return []
478
+ return _collect_hard_constraint_conflicts(built.tracked_hard_constraints, solver)
479
+
480
+
481
+ def _solution_info_kwargs(solver: cp_model.CpSolver) -> dict[str, str]:
482
+ solution_info = solver.response_proto.solution_info
483
+ return {"solutionInfo": solution_info} if solution_info else {}
484
+
485
+
486
+ def _values_kwargs(values: dict[str, int] | None) -> dict[str, dict[str, int]]:
487
+ return {"values": values} if values is not None else {}
488
+
352
489
 
353
- solution_info = solver.response_proto.solution_info
490
+ def _hard_constraint_conflicts_kwargs(
491
+ conflicts: list[SolverHardConstraintConflict],
492
+ ) -> dict[str, list[SolverHardConstraintConflict]]:
493
+ return {"hardConstraintConflicts": conflicts} if conflicts else {}
494
+
495
+
496
+ def _response_from_solver(
497
+ status: int,
498
+ solver: cp_model.CpSolver,
499
+ built: _BuiltModel,
500
+ has_objective: bool,
501
+ hard_constraint_conflicts: list[SolverHardConstraintConflict] | None = None,
502
+ ) -> SolverResponse:
503
+ status_text = _status_text(status)
504
+
505
+ if status not in _SUCCESSFUL_STATUSES:
354
506
  return SolverResponse(
355
507
  status=status_text,
356
- values={name: int(solver.value(var)) for name, var in vars_map.items()},
357
- statistics=statistics,
358
- **({"solutionInfo": solution_info} if solution_info else {}),
359
- softViolations=soft_violations,
508
+ **_solution_info_kwargs(solver),
509
+ **_hard_constraint_conflicts_kwargs(hard_constraint_conflicts or []),
510
+ )
511
+
512
+ return SolverResponse(
513
+ status=status_text,
514
+ values=_solution_values(built.vars_map, solver),
515
+ statistics=_solver_statistics(solver, has_objective),
516
+ **_solution_info_kwargs(solver),
517
+ softConstraintViolations=_collect_soft_constraint_violations(
518
+ built.tracked_soft_constraints,
519
+ built.vars_map,
520
+ solver,
521
+ ),
522
+ )
523
+
524
+
525
+ def _validate_objective_stages(request: SolverRequest) -> list[SolverObjectiveStage]:
526
+ stages = request.objectiveStages
527
+ if stages is None:
528
+ raise ValueError("objectiveStages are required for staged solving")
529
+ if not stages:
530
+ raise ValueError("objectiveStages cannot be empty")
531
+
532
+ seen_stage_ids: set[str] = set()
533
+ for stage in stages:
534
+ if stage.id in seen_stage_ids:
535
+ raise ValueError(f"Duplicate objective stage id {stage.id}")
536
+ seen_stage_ids.add(stage.id)
537
+
538
+ options = request.options or Options()
539
+ if options.solutionLimit == 1:
540
+ raise ValueError("objectiveStages cannot be used with solutionLimit=1")
541
+
542
+ return stages
543
+
544
+
545
+ def _stage_result_from_solver(
546
+ stage: SolverObjectiveStage,
547
+ status: int,
548
+ solver: cp_model.CpSolver,
549
+ has_objective: bool,
550
+ ) -> SolverStageResult:
551
+ result_fields: dict[str, str | int | float] = {
552
+ "id": stage.id,
553
+ "status": _status_text(status),
554
+ "solveTimeMs": int(solver.wall_time * 1000),
555
+ }
556
+ if has_objective and status in _SUCCESSFUL_STATUSES:
557
+ result_fields["objectiveValue"] = solver.objective_value
558
+ result_fields["bestObjectiveBound"] = solver.best_objective_bound
559
+ return SolverStageResult(**result_fields)
560
+
561
+
562
+ def _aggregate_stage_status(stage_results: list[SolverStageResult]) -> SolverStatus:
563
+ if not stage_results:
564
+ raise ValueError("Cannot aggregate empty stage results")
565
+
566
+ aggregate_status: SolverStatus = "OPTIMAL"
567
+ for stage_result in stage_results:
568
+ match stage_result.status:
569
+ case "OPTIMAL":
570
+ continue
571
+ case "FEASIBLE":
572
+ aggregate_status = "FEASIBLE"
573
+ case "INFEASIBLE" | "TIMEOUT" | "ERROR":
574
+ return stage_result.status
575
+
576
+ return aggregate_status
577
+
578
+
579
+ def _staged_statistics(
580
+ stage_results: list[SolverStageResult],
581
+ conflicts: int,
582
+ branches: int,
583
+ ) -> dict[str, int | float]:
584
+ statistics: dict[str, int | float] = {
585
+ "solveTimeMs": sum(stage.solveTimeMs for stage in stage_results),
586
+ "conflicts": conflicts,
587
+ "branches": branches,
588
+ }
589
+
590
+ last_successful_stage = next(
591
+ (stage for stage in reversed(stage_results) if stage.status in ("OPTIMAL", "FEASIBLE")),
592
+ None,
593
+ )
594
+ if last_successful_stage is not None and last_successful_stage.objectiveValue is not None:
595
+ statistics["objectiveValue"] = last_successful_stage.objectiveValue
596
+ if last_successful_stage is not None and last_successful_stage.bestObjectiveBound is not None:
597
+ statistics["bestObjectiveBound"] = last_successful_stage.bestObjectiveBound
598
+
599
+ return statistics
600
+
601
+
602
+ def _solve_single_objective(request: SolverRequest) -> SolverResponse:
603
+ options: Options = request.options or Options()
604
+ built = _build_model(request, diagnose_hard_conflicts=options.diagnostics == "hard")
605
+ has_objective, _ = _build_objective(
606
+ built.model,
607
+ request.objective,
608
+ built.vars_map,
609
+ built.penalty_terms,
610
+ )
611
+
612
+ solver = _new_solver(options, options.timeLimitSeconds)
613
+ status = solver.Solve(built.model)
614
+ hard_constraint_conflicts = _hard_constraint_conflicts_for_status(
615
+ status,
616
+ built,
617
+ solver,
618
+ )
619
+
620
+ return _response_from_solver(status, solver, built, has_objective, hard_constraint_conflicts)
621
+
622
+
623
+ def _solve_satisfy(request: SolverRequest) -> SolverResponse:
624
+ options: Options = request.options or Options()
625
+ built = _build_model(
626
+ request,
627
+ ignore_soft_constraints=True,
628
+ diagnose_hard_conflicts=options.diagnostics == "hard",
629
+ )
630
+ solver = _new_solver(options, options.timeLimitSeconds)
631
+ status = solver.Solve(built.model)
632
+ hard_constraint_conflicts = _hard_constraint_conflicts_for_status(
633
+ status,
634
+ built,
635
+ solver,
636
+ )
637
+
638
+ return _response_from_solver(
639
+ status,
640
+ solver,
641
+ built,
642
+ has_objective=False,
643
+ hard_constraint_conflicts=hard_constraint_conflicts,
644
+ )
645
+
646
+
647
+ def _solve_staged(request: SolverRequest) -> SolverResponse:
648
+ stages = _validate_objective_stages(request)
649
+ stage_ids = {stage.id for stage in stages}
650
+ options: Options = request.options or Options()
651
+ built = _build_model(
652
+ request,
653
+ stage_ids=stage_ids,
654
+ diagnose_hard_conflicts=options.diagnostics == "hard",
655
+ )
656
+ started_at = perf_counter()
657
+
658
+ stage_results: list[SolverStageResult] = []
659
+ last_solver: cp_model.CpSolver | None = None
660
+ last_values: dict[str, int] | None = None
661
+ total_conflicts = 0
662
+ total_branches = 0
663
+
664
+ for stage in stages:
665
+ remaining_time = options.timeLimitSeconds - (perf_counter() - started_at)
666
+ if remaining_time <= 0:
667
+ stage_results.append(SolverStageResult(id=stage.id, status="TIMEOUT", solveTimeMs=0))
668
+ return SolverResponse(
669
+ status=_aggregate_stage_status(stage_results),
670
+ **_values_kwargs(last_values),
671
+ statistics=_staged_statistics(stage_results, total_conflicts, total_branches),
672
+ stageResults=stage_results,
673
+ )
674
+
675
+ built.model.ClearObjective()
676
+ has_objective, objective_expr = _build_objective(
677
+ built.model,
678
+ stage,
679
+ built.vars_map,
680
+ built.penalty_terms_by_stage[stage.id],
681
+ )
682
+ solver = cp_model.CpSolver()
683
+ solver.parameters.max_time_in_seconds = remaining_time
684
+ status = solver.Solve(built.model)
685
+ total_conflicts += solver.num_conflicts
686
+ total_branches += solver.num_branches
687
+
688
+ stage_result = _stage_result_from_solver(stage, status, solver, has_objective)
689
+ stage_results.append(stage_result)
690
+
691
+ if status not in _SUCCESSFUL_STATUSES:
692
+ hard_constraint_conflicts = _hard_constraint_conflicts_for_status(
693
+ status,
694
+ built,
695
+ solver,
696
+ )
697
+ return SolverResponse(
698
+ status=_aggregate_stage_status(stage_results),
699
+ **_values_kwargs(last_values),
700
+ statistics=_staged_statistics(stage_results, total_conflicts, total_branches),
701
+ **_solution_info_kwargs(solver),
702
+ **_hard_constraint_conflicts_kwargs(hard_constraint_conflicts),
703
+ stageResults=stage_results,
704
+ )
705
+
706
+ last_solver = solver
707
+ last_values = _solution_values(built.vars_map, solver)
708
+
709
+ if has_objective and objective_expr is not None:
710
+ achieved_value = int(solver.value(objective_expr))
711
+ if stage.sense == "maximize":
712
+ built.model.Add(objective_expr >= achieved_value)
713
+ else:
714
+ built.model.Add(objective_expr <= achieved_value)
715
+
716
+ return SolverResponse(
717
+ status=_aggregate_stage_status(stage_results),
718
+ **_values_kwargs(last_values),
719
+ statistics=_staged_statistics(stage_results, total_conflicts, total_branches),
720
+ stageResults=stage_results,
721
+ softConstraintViolations=_collect_soft_constraint_violations(
722
+ built.tracked_soft_constraints,
723
+ built.vars_map,
724
+ last_solver,
360
725
  )
726
+ if last_solver is not None
727
+ else [],
728
+ )
729
+
730
+
731
+ def solve_request(request: SolverRequest) -> SolverResponse:
732
+ """Solve a scheduling request."""
733
+ try:
734
+ mode = request.mode or "optimize"
735
+
736
+ if mode == "satisfy":
737
+ if request.objective is not None:
738
+ raise ValueError('mode "satisfy" cannot be used with objective')
739
+ if request.objectiveStages is not None:
740
+ raise ValueError('mode "satisfy" cannot be used with objectiveStages')
741
+ return _solve_satisfy(request)
742
+
743
+ if request.objective is not None and request.objectiveStages is not None:
744
+ raise ValueError("objective and objectiveStages cannot both be present")
745
+
746
+ if request.objectiveStages is not None:
747
+ return _solve_staged(request)
748
+
749
+ return _solve_single_objective(request)
361
750
  except Exception as exc: # pragma: no cover - surfaced in response
362
751
  return SolverResponse(status="ERROR", error=str(exc))