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.
@@ -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
@@ -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,7 @@
1
+ from __future__ import annotations
2
+
3
+ from . import _native
4
+
5
+
6
+ def init() -> None:
7
+ _native.init_console()
@@ -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
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class SolverEvent:
8
+ job_id: int
9
+ event_sequence: int
10
+ lifecycle_state: str
11
+ snapshot_revision: int | None = None
12
+ error: str | None = None
13
+