solverforge 0.4.0__cp314-cp314-win_amd64.whl
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.
- solverforge/__init__.py +47 -0
- solverforge/_native.cp314-win_amd64.pyd +0 -0
- solverforge/_native.pyi +20 -0
- solverforge/config.py +160 -0
- solverforge/console.py +7 -0
- solverforge/constraints.py +293 -0
- solverforge/decorators.py +97 -0
- solverforge/errors.py +15 -0
- solverforge/events.py +13 -0
- solverforge/fields.py +61 -0
- solverforge/joiner.py +28 -0
- solverforge/manager.py +58 -0
- solverforge/model.py +132 -0
- solverforge/py.typed +1 -0
- solverforge/score.py +171 -0
- solverforge/solver.py +19 -0
- solverforge/typing.py +17 -0
- solverforge-0.4.0.dist-info/METADATA +187 -0
- solverforge-0.4.0.dist-info/RECORD +22 -0
- solverforge-0.4.0.dist-info/WHEEL +4 -0
- solverforge-0.4.0.dist-info/licenses/LICENSE +201 -0
- solverforge-0.4.0.dist-info/sboms/solverforge_py.cyclonedx.json +3360 -0
solverforge/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from . import console, joiner
|
|
2
|
+
from .config import SolverConfig, TerminationConfig
|
|
3
|
+
from .constraints import ConstraintFactory
|
|
4
|
+
from .decorators import (
|
|
5
|
+
conflict_repair,
|
|
6
|
+
constraint_provider,
|
|
7
|
+
planning_entity,
|
|
8
|
+
planning_solution,
|
|
9
|
+
problem_fact,
|
|
10
|
+
scalar_group,
|
|
11
|
+
)
|
|
12
|
+
from .errors import CallbackError, ModelValidationError, NativeBridgeError, SolverForgeError
|
|
13
|
+
from .fields import planning_id, planning_list_variable, planning_variable
|
|
14
|
+
from .manager import JobHandle, SolverManager
|
|
15
|
+
from .score import HardMediumSoftScore, HardSoftDecimalScore, HardSoftScore, SoftScore
|
|
16
|
+
from .solver import Solver
|
|
17
|
+
|
|
18
|
+
__version__ = "0.4.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"CallbackError",
|
|
22
|
+
"ConstraintFactory",
|
|
23
|
+
"HardMediumSoftScore",
|
|
24
|
+
"HardSoftDecimalScore",
|
|
25
|
+
"HardSoftScore",
|
|
26
|
+
"JobHandle",
|
|
27
|
+
"ModelValidationError",
|
|
28
|
+
"NativeBridgeError",
|
|
29
|
+
"SoftScore",
|
|
30
|
+
"Solver",
|
|
31
|
+
"SolverConfig",
|
|
32
|
+
"SolverForgeError",
|
|
33
|
+
"SolverManager",
|
|
34
|
+
"TerminationConfig",
|
|
35
|
+
"__version__",
|
|
36
|
+
"console",
|
|
37
|
+
"conflict_repair",
|
|
38
|
+
"constraint_provider",
|
|
39
|
+
"joiner",
|
|
40
|
+
"planning_entity",
|
|
41
|
+
"planning_id",
|
|
42
|
+
"planning_list_variable",
|
|
43
|
+
"planning_solution",
|
|
44
|
+
"planning_variable",
|
|
45
|
+
"problem_fact",
|
|
46
|
+
"scalar_group",
|
|
47
|
+
]
|
|
Binary file
|
solverforge/_native.pyi
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
class NativeSolverError(RuntimeError): ...
|
|
4
|
+
|
|
5
|
+
class SolverManager:
|
|
6
|
+
def __init__(self, config: dict[str, Any] | None = None) -> None: ...
|
|
7
|
+
def solve(self, solution: object, schema: dict[str, Any]) -> int: ...
|
|
8
|
+
def get_status(self, job_id: int) -> dict[str, object]: ...
|
|
9
|
+
def drain_events(self, job_id: int) -> list[dict[str, object]]: ...
|
|
10
|
+
def snapshot(self, job_id: int, snapshot_revision: int | None = None) -> object: ...
|
|
11
|
+
def pause(self, job_id: int) -> None: ...
|
|
12
|
+
def resume(self, job_id: int) -> None: ...
|
|
13
|
+
def cancel(self, job_id: int) -> None: ...
|
|
14
|
+
def delete(self, job_id: int) -> None: ...
|
|
15
|
+
|
|
16
|
+
def solve(solution: object, schema: dict[str, Any], config: dict[str, Any] | None = None) -> object: ...
|
|
17
|
+
def calculate_score(solution: object, schema: dict[str, Any]) -> object: ...
|
|
18
|
+
def init_console() -> None: ...
|
|
19
|
+
def validate_schema(schema: dict[str, Any]) -> None: ...
|
|
20
|
+
def native_version() -> str: ...
|
solverforge/config.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from os import PathLike
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_TERMINATION_FIELDS = {
|
|
11
|
+
"seconds_spent_limit",
|
|
12
|
+
"minutes_spent_limit",
|
|
13
|
+
"best_score_limit",
|
|
14
|
+
"step_count_limit",
|
|
15
|
+
"unimproved_step_count_limit",
|
|
16
|
+
"unimproved_seconds_spent_limit",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class TerminationConfig:
|
|
22
|
+
seconds_spent_limit: int | None = None
|
|
23
|
+
minutes_spent_limit: int | None = None
|
|
24
|
+
best_score_limit: str | None = None
|
|
25
|
+
step_count_limit: int | None = None
|
|
26
|
+
unimproved_step_count_limit: int | None = None
|
|
27
|
+
unimproved_seconds_spent_limit: int | None = None
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_dict(cls, data: dict[str, Any] | None) -> TerminationConfig:
|
|
31
|
+
if not data:
|
|
32
|
+
return cls()
|
|
33
|
+
unknown = sorted(set(data) - _TERMINATION_FIELDS)
|
|
34
|
+
if unknown:
|
|
35
|
+
joined = ", ".join(unknown)
|
|
36
|
+
expected = ", ".join(sorted(_TERMINATION_FIELDS))
|
|
37
|
+
msg = f"unknown SolverForge termination field(s): {joined}; expected one of: {expected}"
|
|
38
|
+
raise ValueError(msg)
|
|
39
|
+
return cls(
|
|
40
|
+
seconds_spent_limit=data.get("seconds_spent_limit"),
|
|
41
|
+
minutes_spent_limit=data.get("minutes_spent_limit"),
|
|
42
|
+
best_score_limit=data.get("best_score_limit"),
|
|
43
|
+
step_count_limit=data.get("step_count_limit"),
|
|
44
|
+
unimproved_step_count_limit=data.get("unimproved_step_count_limit"),
|
|
45
|
+
unimproved_seconds_spent_limit=data.get("unimproved_seconds_spent_limit"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict[str, Any]:
|
|
49
|
+
data = {
|
|
50
|
+
"seconds_spent_limit": self.seconds_spent_limit,
|
|
51
|
+
"minutes_spent_limit": self.minutes_spent_limit,
|
|
52
|
+
"best_score_limit": self.best_score_limit,
|
|
53
|
+
"step_count_limit": self.step_count_limit,
|
|
54
|
+
"unimproved_step_count_limit": self.unimproved_step_count_limit,
|
|
55
|
+
"unimproved_seconds_spent_limit": self.unimproved_seconds_spent_limit,
|
|
56
|
+
}
|
|
57
|
+
return {key: value for key, value in data.items() if value is not None}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class SolverConfig:
|
|
62
|
+
seconds_spent_limit: int | None = None
|
|
63
|
+
random_seed: int | None = None
|
|
64
|
+
phases: list[dict[str, Any]] = field(default_factory=list)
|
|
65
|
+
termination: TerminationConfig | dict[str, Any] | None = None
|
|
66
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_dict(cls, data: dict[str, Any]) -> SolverConfig:
|
|
70
|
+
termination_data = _termination_data_from_config_dict(data)
|
|
71
|
+
termination = TerminationConfig.from_dict(termination_data)
|
|
72
|
+
extra = {
|
|
73
|
+
key: deepcopy(value)
|
|
74
|
+
for key, value in data.items()
|
|
75
|
+
if key not in {"random_seed", "termination", "phases"} | _TERMINATION_FIELDS
|
|
76
|
+
}
|
|
77
|
+
return cls(
|
|
78
|
+
seconds_spent_limit=None,
|
|
79
|
+
random_seed=data.get("random_seed"),
|
|
80
|
+
phases=_normalize_phases(list(data.get("phases", []))),
|
|
81
|
+
termination=termination,
|
|
82
|
+
extra=extra,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_toml(cls, text: str) -> SolverConfig:
|
|
87
|
+
import tomllib
|
|
88
|
+
|
|
89
|
+
return cls.from_dict(tomllib.loads(text))
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def load(cls, path: str | PathLike[str] = "solver.toml") -> SolverConfig:
|
|
93
|
+
return cls.from_toml(Path(path).read_text(encoding="utf-8"))
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_file(cls, path: str | PathLike[str] = "solver.toml") -> SolverConfig:
|
|
97
|
+
return cls.load(path)
|
|
98
|
+
|
|
99
|
+
def to_dict(self) -> dict[str, Any]:
|
|
100
|
+
data: dict[str, Any] = deepcopy(self.extra)
|
|
101
|
+
if self.random_seed is not None:
|
|
102
|
+
data["random_seed"] = self.random_seed
|
|
103
|
+
termination = _termination_to_dict(self.termination)
|
|
104
|
+
if self.seconds_spent_limit is not None:
|
|
105
|
+
termination["seconds_spent_limit"] = self.seconds_spent_limit
|
|
106
|
+
if termination:
|
|
107
|
+
data["termination"] = termination
|
|
108
|
+
if self.phases:
|
|
109
|
+
data["phases"] = _normalize_phases(self.phases)
|
|
110
|
+
return data
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _resolve_config(config: SolverConfig | dict[str, Any] | None) -> dict[str, Any] | None:
|
|
114
|
+
if isinstance(config, SolverConfig):
|
|
115
|
+
return config.to_dict()
|
|
116
|
+
if config is not None:
|
|
117
|
+
return SolverConfig.from_dict(config).to_dict()
|
|
118
|
+
path = Path("solver.toml")
|
|
119
|
+
if path.is_file():
|
|
120
|
+
return SolverConfig.load(path).to_dict()
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _termination_to_dict(
|
|
125
|
+
termination: TerminationConfig | dict[str, Any] | None,
|
|
126
|
+
) -> dict[str, Any]:
|
|
127
|
+
if termination is None:
|
|
128
|
+
return {}
|
|
129
|
+
if isinstance(termination, TerminationConfig):
|
|
130
|
+
return termination.to_dict()
|
|
131
|
+
return TerminationConfig.from_dict(dict(termination)).to_dict()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _termination_data_from_config_dict(data: dict[str, Any]) -> dict[str, Any]:
|
|
135
|
+
termination = dict(data.get("termination") or {})
|
|
136
|
+
for key in _TERMINATION_FIELDS:
|
|
137
|
+
if key not in data:
|
|
138
|
+
continue
|
|
139
|
+
if key in termination and termination[key] != data[key]:
|
|
140
|
+
msg = (
|
|
141
|
+
f"conflicting SolverForge termination field `{key}`: "
|
|
142
|
+
"set it either at the top level or under `termination`, not both"
|
|
143
|
+
)
|
|
144
|
+
raise ValueError(msg)
|
|
145
|
+
termination[key] = deepcopy(data[key])
|
|
146
|
+
return termination
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _normalize_phases(phases: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
150
|
+
normalized: list[dict[str, Any]] = []
|
|
151
|
+
for phase in phases:
|
|
152
|
+
phase_copy = deepcopy(phase)
|
|
153
|
+
if "termination" in phase_copy and phase_copy["termination"] is not None:
|
|
154
|
+
phase_copy["termination"] = TerminationConfig.from_dict(
|
|
155
|
+
dict(phase_copy["termination"])
|
|
156
|
+
).to_dict()
|
|
157
|
+
if "child_phases" in phase_copy:
|
|
158
|
+
phase_copy["child_phases"] = _normalize_phases(list(phase_copy["child_phases"]))
|
|
159
|
+
normalized.append(phase_copy)
|
|
160
|
+
return normalized
|
solverforge/console.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Self
|
|
6
|
+
|
|
7
|
+
from .score import score_to_native
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ConstraintPlan:
|
|
12
|
+
entity_type: type[object]
|
|
13
|
+
score_family: str = "hard_soft"
|
|
14
|
+
arity: int = 1
|
|
15
|
+
left_filters: tuple[Callable[..., bool], ...] = ()
|
|
16
|
+
right_filters: tuple[Callable[..., bool], ...] = ()
|
|
17
|
+
filters: tuple[Callable[..., bool], ...] = ()
|
|
18
|
+
group_filters: tuple[Callable[..., bool], ...] = ()
|
|
19
|
+
impact: str = "penalty"
|
|
20
|
+
weight: object = 1
|
|
21
|
+
name: str | None = None
|
|
22
|
+
right_entity_type: type[object] | None = None
|
|
23
|
+
joiners: tuple[object, ...] = ()
|
|
24
|
+
group_key: Callable[..., object] | None = None
|
|
25
|
+
balance_key: Callable[..., object] | None = None
|
|
26
|
+
|
|
27
|
+
def to_native(self) -> dict[str, object]:
|
|
28
|
+
plan: dict[str, object] = {
|
|
29
|
+
"arity": self.arity,
|
|
30
|
+
"entity_type": self.entity_type.__name__,
|
|
31
|
+
"left_filters": list(self.left_filters),
|
|
32
|
+
"right_filters": list(self.right_filters),
|
|
33
|
+
"filters": list(self.filters),
|
|
34
|
+
"group_filters": list(self.group_filters),
|
|
35
|
+
"impact": self.impact,
|
|
36
|
+
"name": self.name or f"{self.entity_type.__name__} constraint",
|
|
37
|
+
}
|
|
38
|
+
if self.right_entity_type is not None:
|
|
39
|
+
plan["right_entity_type"] = self.right_entity_type.__name__
|
|
40
|
+
if self.joiners:
|
|
41
|
+
plan["joiners"] = [joiner_to_native(joiner) for joiner in self.joiners]
|
|
42
|
+
if self.group_key is not None:
|
|
43
|
+
plan["group_key"] = self.group_key
|
|
44
|
+
if self.balance_key is not None:
|
|
45
|
+
plan["balance_key"] = self.balance_key
|
|
46
|
+
if callable(self.weight):
|
|
47
|
+
plan["weight"] = _callback_weight_placeholder(self.score_family)
|
|
48
|
+
plan["weight_callback"] = self.weight
|
|
49
|
+
else:
|
|
50
|
+
plan["weight"] = score_to_native(self.weight, self.score_family)
|
|
51
|
+
return plan
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class UniConstraintStream:
|
|
56
|
+
entity_type: type[object]
|
|
57
|
+
score_family: str = "hard_soft"
|
|
58
|
+
filters: list[Callable[..., bool]] = field(default_factory=list)
|
|
59
|
+
impact: str | None = None
|
|
60
|
+
weight: object | None = None
|
|
61
|
+
|
|
62
|
+
def filter(self, predicate: Callable[..., bool]) -> Self:
|
|
63
|
+
self.filters.append(predicate)
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def penalize(self, weight: object) -> Self:
|
|
67
|
+
self.impact = "penalty"
|
|
68
|
+
self.weight = weight
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def reward(self, weight: object) -> Self:
|
|
72
|
+
self.impact = "reward"
|
|
73
|
+
self.weight = weight
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def join(
|
|
77
|
+
self,
|
|
78
|
+
target: object,
|
|
79
|
+
*joiners: object,
|
|
80
|
+
) -> BiConstraintStream:
|
|
81
|
+
right_filters: list[Callable[..., bool]] = []
|
|
82
|
+
if isinstance(target, tuple):
|
|
83
|
+
if not target:
|
|
84
|
+
msg = "join target tuple must not be empty"
|
|
85
|
+
raise TypeError(msg)
|
|
86
|
+
stream_or_type, *tuple_joiners = target
|
|
87
|
+
target = stream_or_type
|
|
88
|
+
joiners = (*tuple_joiners, *joiners)
|
|
89
|
+
if isinstance(target, UniConstraintStream):
|
|
90
|
+
right_entity_type = target.entity_type
|
|
91
|
+
right_filters = list(target.filters)
|
|
92
|
+
elif isinstance(target, type):
|
|
93
|
+
right_entity_type = target
|
|
94
|
+
elif hasattr(target, "to_native"):
|
|
95
|
+
right_entity_type = self.entity_type
|
|
96
|
+
joiners = (target, *joiners)
|
|
97
|
+
else:
|
|
98
|
+
msg = f"unsupported join target {target!r}"
|
|
99
|
+
raise TypeError(msg)
|
|
100
|
+
return BiConstraintStream(
|
|
101
|
+
entity_type=self.entity_type,
|
|
102
|
+
score_family=self.score_family,
|
|
103
|
+
right_entity_type=right_entity_type,
|
|
104
|
+
left_filters=list(self.filters),
|
|
105
|
+
right_filters=right_filters,
|
|
106
|
+
joiners=list(joiners),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def group_by(self, key: Callable[[object], object]) -> GroupedConstraintStream:
|
|
110
|
+
return GroupedConstraintStream(
|
|
111
|
+
entity_type=self.entity_type,
|
|
112
|
+
score_family=self.score_family,
|
|
113
|
+
pre_filters=list(self.filters),
|
|
114
|
+
group_key=key,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def balance(self, key: Callable[[object], object]) -> BalanceConstraintStream:
|
|
118
|
+
return BalanceConstraintStream(
|
|
119
|
+
entity_type=self.entity_type,
|
|
120
|
+
score_family=self.score_family,
|
|
121
|
+
pre_filters=list(self.filters),
|
|
122
|
+
balance_key=key,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def named(self, name: str) -> ConstraintPlan:
|
|
126
|
+
return ConstraintPlan(
|
|
127
|
+
entity_type=self.entity_type,
|
|
128
|
+
score_family=self.score_family,
|
|
129
|
+
filters=tuple(self.filters),
|
|
130
|
+
impact=self.impact or "penalty",
|
|
131
|
+
weight=self.weight if self.weight is not None else 1,
|
|
132
|
+
name=name,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class BiConstraintStream:
|
|
138
|
+
entity_type: type[object]
|
|
139
|
+
score_family: str
|
|
140
|
+
right_entity_type: type[object]
|
|
141
|
+
left_filters: list[Callable[..., bool]] = field(default_factory=list)
|
|
142
|
+
right_filters: list[Callable[..., bool]] = field(default_factory=list)
|
|
143
|
+
filters: list[Callable[..., bool]] = field(default_factory=list)
|
|
144
|
+
joiners: list[object] = field(default_factory=list)
|
|
145
|
+
impact: str | None = None
|
|
146
|
+
weight: object | None = None
|
|
147
|
+
|
|
148
|
+
def filter(self, predicate: Callable[..., bool]) -> Self:
|
|
149
|
+
self.filters.append(predicate)
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def penalize(self, weight: object) -> Self:
|
|
153
|
+
self.impact = "penalty"
|
|
154
|
+
self.weight = weight
|
|
155
|
+
return self
|
|
156
|
+
|
|
157
|
+
def reward(self, weight: object) -> Self:
|
|
158
|
+
self.impact = "reward"
|
|
159
|
+
self.weight = weight
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
def named(self, name: str) -> ConstraintPlan:
|
|
163
|
+
return ConstraintPlan(
|
|
164
|
+
entity_type=self.entity_type,
|
|
165
|
+
score_family=self.score_family,
|
|
166
|
+
arity=2,
|
|
167
|
+
left_filters=tuple(self.left_filters),
|
|
168
|
+
right_filters=tuple(self.right_filters),
|
|
169
|
+
filters=tuple(self.filters),
|
|
170
|
+
impact=self.impact or "penalty",
|
|
171
|
+
weight=self.weight if self.weight is not None else 1,
|
|
172
|
+
name=name,
|
|
173
|
+
right_entity_type=self.right_entity_type,
|
|
174
|
+
joiners=tuple(self.joiners),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass
|
|
179
|
+
class GroupedConstraintStream:
|
|
180
|
+
entity_type: type[object]
|
|
181
|
+
score_family: str
|
|
182
|
+
group_key: Callable[[object], object]
|
|
183
|
+
pre_filters: list[Callable[..., bool]] = field(default_factory=list)
|
|
184
|
+
filters: list[Callable[..., bool]] = field(default_factory=list)
|
|
185
|
+
impact: str | None = None
|
|
186
|
+
weight: object | None = None
|
|
187
|
+
|
|
188
|
+
def filter(self, predicate: Callable[..., bool]) -> Self:
|
|
189
|
+
self.filters.append(predicate)
|
|
190
|
+
return self
|
|
191
|
+
|
|
192
|
+
def penalize(self, weight: object) -> Self:
|
|
193
|
+
self.impact = "penalty"
|
|
194
|
+
self.weight = weight
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
def reward(self, weight: object) -> Self:
|
|
198
|
+
self.impact = "reward"
|
|
199
|
+
self.weight = weight
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
def named(self, name: str) -> ConstraintPlan:
|
|
203
|
+
return ConstraintPlan(
|
|
204
|
+
entity_type=self.entity_type,
|
|
205
|
+
score_family=self.score_family,
|
|
206
|
+
filters=tuple(self.pre_filters),
|
|
207
|
+
group_filters=tuple(self.filters),
|
|
208
|
+
impact=self.impact or "penalty",
|
|
209
|
+
weight=self.weight if self.weight is not None else 1,
|
|
210
|
+
name=name,
|
|
211
|
+
group_key=self.group_key,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass
|
|
216
|
+
class BalanceConstraintStream:
|
|
217
|
+
entity_type: type[object]
|
|
218
|
+
score_family: str
|
|
219
|
+
balance_key: Callable[[object], object]
|
|
220
|
+
pre_filters: list[Callable[..., bool]] = field(default_factory=list)
|
|
221
|
+
filters: list[Callable[..., bool]] = field(default_factory=list)
|
|
222
|
+
impact: str | None = None
|
|
223
|
+
weight: object | None = None
|
|
224
|
+
|
|
225
|
+
def filter(self, predicate: Callable[..., bool]) -> Self:
|
|
226
|
+
self.filters.append(predicate)
|
|
227
|
+
return self
|
|
228
|
+
|
|
229
|
+
def penalize(self, weight: object) -> Self:
|
|
230
|
+
self.impact = "penalty"
|
|
231
|
+
self.weight = weight
|
|
232
|
+
return self
|
|
233
|
+
|
|
234
|
+
def reward(self, weight: object) -> Self:
|
|
235
|
+
self.impact = "reward"
|
|
236
|
+
self.weight = weight
|
|
237
|
+
return self
|
|
238
|
+
|
|
239
|
+
def named(self, name: str) -> ConstraintPlan:
|
|
240
|
+
return ConstraintPlan(
|
|
241
|
+
entity_type=self.entity_type,
|
|
242
|
+
score_family=self.score_family,
|
|
243
|
+
filters=tuple(self.pre_filters + self.filters),
|
|
244
|
+
impact=self.impact or "penalty",
|
|
245
|
+
weight=self.weight if self.weight is not None else 1,
|
|
246
|
+
name=name,
|
|
247
|
+
balance_key=self.balance_key,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class ConstraintFactory:
|
|
252
|
+
def __init__(self, *, score_family: str = "hard_soft") -> None:
|
|
253
|
+
self.score_family = score_family
|
|
254
|
+
|
|
255
|
+
def for_each(self, entity_type: type[object]) -> UniConstraintStream:
|
|
256
|
+
return UniConstraintStream(entity_type=entity_type, score_family=self.score_family)
|
|
257
|
+
|
|
258
|
+
def join(self, *_args: Any, **_kwargs: Any) -> None:
|
|
259
|
+
raise NotImplementedError("dynamic join is implemented in the native stream planner")
|
|
260
|
+
|
|
261
|
+
def if_exists(self, *_args: Any, **_kwargs: Any) -> None:
|
|
262
|
+
raise NotImplementedError("dynamic if_exists is implemented in the native stream planner")
|
|
263
|
+
|
|
264
|
+
def if_not_exists(self, *_args: Any, **_kwargs: Any) -> None:
|
|
265
|
+
raise NotImplementedError("dynamic if_not_exists is implemented in the native stream planner")
|
|
266
|
+
|
|
267
|
+
def group_by(self, *_args: Any, **_kwargs: Any) -> None:
|
|
268
|
+
raise NotImplementedError("dynamic group_by is implemented in the native stream planner")
|
|
269
|
+
|
|
270
|
+
def flattened(self, *_args: Any, **_kwargs: Any) -> None:
|
|
271
|
+
raise NotImplementedError("dynamic flattened is implemented in the native stream planner")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def joiner_to_native(joiner: object) -> object:
|
|
275
|
+
if hasattr(joiner, "to_native"):
|
|
276
|
+
native = joiner.to_native()
|
|
277
|
+
if isinstance(native, dict):
|
|
278
|
+
return native
|
|
279
|
+
return joiner
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _callback_weight_placeholder(score_family: str) -> dict[str, object]:
|
|
283
|
+
match score_family:
|
|
284
|
+
case "soft":
|
|
285
|
+
return {"family": "soft", "levels": [1]}
|
|
286
|
+
case "hard_soft":
|
|
287
|
+
return {"family": "hard_soft", "levels": [1, 0]}
|
|
288
|
+
case "hard_soft_decimal":
|
|
289
|
+
return {"family": "hard_soft_decimal", "levels": [1, 0]}
|
|
290
|
+
case "hard_medium_soft":
|
|
291
|
+
return {"family": "hard_medium_soft", "levels": [1, 0, 0]}
|
|
292
|
+
case _:
|
|
293
|
+
return score_to_native(1)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import TypeVar
|
|
5
|
+
|
|
6
|
+
from .fields import PlanningField
|
|
7
|
+
from .score import HardSoftScore, score_family
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T", bound=type[object])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _collect_fields(cls: type[object]) -> list[dict[str, object]]:
|
|
13
|
+
fields: list[dict[str, object]] = []
|
|
14
|
+
for name, value in vars(cls).items():
|
|
15
|
+
if isinstance(value, PlanningField):
|
|
16
|
+
fields.append(
|
|
17
|
+
{
|
|
18
|
+
"name": name,
|
|
19
|
+
"kind": value.metadata.kind,
|
|
20
|
+
"value_range_provider": value.metadata.value_range_provider,
|
|
21
|
+
"allows_unassigned": value.metadata.allows_unassigned,
|
|
22
|
+
"element_collection": value.metadata.element_collection,
|
|
23
|
+
"pinning": value.metadata.pinning,
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
return fields
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def planning_entity(cls: T) -> T:
|
|
30
|
+
setattr(
|
|
31
|
+
cls,
|
|
32
|
+
"__solverforge_entity__",
|
|
33
|
+
{
|
|
34
|
+
"type_name": cls.__name__,
|
|
35
|
+
"fields": _collect_fields(cls),
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
return cls
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def problem_fact(cls: T) -> T:
|
|
42
|
+
setattr(
|
|
43
|
+
cls,
|
|
44
|
+
"__solverforge_fact__",
|
|
45
|
+
{
|
|
46
|
+
"type_name": cls.__name__,
|
|
47
|
+
"fields": _collect_fields(cls),
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
return cls
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def constraint_provider(fn: Callable[..., object]) -> Callable[..., object]:
|
|
54
|
+
setattr(fn, "__solverforge_constraint_provider__", True)
|
|
55
|
+
return fn
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def scalar_group(name: str) -> Callable[[Callable[..., object]], Callable[..., object]]:
|
|
59
|
+
def decorate(fn: Callable[..., object]) -> Callable[..., object]:
|
|
60
|
+
setattr(fn, "__solverforge_scalar_group__", {"name": name})
|
|
61
|
+
return fn
|
|
62
|
+
|
|
63
|
+
return decorate
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def conflict_repair(
|
|
67
|
+
*constraint_names: str,
|
|
68
|
+
) -> Callable[[Callable[..., object]], Callable[..., object]]:
|
|
69
|
+
def decorate(fn: Callable[..., object]) -> Callable[..., object]:
|
|
70
|
+
setattr(fn, "__solverforge_conflict_repair__", {"constraints": list(constraint_names)})
|
|
71
|
+
return fn
|
|
72
|
+
|
|
73
|
+
return decorate
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def planning_solution(
|
|
77
|
+
*,
|
|
78
|
+
score: type[object] = HardSoftScore,
|
|
79
|
+
constraints: Callable[..., object] | None = None,
|
|
80
|
+
scalar_groups: list[Callable[..., object]] | None = None,
|
|
81
|
+
conflict_repairs: list[Callable[..., object]] | None = None,
|
|
82
|
+
) -> Callable[[T], T]:
|
|
83
|
+
def decorate(cls: T) -> T:
|
|
84
|
+
setattr(
|
|
85
|
+
cls,
|
|
86
|
+
"__solverforge_solution__",
|
|
87
|
+
{
|
|
88
|
+
"type_name": cls.__name__,
|
|
89
|
+
"score_family": score_family(score),
|
|
90
|
+
"constraints": constraints,
|
|
91
|
+
"scalar_groups": list(scalar_groups or []),
|
|
92
|
+
"conflict_repairs": list(conflict_repairs or []),
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
return cls
|
|
96
|
+
|
|
97
|
+
return decorate
|
solverforge/errors.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class SolverForgeError(Exception):
|
|
2
|
+
"""Base class for SolverForge Python binding errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ModelValidationError(SolverForgeError):
|
|
6
|
+
"""Raised when a Python model cannot be mapped to SolverForge metadata."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CallbackError(SolverForgeError):
|
|
10
|
+
"""Raised when a user callback fails during solving or scoring."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NativeBridgeError(SolverForgeError):
|
|
14
|
+
"""Raised when the PyO3 bridge reports a native-side failure."""
|
|
15
|
+
|
solverforge/events.py
ADDED