dabke 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/LICENSE +21 -0
  3. package/README.md +187 -0
  4. package/dist/client.d.ts +14 -0
  5. package/dist/client.d.ts.map +1 -0
  6. package/dist/client.js +42 -0
  7. package/dist/client.js.map +1 -0
  8. package/dist/client.schemas.d.ts +250 -0
  9. package/dist/client.schemas.d.ts.map +1 -0
  10. package/dist/client.schemas.js +137 -0
  11. package/dist/client.schemas.js.map +1 -0
  12. package/dist/client.types.d.ts +34 -0
  13. package/dist/client.types.d.ts.map +1 -0
  14. package/dist/client.types.js +18 -0
  15. package/dist/client.types.js.map +1 -0
  16. package/dist/cpsat/model-builder.d.ts +128 -0
  17. package/dist/cpsat/model-builder.d.ts.map +1 -0
  18. package/dist/cpsat/model-builder.js +640 -0
  19. package/dist/cpsat/model-builder.js.map +1 -0
  20. package/dist/cpsat/response.d.ts +74 -0
  21. package/dist/cpsat/response.d.ts.map +1 -0
  22. package/dist/cpsat/response.js +92 -0
  23. package/dist/cpsat/response.js.map +1 -0
  24. package/dist/cpsat/rules/assign-together.d.ts +23 -0
  25. package/dist/cpsat/rules/assign-together.d.ts.map +1 -0
  26. package/dist/cpsat/rules/assign-together.js +78 -0
  27. package/dist/cpsat/rules/assign-together.js.map +1 -0
  28. package/dist/cpsat/rules/employee-assignment-priority.d.ts +64 -0
  29. package/dist/cpsat/rules/employee-assignment-priority.d.ts.map +1 -0
  30. package/dist/cpsat/rules/employee-assignment-priority.js +151 -0
  31. package/dist/cpsat/rules/employee-assignment-priority.js.map +1 -0
  32. package/dist/cpsat/rules/index.d.ts +13 -0
  33. package/dist/cpsat/rules/index.d.ts.map +1 -0
  34. package/dist/cpsat/rules/index.js +13 -0
  35. package/dist/cpsat/rules/index.js.map +1 -0
  36. package/dist/cpsat/rules/location-preference.d.ts +29 -0
  37. package/dist/cpsat/rules/location-preference.d.ts.map +1 -0
  38. package/dist/cpsat/rules/location-preference.js +59 -0
  39. package/dist/cpsat/rules/location-preference.js.map +1 -0
  40. package/dist/cpsat/rules/max-consecutive-days.d.ts +28 -0
  41. package/dist/cpsat/rules/max-consecutive-days.d.ts.map +1 -0
  42. package/dist/cpsat/rules/max-consecutive-days.js +70 -0
  43. package/dist/cpsat/rules/max-consecutive-days.js.map +1 -0
  44. package/dist/cpsat/rules/max-hours-day.d.ts +57 -0
  45. package/dist/cpsat/rules/max-hours-day.d.ts.map +1 -0
  46. package/dist/cpsat/rules/max-hours-day.js +159 -0
  47. package/dist/cpsat/rules/max-hours-day.js.map +1 -0
  48. package/dist/cpsat/rules/max-hours-week.d.ts +62 -0
  49. package/dist/cpsat/rules/max-hours-week.d.ts.map +1 -0
  50. package/dist/cpsat/rules/max-hours-week.js +169 -0
  51. package/dist/cpsat/rules/max-hours-week.js.map +1 -0
  52. package/dist/cpsat/rules/max-shifts-day.d.ts +69 -0
  53. package/dist/cpsat/rules/max-shifts-day.d.ts.map +1 -0
  54. package/dist/cpsat/rules/max-shifts-day.js +170 -0
  55. package/dist/cpsat/rules/max-shifts-day.js.map +1 -0
  56. package/dist/cpsat/rules/min-consecutive-days.d.ts +29 -0
  57. package/dist/cpsat/rules/min-consecutive-days.d.ts.map +1 -0
  58. package/dist/cpsat/rules/min-consecutive-days.js +104 -0
  59. package/dist/cpsat/rules/min-consecutive-days.js.map +1 -0
  60. package/dist/cpsat/rules/min-hours-day.d.ts +28 -0
  61. package/dist/cpsat/rules/min-hours-day.d.ts.map +1 -0
  62. package/dist/cpsat/rules/min-hours-day.js +61 -0
  63. package/dist/cpsat/rules/min-hours-day.js.map +1 -0
  64. package/dist/cpsat/rules/min-hours-week.d.ts +29 -0
  65. package/dist/cpsat/rules/min-hours-week.d.ts.map +1 -0
  66. package/dist/cpsat/rules/min-hours-week.js +68 -0
  67. package/dist/cpsat/rules/min-hours-week.js.map +1 -0
  68. package/dist/cpsat/rules/min-rest-between-shifts.d.ts +28 -0
  69. package/dist/cpsat/rules/min-rest-between-shifts.d.ts.map +1 -0
  70. package/dist/cpsat/rules/min-rest-between-shifts.js +95 -0
  71. package/dist/cpsat/rules/min-rest-between-shifts.js.map +1 -0
  72. package/dist/cpsat/rules/registry.d.ts +7 -0
  73. package/dist/cpsat/rules/registry.d.ts.map +1 -0
  74. package/dist/cpsat/rules/registry.js +28 -0
  75. package/dist/cpsat/rules/registry.js.map +1 -0
  76. package/dist/cpsat/rules/resolver.d.ts +31 -0
  77. package/dist/cpsat/rules/resolver.d.ts.map +1 -0
  78. package/dist/cpsat/rules/resolver.js +124 -0
  79. package/dist/cpsat/rules/resolver.js.map +1 -0
  80. package/dist/cpsat/rules/rules.types.d.ts +32 -0
  81. package/dist/cpsat/rules/rules.types.d.ts.map +1 -0
  82. package/dist/cpsat/rules/rules.types.js +2 -0
  83. package/dist/cpsat/rules/rules.types.js.map +1 -0
  84. package/dist/cpsat/rules/scoping.d.ts +129 -0
  85. package/dist/cpsat/rules/scoping.d.ts.map +1 -0
  86. package/dist/cpsat/rules/scoping.js +190 -0
  87. package/dist/cpsat/rules/scoping.js.map +1 -0
  88. package/dist/cpsat/rules/time-off.d.ts +78 -0
  89. package/dist/cpsat/rules/time-off.d.ts.map +1 -0
  90. package/dist/cpsat/rules/time-off.js +261 -0
  91. package/dist/cpsat/rules/time-off.js.map +1 -0
  92. package/dist/cpsat/rules.d.ts +5 -0
  93. package/dist/cpsat/rules.d.ts.map +1 -0
  94. package/dist/cpsat/rules.js +4 -0
  95. package/dist/cpsat/rules.js.map +1 -0
  96. package/dist/cpsat/semantic-time.d.ts +198 -0
  97. package/dist/cpsat/semantic-time.d.ts.map +1 -0
  98. package/dist/cpsat/semantic-time.js +222 -0
  99. package/dist/cpsat/semantic-time.js.map +1 -0
  100. package/dist/cpsat/types.d.ts +180 -0
  101. package/dist/cpsat/types.d.ts.map +1 -0
  102. package/dist/cpsat/types.js +2 -0
  103. package/dist/cpsat/types.js.map +1 -0
  104. package/dist/cpsat/utils.d.ts +47 -0
  105. package/dist/cpsat/utils.d.ts.map +1 -0
  106. package/dist/cpsat/utils.js +92 -0
  107. package/dist/cpsat/utils.js.map +1 -0
  108. package/dist/cpsat/validation-reporter.d.ts +54 -0
  109. package/dist/cpsat/validation-reporter.d.ts.map +1 -0
  110. package/dist/cpsat/validation-reporter.js +261 -0
  111. package/dist/cpsat/validation-reporter.js.map +1 -0
  112. package/dist/cpsat/validation.types.d.ts +141 -0
  113. package/dist/cpsat/validation.types.d.ts.map +1 -0
  114. package/dist/cpsat/validation.types.js +14 -0
  115. package/dist/cpsat/validation.types.js.map +1 -0
  116. package/dist/datetime.utils.d.ts +245 -0
  117. package/dist/datetime.utils.d.ts.map +1 -0
  118. package/dist/datetime.utils.js +372 -0
  119. package/dist/datetime.utils.js.map +1 -0
  120. package/dist/errors.d.ts +12 -0
  121. package/dist/errors.d.ts.map +1 -0
  122. package/dist/errors.js +17 -0
  123. package/dist/errors.js.map +1 -0
  124. package/dist/index.d.ts +112 -0
  125. package/dist/index.d.ts.map +1 -0
  126. package/dist/index.js +116 -0
  127. package/dist/index.js.map +1 -0
  128. package/dist/llms.d.ts +5 -0
  129. package/dist/llms.d.ts.map +1 -0
  130. package/dist/llms.js +8 -0
  131. package/dist/llms.js.map +1 -0
  132. package/dist/testing/index.d.ts +12 -0
  133. package/dist/testing/index.d.ts.map +1 -0
  134. package/dist/testing/index.js +11 -0
  135. package/dist/testing/index.js.map +1 -0
  136. package/dist/testing/solver-container.d.ts +49 -0
  137. package/dist/testing/solver-container.d.ts.map +1 -0
  138. package/dist/testing/solver-container.js +127 -0
  139. package/dist/testing/solver-container.js.map +1 -0
  140. package/dist/types.d.ts +155 -0
  141. package/dist/types.d.ts.map +1 -0
  142. package/dist/types.js +20 -0
  143. package/dist/types.js.map +1 -0
  144. package/dist/validation.d.ts +105 -0
  145. package/dist/validation.d.ts.map +1 -0
  146. package/dist/validation.js +130 -0
  147. package/dist/validation.js.map +1 -0
  148. package/llms.txt +2188 -0
  149. package/package.json +76 -0
  150. package/solver/Dockerfile +31 -0
  151. package/solver/README.md +23 -0
  152. package/solver/pyproject.toml +28 -0
  153. package/solver/src/solver/__init__.py +1 -0
  154. package/solver/src/solver/app.py +24 -0
  155. package/solver/src/solver/models.py +120 -0
  156. package/solver/src/solver/solver.py +359 -0
  157. package/solver/tests/test_solver.py +156 -0
  158. package/solver/uv.lock +661 -0
  159. package/src/client.schemas.ts +163 -0
  160. package/src/client.ts +67 -0
  161. package/src/client.types.ts +66 -0
  162. package/src/cpsat/model-builder.ts +858 -0
  163. package/src/cpsat/response.ts +130 -0
  164. package/src/cpsat/rules/assign-together.ts +96 -0
  165. package/src/cpsat/rules/employee-assignment-priority.ts +182 -0
  166. package/src/cpsat/rules/index.ts +12 -0
  167. package/src/cpsat/rules/location-preference.ts +68 -0
  168. package/src/cpsat/rules/max-consecutive-days.ts +98 -0
  169. package/src/cpsat/rules/max-hours-day.ts +187 -0
  170. package/src/cpsat/rules/max-hours-week.ts +197 -0
  171. package/src/cpsat/rules/max-shifts-day.ts +198 -0
  172. package/src/cpsat/rules/min-consecutive-days.ts +140 -0
  173. package/src/cpsat/rules/min-hours-day.ts +69 -0
  174. package/src/cpsat/rules/min-hours-week.ts +77 -0
  175. package/src/cpsat/rules/min-rest-between-shifts.ts +121 -0
  176. package/src/cpsat/rules/registry.ts +49 -0
  177. package/src/cpsat/rules/resolver.ts +181 -0
  178. package/src/cpsat/rules/rules.types.ts +41 -0
  179. package/src/cpsat/rules/scoping.ts +340 -0
  180. package/src/cpsat/rules/time-off.ts +336 -0
  181. package/src/cpsat/rules.ts +27 -0
  182. package/src/cpsat/semantic-time.ts +463 -0
  183. package/src/cpsat/types.ts +194 -0
  184. package/src/cpsat/utils.ts +105 -0
  185. package/src/cpsat/validation-reporter.ts +366 -0
  186. package/src/cpsat/validation.types.ts +185 -0
  187. package/src/datetime.utils.ts +426 -0
  188. package/src/errors.ts +17 -0
  189. package/src/index.ts +289 -0
  190. package/src/llms.ts +9 -0
  191. package/src/testing/index.ts +12 -0
  192. package/src/testing/solver-container.ts +172 -0
  193. package/src/types.ts +191 -0
  194. package/src/validation.ts +188 -0
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "dabke",
3
+ "version": "0.78.0",
4
+ "license": "MIT",
5
+ "description": "Scheduling library powered by constraint programming (CP-SAT)",
6
+ "author": "Christian Klotz <hello@christianklotz.co.uk>",
7
+ "homepage": "https://github.com/christianklotz/dabke#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/christianklotz/dabke.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/christianklotz/dabke/issues"
14
+ },
15
+ "keywords": [
16
+ "scheduling",
17
+ "staff-scheduling",
18
+ "constraint-programming",
19
+ "cp-sat",
20
+ "workforce-management",
21
+ "optimization",
22
+ "operations-research",
23
+ "or-tools"
24
+ ],
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "src",
33
+ "solver",
34
+ "llms.txt",
35
+ "LICENSE",
36
+ "README.md",
37
+ "CHANGELOG.md"
38
+ ],
39
+ "main": "dist/index.js",
40
+ "types": "dist/index.d.ts",
41
+ "exports": {
42
+ ".": {
43
+ "types": "./dist/index.d.ts",
44
+ "default": "./dist/index.js"
45
+ },
46
+ "./llms": {
47
+ "types": "./dist/llms.d.ts",
48
+ "default": "./dist/llms.js"
49
+ },
50
+ "./testing": {
51
+ "types": "./dist/testing/index.d.ts",
52
+ "default": "./dist/testing/index.js"
53
+ },
54
+ "./package.json": "./package.json"
55
+ },
56
+ "scripts": {
57
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
58
+ "prepublishOnly": "npm run build",
59
+ "typecheck": "tsc -p tsconfig.test.json",
60
+ "test": "npm run typecheck && npm run test:unit",
61
+ "test:unit": "vitest run --project=unit",
62
+ "test:unit:watch": "vitest --watch --project=unit",
63
+ "test:integration": "[ -f .env ] && { set -a; source .env; set +a; } && vitest run --project=integration",
64
+ "test:integration:watch": "[ -f .env ] && { set -a; source .env; set +a; } && vitest --watch --project=integration",
65
+ "generate:llmstxt": "tsx scripts/generate-llmstxt.ts && oxfmt --write llms.txt src/llms.ts"
66
+ },
67
+ "dependencies": {
68
+ "zod": "^4.3.6"
69
+ },
70
+ "devDependencies": {
71
+ "@types/node": "^22.0.0",
72
+ "commander": "^14.0.2",
73
+ "typescript": "^5.9.3",
74
+ "vitest": "^4.0.18"
75
+ }
76
+ }
@@ -0,0 +1,31 @@
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONUNBUFFERED=1
4
+ WORKDIR /app
5
+
6
+ # Install uv from official image
7
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
8
+
9
+ # Copy dependency files first for layer caching
10
+ COPY pyproject.toml uv.lock ./
11
+
12
+ # Sync dependencies only (skip local package since src/ doesn't exist yet)
13
+ RUN uv sync --frozen --no-dev --no-install-project
14
+
15
+ # Copy application source
16
+ COPY src/ ./src/
17
+
18
+ # Install the local package now that source exists
19
+ RUN uv sync --frozen --no-dev
20
+
21
+ # Use the venv
22
+ ENV PATH="/app/.venv/bin:$PATH"
23
+
24
+ # Server configuration
25
+ ENV PORT=8080
26
+ EXPOSE 8080
27
+
28
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
29
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"
30
+
31
+ CMD ["sh", "-c", "exec uvicorn solver.app:app --host 0.0.0.0 --port ${PORT} --workers 1"]
@@ -0,0 +1,23 @@
1
+ # Scheduler Solver Service
2
+
3
+ Minimal FastAPI service that accepts scheduling primitives (variables, constraints, optional objective) and solves them with OR-Tools CP-SAT.
4
+
5
+ ## Local run
6
+
7
+ ```sh
8
+ uv sync
9
+ uvicorn solver.app:app --reload --port 8080
10
+ ```
11
+
12
+ ## Docker
13
+
14
+ ```sh
15
+ docker build -t scheduler-solver .
16
+ docker run -p 8080:8080 scheduler-solver
17
+ ```
18
+
19
+ ## Tests
20
+
21
+ ```sh
22
+ uv run pytest
23
+ ```
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "scheduler-solver"
7
+ version = "0.1.0"
8
+ description = "CP-SAT solver microservice for scheduling primitives"
9
+ requires-python = ">=3.11,<3.13"
10
+ dependencies = [
11
+ "fastapi>=0.115.0",
12
+ "uvicorn[standard]>=0.32.0",
13
+ "pydantic>=2.10.0",
14
+ "ortools>=9.15.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = ["pytest>=8.3.3", "httpx>=0.28.1"]
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["src"]
22
+
23
+ [tool.pytest.ini_options]
24
+ addopts = "-q"
25
+ pythonpath = "src"
26
+
27
+ [dependency-groups]
28
+ dev = ["pytest>=8.3.3", "httpx>=0.28.1"]
@@ -0,0 +1 @@
1
+ """Scheduler solver service package."""
@@ -0,0 +1,24 @@
1
+ """FastAPI entrypoint for the solver service."""
2
+
3
+ from fastapi import FastAPI
4
+
5
+ from .models import SolverRequest, SolverResponse
6
+ from .solver import solve_request
7
+
8
+ app = FastAPI(
9
+ title="Scheduler Solver",
10
+ description="CP-SAT solver for scheduling primitives",
11
+ version="0.1.0",
12
+ )
13
+
14
+
15
+ @app.get("/health")
16
+ async def health() -> dict[str, str]:
17
+ """Health probe endpoint."""
18
+ return {"status": "ok", "service": "scheduler-solver"}
19
+
20
+
21
+ @app.post("/solve", response_model=SolverResponse)
22
+ async def solve(request: SolverRequest) -> SolverResponse:
23
+ """Solve a scheduling model described by primitive constraints."""
24
+ return solve_request(request)
@@ -0,0 +1,120 @@
1
+ """Pydantic models for solver requests and responses."""
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel, Field, ConfigDict
6
+
7
+
8
+ class Variable(BaseModel):
9
+ """Decision variable.
10
+
11
+ Supported types:
12
+ - bool: boolean variable
13
+ - int: integer variable with bounds
14
+ - interval: (optional) fixed interval with an absolute start/end and fixed size.
15
+ When presenceVar is provided, the interval is optional and active iff the
16
+ boolean presenceVar is true.
17
+ """
18
+
19
+ model_config = ConfigDict(extra="forbid")
20
+
21
+ type: Literal["bool", "int", "interval"]
22
+ name: str
23
+
24
+ # int
25
+ min: int | None = None
26
+ max: int | None = None
27
+
28
+ # interval
29
+ start: int | None = None
30
+ end: int | None = None
31
+ size: int | None = None
32
+ presenceVar: str | None = None
33
+
34
+
35
+ class Term(BaseModel):
36
+ """Linear term var * coeff."""
37
+
38
+ model_config = ConfigDict(extra="forbid")
39
+
40
+ var: str
41
+ coeff: int
42
+
43
+
44
+ class Constraint(BaseModel):
45
+ """Supported constraint primitives."""
46
+
47
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
48
+
49
+ type: Literal[
50
+ "linear",
51
+ "soft_linear",
52
+ "exactly_one",
53
+ "at_most_one",
54
+ "implication",
55
+ "bool_or",
56
+ "bool_and",
57
+ "no_overlap",
58
+ ]
59
+ terms: list[Term] | None = None
60
+ op: Literal["<=", ">=", "=="] | None = None
61
+ rhs: int | None = None
62
+ penalty: int | None = None
63
+ vars: list[str] | None = None
64
+ intervals: list[str] | None = None
65
+ if_: str | None = Field(default=None, alias="if")
66
+ then: str | None = None
67
+ id: str | None = None
68
+
69
+
70
+ class Objective(BaseModel):
71
+ """Optional objective definition."""
72
+
73
+ model_config = ConfigDict(extra="forbid")
74
+
75
+ sense: Literal["minimize", "maximize"]
76
+ terms: list[Term]
77
+
78
+
79
+ class Options(BaseModel):
80
+ """Solver tuning options."""
81
+
82
+ model_config = ConfigDict(extra="forbid")
83
+
84
+ timeLimitSeconds: float = 60.0
85
+ solutionLimit: int | None = None # Only solutionLimit=1 is supported (stops after first feasible solution)
86
+
87
+
88
+ class SolverRequest(BaseModel):
89
+ """Request payload accepted by the solver service."""
90
+
91
+ model_config = ConfigDict(extra="forbid")
92
+
93
+ variables: list[Variable]
94
+ constraints: list[Constraint]
95
+ objective: Objective | None = None
96
+ options: Options | None = None
97
+
98
+
99
+ class SoftConstraintViolation(BaseModel):
100
+ """A soft constraint violation in the solver output."""
101
+
102
+ model_config = ConfigDict(extra="forbid")
103
+
104
+ constraintId: str
105
+ violationAmount: int
106
+ targetValue: int
107
+ actualValue: int
108
+
109
+
110
+ class SolverResponse(BaseModel):
111
+ """Response payload produced by the solver service."""
112
+
113
+ model_config = ConfigDict(extra="forbid")
114
+
115
+ status: Literal["OPTIMAL", "FEASIBLE", "INFEASIBLE", "TIMEOUT", "ERROR"]
116
+ values: dict[str, int] | None = None
117
+ statistics: dict[str, int | float] | None = None
118
+ error: str | None = None
119
+ solutionInfo: str | None = None
120
+ softViolations: list[SoftConstraintViolation] | None = None
@@ -0,0 +1,359 @@
1
+ """CP-SAT solver translation layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Iterable, Optional
7
+
8
+ from ortools.sat.python import cp_model
9
+
10
+ from .models import (
11
+ Constraint,
12
+ Objective,
13
+ Options,
14
+ SolverRequest,
15
+ SolverResponse,
16
+ SoftConstraintViolation,
17
+ Term,
18
+ Variable,
19
+ )
20
+
21
+
22
+ @dataclass
23
+ class _VariableBounds:
24
+ """Simple bounds container for quick min/max calculations."""
25
+
26
+ lower: int
27
+ upper: int
28
+
29
+
30
+ @dataclass
31
+ class _TrackedSoftConstraint:
32
+ """Tracks soft constraints with identifiers for post-solve diagnostics."""
33
+
34
+ constraint_id: str
35
+ violation_var: cp_model.IntVar
36
+ target_value: int
37
+ comparator: str
38
+ terms: list[Term]
39
+
40
+
41
+ def _collect_bounds(variables: Iterable[Variable]) -> dict[str, _VariableBounds]:
42
+ bounds: dict[str, _VariableBounds] = {}
43
+ for var in variables:
44
+ if var.type == "bool":
45
+ bounds[var.name] = _VariableBounds(0, 1)
46
+ elif var.type == "int":
47
+ if var.min is None or var.max is None:
48
+ raise ValueError(f"Int variable {var.name} requires min and max")
49
+ bounds[var.name] = _VariableBounds(var.min, var.max)
50
+ elif var.type == "interval":
51
+ # Interval variables are not referenced in linear expressions.
52
+ continue
53
+ else:
54
+ raise ValueError(f"Unknown variable type {var.type}")
55
+ return bounds
56
+
57
+
58
+ def _term_range(term: Term, bounds: _VariableBounds) -> tuple[int, int]:
59
+ coeff = term.coeff
60
+ if coeff >= 0:
61
+ return coeff * bounds.lower, coeff * bounds.upper
62
+ return coeff * bounds.upper, coeff * bounds.lower
63
+
64
+
65
+ def _expression_range(terms: list[Term], bounds: dict[str, _VariableBounds]) -> tuple[int, int]:
66
+ min_expr = 0
67
+ max_expr = 0
68
+ for term in terms:
69
+ if term.var not in bounds:
70
+ raise ValueError(f"Unknown variable {term.var}")
71
+ term_min, term_max = _term_range(term, bounds[term.var])
72
+ min_expr += term_min
73
+ max_expr += term_max
74
+ return min_expr, max_expr
75
+
76
+
77
+ def _sum_expr(exprs: list[cp_model.LinearExpr]) -> Optional[cp_model.LinearExpr]:
78
+ if not exprs:
79
+ return None
80
+ total = exprs[0]
81
+ for expr in exprs[1:]:
82
+ total += expr
83
+ return total
84
+
85
+
86
+ def _add_linear_constraint(model: cp_model.CpModel, constraint: Constraint, vars_map: dict[str, cp_model.IntVar]) -> None:
87
+ if not constraint.terms or constraint.op is None or constraint.rhs is None:
88
+ raise ValueError("Linear constraint requires terms, op, and rhs")
89
+ expr = sum(vars_map[t.var] * t.coeff for t in constraint.terms)
90
+ match constraint.op:
91
+ case "<=":
92
+ model.Add(expr <= constraint.rhs)
93
+ case ">=":
94
+ model.Add(expr >= constraint.rhs)
95
+ case "==":
96
+ model.Add(expr == constraint.rhs)
97
+ case _:
98
+ raise ValueError(f"Unsupported linear operator {constraint.op}")
99
+
100
+
101
+ def _add_soft_linear_constraint(
102
+ model: cp_model.CpModel,
103
+ constraint: Constraint,
104
+ vars_map: dict[str, cp_model.IntVar],
105
+ bounds: dict[str, _VariableBounds],
106
+ penalty_terms: list[cp_model.LinearExpr],
107
+ tracked_constraints: list[_TrackedSoftConstraint],
108
+ constraint_index: int,
109
+ ) -> 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)
114
+ max_violation = 0
115
+ violation: cp_model.IntVar | None = None
116
+ expr = sum(vars_map[t.var] * t.coeff for t in constraint.terms)
117
+ constraint_id = constraint.id or f"soft_{constraint_index}"
118
+ match constraint.op:
119
+ case "<=":
120
+ max_violation = max(0, max_expr - constraint.rhs)
121
+ violation = model.NewIntVar(0, max_violation, f"violation_{constraint_id}")
122
+ model.Add(expr <= constraint.rhs + violation)
123
+ case ">=":
124
+ max_violation = max(0, constraint.rhs - min_expr)
125
+ violation = model.NewIntVar(0, max_violation, f"violation_{constraint_id}")
126
+ model.Add(expr + violation >= constraint.rhs)
127
+ case _:
128
+ raise ValueError(f"Unsupported soft linear operator {constraint.op}")
129
+
130
+ if violation is None:
131
+ raise ValueError("Soft linear constraint failed to create violation variable")
132
+
133
+ if max_violation > 0:
134
+ penalty_terms.append(violation * constraint.penalty)
135
+ if constraint.id is not None:
136
+ tracked_constraints.append(
137
+ _TrackedSoftConstraint(
138
+ constraint_id=constraint_id,
139
+ violation_var=violation,
140
+ target_value=constraint.rhs,
141
+ comparator=constraint.op,
142
+ terms=constraint.terms,
143
+ )
144
+ )
145
+
146
+
147
+ def _add_exactly_one(model: cp_model.CpModel, constraint: Constraint, vars_map: dict[str, cp_model.IntVar]) -> None:
148
+ if not constraint.vars:
149
+ raise ValueError("Exactly one constraint requires vars")
150
+ literals = [vars_map[v] for v in constraint.vars]
151
+ model.AddExactlyOne(literals)
152
+
153
+
154
+ def _add_at_most_one(model: cp_model.CpModel, constraint: Constraint, vars_map: dict[str, cp_model.IntVar]) -> None:
155
+ if not constraint.vars:
156
+ raise ValueError("At most one constraint requires vars")
157
+ literals = [vars_map[v] for v in constraint.vars]
158
+ model.AddAtMostOne(literals)
159
+
160
+
161
+ def _add_implication(model: cp_model.CpModel, constraint: Constraint, vars_map: dict[str, cp_model.IntVar]) -> None:
162
+ if constraint.if_ is None or constraint.then is None:
163
+ raise ValueError("Implication constraint requires if/then")
164
+ model.AddImplication(vars_map[constraint.if_], vars_map[constraint.then])
165
+
166
+
167
+ def _add_bool_or(model: cp_model.CpModel, constraint: Constraint, vars_map: dict[str, cp_model.IntVar]) -> None:
168
+ if not constraint.vars:
169
+ raise ValueError("Bool OR constraint requires vars")
170
+ literals = [vars_map[v] for v in constraint.vars]
171
+ model.AddBoolOr(literals)
172
+
173
+
174
+ def _add_bool_and(model: cp_model.CpModel, constraint: Constraint, vars_map: dict[str, cp_model.IntVar]) -> None:
175
+ if not constraint.vars:
176
+ raise ValueError("Bool AND constraint requires vars")
177
+ literals = [vars_map[v] for v in constraint.vars]
178
+ model.AddBoolAnd(literals)
179
+
180
+
181
+ def _add_no_overlap(
182
+ model: cp_model.CpModel,
183
+ constraint: Constraint,
184
+ intervals_map: dict[str, cp_model.IntervalVar],
185
+ ) -> None:
186
+ if not constraint.intervals:
187
+ raise ValueError("NoOverlap constraint requires intervals")
188
+ intervals = [intervals_map[name] for name in constraint.intervals]
189
+ model.AddNoOverlap(intervals)
190
+
191
+
192
+ def _build_objective(
193
+ model: cp_model.CpModel,
194
+ objective: Objective | None,
195
+ vars_map: dict[str, cp_model.IntVar],
196
+ penalty_terms: list[cp_model.LinearExpr],
197
+ ) -> bool:
198
+ penalty_expr: cp_model.LinearExpr | None = _sum_expr(penalty_terms)
199
+
200
+ if objective:
201
+ objective_terms: list[cp_model.LinearExpr] = (
202
+ [vars_map[t.var] * t.coeff for t in objective.terms] if objective.terms else []
203
+ )
204
+ obj_expr: cp_model.LinearExpr | None = _sum_expr(objective_terms)
205
+ 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
213
+
214
+ if objective.sense == "maximize":
215
+ model.Maximize(expr)
216
+ else:
217
+ model.Minimize(expr)
218
+ return True
219
+
220
+ if penalty_expr is not None:
221
+ model.Minimize(penalty_expr)
222
+ return True
223
+
224
+ return False
225
+
226
+
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
+
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
+ return SolverResponse(
323
+ status=status_text,
324
+ solutionInfo=solver.response_proto.solution_info or None,
325
+ )
326
+
327
+ statistics: dict[str, int | float] = {
328
+ "solveTimeMs": int(solver.wall_time * 1000),
329
+ "conflicts": solver.num_conflicts,
330
+ "branches": solver.num_branches,
331
+ }
332
+
333
+ if has_objective:
334
+ statistics["objectiveValue"] = solver.objective_value
335
+ statistics["bestObjectiveBound"] = solver.best_objective_bound
336
+
337
+ soft_violations: list[SoftConstraintViolation] = []
338
+ for tracked in tracked_constraints:
339
+ violation_amount = int(solver.value(tracked.violation_var))
340
+ if violation_amount <= 0:
341
+ continue
342
+ actual_value = sum(int(solver.value(vars_map[t.var])) * t.coeff for t in tracked.terms)
343
+ soft_violations.append(
344
+ SoftConstraintViolation(
345
+ constraintId=tracked.constraint_id,
346
+ violationAmount=violation_amount,
347
+ targetValue=tracked.target_value,
348
+ actualValue=actual_value,
349
+ )
350
+ )
351
+
352
+ return SolverResponse(
353
+ status=status_text,
354
+ values={name: int(solver.value(var)) for name, var in vars_map.items()},
355
+ statistics=statistics,
356
+ softViolations=soft_violations if soft_violations else None,
357
+ )
358
+ except Exception as exc: # pragma: no cover - surfaced in response
359
+ return SolverResponse(status="ERROR", error=str(exc))