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.
- package/CHANGELOG.md +120 -0
- package/LICENSE +21 -0
- package/README.md +187 -0
- package/dist/client.d.ts +14 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +42 -0
- package/dist/client.js.map +1 -0
- package/dist/client.schemas.d.ts +250 -0
- package/dist/client.schemas.d.ts.map +1 -0
- package/dist/client.schemas.js +137 -0
- package/dist/client.schemas.js.map +1 -0
- package/dist/client.types.d.ts +34 -0
- package/dist/client.types.d.ts.map +1 -0
- package/dist/client.types.js +18 -0
- package/dist/client.types.js.map +1 -0
- package/dist/cpsat/model-builder.d.ts +128 -0
- package/dist/cpsat/model-builder.d.ts.map +1 -0
- package/dist/cpsat/model-builder.js +640 -0
- package/dist/cpsat/model-builder.js.map +1 -0
- package/dist/cpsat/response.d.ts +74 -0
- package/dist/cpsat/response.d.ts.map +1 -0
- package/dist/cpsat/response.js +92 -0
- package/dist/cpsat/response.js.map +1 -0
- package/dist/cpsat/rules/assign-together.d.ts +23 -0
- package/dist/cpsat/rules/assign-together.d.ts.map +1 -0
- package/dist/cpsat/rules/assign-together.js +78 -0
- package/dist/cpsat/rules/assign-together.js.map +1 -0
- package/dist/cpsat/rules/employee-assignment-priority.d.ts +64 -0
- package/dist/cpsat/rules/employee-assignment-priority.d.ts.map +1 -0
- package/dist/cpsat/rules/employee-assignment-priority.js +151 -0
- package/dist/cpsat/rules/employee-assignment-priority.js.map +1 -0
- package/dist/cpsat/rules/index.d.ts +13 -0
- package/dist/cpsat/rules/index.d.ts.map +1 -0
- package/dist/cpsat/rules/index.js +13 -0
- package/dist/cpsat/rules/index.js.map +1 -0
- package/dist/cpsat/rules/location-preference.d.ts +29 -0
- package/dist/cpsat/rules/location-preference.d.ts.map +1 -0
- package/dist/cpsat/rules/location-preference.js +59 -0
- package/dist/cpsat/rules/location-preference.js.map +1 -0
- package/dist/cpsat/rules/max-consecutive-days.d.ts +28 -0
- package/dist/cpsat/rules/max-consecutive-days.d.ts.map +1 -0
- package/dist/cpsat/rules/max-consecutive-days.js +70 -0
- package/dist/cpsat/rules/max-consecutive-days.js.map +1 -0
- package/dist/cpsat/rules/max-hours-day.d.ts +57 -0
- package/dist/cpsat/rules/max-hours-day.d.ts.map +1 -0
- package/dist/cpsat/rules/max-hours-day.js +159 -0
- package/dist/cpsat/rules/max-hours-day.js.map +1 -0
- package/dist/cpsat/rules/max-hours-week.d.ts +62 -0
- package/dist/cpsat/rules/max-hours-week.d.ts.map +1 -0
- package/dist/cpsat/rules/max-hours-week.js +169 -0
- package/dist/cpsat/rules/max-hours-week.js.map +1 -0
- package/dist/cpsat/rules/max-shifts-day.d.ts +69 -0
- package/dist/cpsat/rules/max-shifts-day.d.ts.map +1 -0
- package/dist/cpsat/rules/max-shifts-day.js +170 -0
- package/dist/cpsat/rules/max-shifts-day.js.map +1 -0
- package/dist/cpsat/rules/min-consecutive-days.d.ts +29 -0
- package/dist/cpsat/rules/min-consecutive-days.d.ts.map +1 -0
- package/dist/cpsat/rules/min-consecutive-days.js +104 -0
- package/dist/cpsat/rules/min-consecutive-days.js.map +1 -0
- package/dist/cpsat/rules/min-hours-day.d.ts +28 -0
- package/dist/cpsat/rules/min-hours-day.d.ts.map +1 -0
- package/dist/cpsat/rules/min-hours-day.js +61 -0
- package/dist/cpsat/rules/min-hours-day.js.map +1 -0
- package/dist/cpsat/rules/min-hours-week.d.ts +29 -0
- package/dist/cpsat/rules/min-hours-week.d.ts.map +1 -0
- package/dist/cpsat/rules/min-hours-week.js +68 -0
- package/dist/cpsat/rules/min-hours-week.js.map +1 -0
- package/dist/cpsat/rules/min-rest-between-shifts.d.ts +28 -0
- package/dist/cpsat/rules/min-rest-between-shifts.d.ts.map +1 -0
- package/dist/cpsat/rules/min-rest-between-shifts.js +95 -0
- package/dist/cpsat/rules/min-rest-between-shifts.js.map +1 -0
- package/dist/cpsat/rules/registry.d.ts +7 -0
- package/dist/cpsat/rules/registry.d.ts.map +1 -0
- package/dist/cpsat/rules/registry.js +28 -0
- package/dist/cpsat/rules/registry.js.map +1 -0
- package/dist/cpsat/rules/resolver.d.ts +31 -0
- package/dist/cpsat/rules/resolver.d.ts.map +1 -0
- package/dist/cpsat/rules/resolver.js +124 -0
- package/dist/cpsat/rules/resolver.js.map +1 -0
- package/dist/cpsat/rules/rules.types.d.ts +32 -0
- package/dist/cpsat/rules/rules.types.d.ts.map +1 -0
- package/dist/cpsat/rules/rules.types.js +2 -0
- package/dist/cpsat/rules/rules.types.js.map +1 -0
- package/dist/cpsat/rules/scoping.d.ts +129 -0
- package/dist/cpsat/rules/scoping.d.ts.map +1 -0
- package/dist/cpsat/rules/scoping.js +190 -0
- package/dist/cpsat/rules/scoping.js.map +1 -0
- package/dist/cpsat/rules/time-off.d.ts +78 -0
- package/dist/cpsat/rules/time-off.d.ts.map +1 -0
- package/dist/cpsat/rules/time-off.js +261 -0
- package/dist/cpsat/rules/time-off.js.map +1 -0
- package/dist/cpsat/rules.d.ts +5 -0
- package/dist/cpsat/rules.d.ts.map +1 -0
- package/dist/cpsat/rules.js +4 -0
- package/dist/cpsat/rules.js.map +1 -0
- package/dist/cpsat/semantic-time.d.ts +198 -0
- package/dist/cpsat/semantic-time.d.ts.map +1 -0
- package/dist/cpsat/semantic-time.js +222 -0
- package/dist/cpsat/semantic-time.js.map +1 -0
- package/dist/cpsat/types.d.ts +180 -0
- package/dist/cpsat/types.d.ts.map +1 -0
- package/dist/cpsat/types.js +2 -0
- package/dist/cpsat/types.js.map +1 -0
- package/dist/cpsat/utils.d.ts +47 -0
- package/dist/cpsat/utils.d.ts.map +1 -0
- package/dist/cpsat/utils.js +92 -0
- package/dist/cpsat/utils.js.map +1 -0
- package/dist/cpsat/validation-reporter.d.ts +54 -0
- package/dist/cpsat/validation-reporter.d.ts.map +1 -0
- package/dist/cpsat/validation-reporter.js +261 -0
- package/dist/cpsat/validation-reporter.js.map +1 -0
- package/dist/cpsat/validation.types.d.ts +141 -0
- package/dist/cpsat/validation.types.d.ts.map +1 -0
- package/dist/cpsat/validation.types.js +14 -0
- package/dist/cpsat/validation.types.js.map +1 -0
- package/dist/datetime.utils.d.ts +245 -0
- package/dist/datetime.utils.d.ts.map +1 -0
- package/dist/datetime.utils.js +372 -0
- package/dist/datetime.utils.js.map +1 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +17 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +112 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +116 -0
- package/dist/index.js.map +1 -0
- package/dist/llms.d.ts +5 -0
- package/dist/llms.d.ts.map +1 -0
- package/dist/llms.js +8 -0
- package/dist/llms.js.map +1 -0
- package/dist/testing/index.d.ts +12 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +11 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/solver-container.d.ts +49 -0
- package/dist/testing/solver-container.d.ts.map +1 -0
- package/dist/testing/solver-container.js +127 -0
- package/dist/testing/solver-container.js.map +1 -0
- package/dist/types.d.ts +155 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +20 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +105 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +130 -0
- package/dist/validation.js.map +1 -0
- package/llms.txt +2188 -0
- package/package.json +76 -0
- package/solver/Dockerfile +31 -0
- package/solver/README.md +23 -0
- package/solver/pyproject.toml +28 -0
- package/solver/src/solver/__init__.py +1 -0
- package/solver/src/solver/app.py +24 -0
- package/solver/src/solver/models.py +120 -0
- package/solver/src/solver/solver.py +359 -0
- package/solver/tests/test_solver.py +156 -0
- package/solver/uv.lock +661 -0
- package/src/client.schemas.ts +163 -0
- package/src/client.ts +67 -0
- package/src/client.types.ts +66 -0
- package/src/cpsat/model-builder.ts +858 -0
- package/src/cpsat/response.ts +130 -0
- package/src/cpsat/rules/assign-together.ts +96 -0
- package/src/cpsat/rules/employee-assignment-priority.ts +182 -0
- package/src/cpsat/rules/index.ts +12 -0
- package/src/cpsat/rules/location-preference.ts +68 -0
- package/src/cpsat/rules/max-consecutive-days.ts +98 -0
- package/src/cpsat/rules/max-hours-day.ts +187 -0
- package/src/cpsat/rules/max-hours-week.ts +197 -0
- package/src/cpsat/rules/max-shifts-day.ts +198 -0
- package/src/cpsat/rules/min-consecutive-days.ts +140 -0
- package/src/cpsat/rules/min-hours-day.ts +69 -0
- package/src/cpsat/rules/min-hours-week.ts +77 -0
- package/src/cpsat/rules/min-rest-between-shifts.ts +121 -0
- package/src/cpsat/rules/registry.ts +49 -0
- package/src/cpsat/rules/resolver.ts +181 -0
- package/src/cpsat/rules/rules.types.ts +41 -0
- package/src/cpsat/rules/scoping.ts +340 -0
- package/src/cpsat/rules/time-off.ts +336 -0
- package/src/cpsat/rules.ts +27 -0
- package/src/cpsat/semantic-time.ts +463 -0
- package/src/cpsat/types.ts +194 -0
- package/src/cpsat/utils.ts +105 -0
- package/src/cpsat/validation-reporter.ts +366 -0
- package/src/cpsat/validation.types.ts +185 -0
- package/src/datetime.utils.ts +426 -0
- package/src/errors.ts +17 -0
- package/src/index.ts +289 -0
- package/src/llms.ts +9 -0
- package/src/testing/index.ts +12 -0
- package/src/testing/solver-container.ts +172 -0
- package/src/types.ts +191 -0
- 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"]
|
package/solver/README.md
ADDED
|
@@ -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))
|