solver-support 0.4.0__tar.gz → 0.4.1__tar.gz
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.
- {solver_support-0.4.0/solver_support.egg-info → solver_support-0.4.1}/PKG-INFO +2 -2
- {solver_support-0.4.0 → solver_support-0.4.1}/README.md +1 -1
- {solver_support-0.4.0 → solver_support-0.4.1}/docs/timing-model.md +20 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/pyproject.toml +1 -1
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/horizon_utils.py +35 -10
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/model_registry.py +68 -12
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/solver_horizon.py +110 -22
- solver_support-0.4.1/solver_support/starter/models.yaml +189 -0
- {solver_support-0.4.0 → solver_support-0.4.1/solver_support.egg-info}/PKG-INFO +2 -2
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support.egg-info/SOURCES.txt +1 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/tests/fixtures/models.yaml +26 -0
- solver_support-0.4.1/tests/test_horizon_params.py +139 -0
- solver_support-0.4.1/tests/test_starter_config.py +160 -0
- solver_support-0.4.0/solver_support/starter/models.yaml +0 -144
- solver_support-0.4.0/tests/test_starter_config.py +0 -85
- {solver_support-0.4.0 → solver_support-0.4.1}/LICENSE +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/MANIFEST.in +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/docs/injection-api.md +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/docs/solver-runner.md +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/setup.cfg +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/__init__.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/command_builders.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/file_management.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/global_state.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/objective.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/run_summary.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/solver_base.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/solver_enhsp.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/solver_fd.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/solver_runner.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/solver_stats.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/solver_upfplan.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/utilities.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support.egg-info/dependency_links.txt +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support.egg-info/not-zip-safe +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support.egg-info/requires.txt +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/solver_support.egg-info/top_level.txt +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/tests/conftest.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_command_builders.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_fd_verdict.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_helpers.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_horizon_utils.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_import.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_proves_unsat.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_registry_dump.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_run_summary.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_run_summary_success.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_savilerow_info_recording.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_solution_naming.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_solver_runner.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_solver_stats.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_solvers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: solver-support
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Generic Conjure / Savile Row / Fast Downward / SymK / ENHSP / UPF solver pipeline
|
|
5
5
|
Author: András Salamon
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -75,7 +75,7 @@ runtime dependencies are `psutil` and `PyYAML`.
|
|
|
75
75
|
## Quick start
|
|
76
76
|
|
|
77
77
|
The package ships no model registry, but it carries a commented starter one
|
|
78
|
-
(Fast Downward, SymK
|
|
78
|
+
(Conjure, Savile Row, Fast Downward, SymK and ENHSP) to copy and edit. Writing it out
|
|
79
79
|
does not wire it; you point the registry at it:
|
|
80
80
|
|
|
81
81
|
```python
|
|
@@ -47,7 +47,7 @@ runtime dependencies are `psutil` and `PyYAML`.
|
|
|
47
47
|
## Quick start
|
|
48
48
|
|
|
49
49
|
The package ships no model registry, but it carries a commented starter one
|
|
50
|
-
(Fast Downward, SymK
|
|
50
|
+
(Conjure, Savile Row, Fast Downward, SymK and ENHSP) to copy and edit. Writing it out
|
|
51
51
|
does not wire it; you point the registry at it:
|
|
52
52
|
|
|
53
53
|
```python
|
|
@@ -70,6 +70,18 @@ fields. All times are seconds unless noted.
|
|
|
70
70
|
### Savile Row (Conjure / Essence Prime models)
|
|
71
71
|
|
|
72
72
|
- **Shape**: a horizon scan — one `solving-horizon-N` phase per move budget.
|
|
73
|
+
Each horizon reaches the model as `letting <name> be N`, appended to the
|
|
74
|
+
instance for every name in the model's `horizon-params` (or its solver's).
|
|
75
|
+
These must be exactly the parameters the model declares, since Conjure
|
|
76
|
+
rejects a `letting` for one it lacks. A config without the key gets the
|
|
77
|
+
historical names, `noSteps` (Savile Row) or `maxMoves` + `horizon` (Conjure),
|
|
78
|
+
so models written for those keep working. Only `letting` lines for those names
|
|
79
|
+
are dropped from the instance first, not any line that mentions one.
|
|
80
|
+
- **Which Savile Row entry**: Conjure only refines and translates. Each horizon
|
|
81
|
+
is tailored and solved by the Savile Row entry the `conjure` config names
|
|
82
|
+
(`savilerow: <entry>`), and an Essence run's `--tune` picks among that
|
|
83
|
+
entry's presets. A direct Savile Row scan uses the run's own solver. Without
|
|
84
|
+
either, it is `kissat`, the name older configs use.
|
|
73
85
|
- **Split source**: Savile Row writes `<param>.info` per horizon;
|
|
74
86
|
`_record_savilerow_times` (`solver_horizon.py`) reads `SavileRowTotalTime`
|
|
75
87
|
(tailoring) → `savilerow_time` and `SolverTotalTime` (backend search) →
|
|
@@ -176,6 +188,14 @@ So size the external kill with `savilerow_backstop(T, S)` — `T + S` plus grace
|
|
|
176
188
|
for the flush — and it comes to mean "the toolchain ignored its limits", a
|
|
177
189
|
genuine fault rather than an ordinary and lossy way for a run to end.
|
|
178
190
|
|
|
191
|
+
The built-in horizon scans do this per horizon when asked:
|
|
192
|
+
`SavileRowSolver.solve(..., backstop=True)` and `ConjureSolver.solve(...,
|
|
193
|
+
backstop=True)` kill each horizon at `savilerow_backstop(R)`, where `R` is the
|
|
194
|
+
budget that horizon's command was built with. Without it, each horizon is
|
|
195
|
+
killed at the runner's own timeout, as before. It is opt-in because a scan
|
|
196
|
+
killed at the backstop can overrun the runner's timeout by up to about that
|
|
197
|
+
timeout again, which would change the run times of an existing consumer.
|
|
198
|
+
|
|
179
199
|
**To hold a run to a wall-clock budget, shrink the pair, not the backstop.**
|
|
180
200
|
Give the solver `S = T - (what tailoring will cost)`. The core picks no reserve
|
|
181
201
|
policy: a horizon scan can estimate from the previous horizon's
|
|
@@ -20,7 +20,7 @@ name = "solver-support"
|
|
|
20
20
|
# escape hatch for a version that genuinely cannot be known without building.
|
|
21
21
|
# Ours can. Carrying a `.devN` suffix between releases keeps the working tree
|
|
22
22
|
# from claiming a version that is already tagged and published.
|
|
23
|
-
version = "0.4.
|
|
23
|
+
version = "0.4.1"
|
|
24
24
|
description = "Generic Conjure / Savile Row / Fast Downward / SymK / ENHSP / UPF solver pipeline"
|
|
25
25
|
readme = "README.md"
|
|
26
26
|
requires-python = ">=3.9"
|
|
@@ -6,10 +6,11 @@ that can be used by different solver types (Savile Row, Conjure, etc.).
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
|
+
import re
|
|
9
10
|
import shutil
|
|
10
11
|
import time
|
|
11
12
|
from pathlib import Path
|
|
12
|
-
from typing import Optional, Callable, Tuple
|
|
13
|
+
from typing import Optional, Callable, Sequence, Tuple
|
|
13
14
|
|
|
14
15
|
from .file_management import SolutionFileNamer
|
|
15
16
|
from .global_state import global_state
|
|
@@ -76,6 +77,20 @@ def _create_main_solution_from_best(runner: SolverRunner, best_solution_found: P
|
|
|
76
77
|
return main_solution
|
|
77
78
|
|
|
78
79
|
|
|
80
|
+
#: Every name a horizon scan has ever written, for a caller that does not say
|
|
81
|
+
#: which it writes. Only `letting` lines for these are dropped from the instance;
|
|
82
|
+
#: the scan's callback then appends its own for the horizon at hand.
|
|
83
|
+
LEGACY_HORIZON_PARAMS = ('noSteps', 'maxMoves', 'horizon')
|
|
84
|
+
|
|
85
|
+
_LETTING_NAME_RE = re.compile(r'^\s*letting\s+(\w+)\b')
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_horizon_letting(line: str, names: Sequence[str]) -> bool:
|
|
89
|
+
"""Whether ``line`` sets one of the horizon parameters ``names``."""
|
|
90
|
+
m = _LETTING_NAME_RE.match(line)
|
|
91
|
+
return bool(m) and m.group(1) in names
|
|
92
|
+
|
|
93
|
+
|
|
79
94
|
def horizon_search(runner: SolverRunner,
|
|
80
95
|
param_file: Path,
|
|
81
96
|
startat: int,
|
|
@@ -83,7 +98,8 @@ def horizon_search(runner: SolverRunner,
|
|
|
83
98
|
logger: logging.Logger,
|
|
84
99
|
min_horizon: int = 1,
|
|
85
100
|
stopat: int = 100,
|
|
86
|
-
extra_constraints_callback: Optional[Callable[[Path, int], None]] = None
|
|
101
|
+
extra_constraints_callback: Optional[Callable[[Path, int], None]] = None,
|
|
102
|
+
horizon_params: Optional[Sequence[str]] = None) -> Optional[Path]:
|
|
87
103
|
"""
|
|
88
104
|
Generic horizon-extending search loop.
|
|
89
105
|
|
|
@@ -97,6 +113,9 @@ def horizon_search(runner: SolverRunner,
|
|
|
97
113
|
min_horizon: Minimum allowed horizon value
|
|
98
114
|
stopat: Highest horizon to try (inclusive upper limit of the scan)
|
|
99
115
|
extra_constraints_callback: Optional function(horizon_param_file, n) to add extra constraints
|
|
116
|
+
horizon_params: The parameter names the callback sets to the horizon, so
|
|
117
|
+
an instance's own value for them is dropped first. None means
|
|
118
|
+
LEGACY_HORIZON_PARAMS.
|
|
100
119
|
|
|
101
120
|
Returns:
|
|
102
121
|
Path to solution file if found, None otherwise
|
|
@@ -137,7 +156,8 @@ def horizon_search(runner: SolverRunner,
|
|
|
137
156
|
return None
|
|
138
157
|
|
|
139
158
|
# Create parameter file with horizon constraints
|
|
140
|
-
horizon_param_file = create_horizon_param_file(
|
|
159
|
+
horizon_param_file = create_horizon_param_file(
|
|
160
|
+
param_file, output_dir, n, logger, runner, horizon_params=horizon_params)
|
|
141
161
|
if not horizon_param_file:
|
|
142
162
|
continue
|
|
143
163
|
|
|
@@ -200,7 +220,8 @@ def horizon_search(runner: SolverRunner,
|
|
|
200
220
|
return None
|
|
201
221
|
|
|
202
222
|
|
|
203
|
-
def create_horizon_param_file(param_file: Path, output_dir: Path, n: int, logger: logging.Logger, runner: SolverRunner = None
|
|
223
|
+
def create_horizon_param_file(param_file: Path, output_dir: Path, n: int, logger: logging.Logger, runner: SolverRunner = None,
|
|
224
|
+
horizon_params: Optional[Sequence[str]] = None) -> Optional[Path]:
|
|
204
225
|
"""
|
|
205
226
|
Create parameter file with base content, ready for horizon-specific constraints.
|
|
206
227
|
|
|
@@ -210,6 +231,9 @@ def create_horizon_param_file(param_file: Path, output_dir: Path, n: int, logger
|
|
|
210
231
|
n: Horizon value
|
|
211
232
|
logger: Logger instance
|
|
212
233
|
runner: SolverRunner instance for consistent naming (optional for backward compatibility)
|
|
234
|
+
horizon_params: Names whose `letting` lines are dropped from the
|
|
235
|
+
instance, since the caller sets them per horizon. None means
|
|
236
|
+
LEGACY_HORIZON_PARAMS.
|
|
213
237
|
|
|
214
238
|
Returns:
|
|
215
239
|
Path to created parameter file, or None if creation failed
|
|
@@ -233,12 +257,13 @@ def create_horizon_param_file(param_file: Path, output_dir: Path, n: int, logger
|
|
|
233
257
|
with open(param_file, 'r') as f:
|
|
234
258
|
param_content = f.read()
|
|
235
259
|
|
|
236
|
-
#
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
260
|
+
# Drop the instance's own value for each horizon parameter: the caller
|
|
261
|
+
# appends one per horizon, and two lettings of a name is an error. Only
|
|
262
|
+
# a `letting` of exactly that name goes - a line that merely mentions
|
|
263
|
+
# it (a comment, `letting horizonLimit`) is the instance's own.
|
|
264
|
+
names = LEGACY_HORIZON_PARAMS if horizon_params is None else tuple(horizon_params)
|
|
265
|
+
param_lines = [line for line in param_content.splitlines()
|
|
266
|
+
if not _is_horizon_letting(line, names)]
|
|
242
267
|
|
|
243
268
|
# Write base parameter file
|
|
244
269
|
with open(horizon_param_file, 'w') as f:
|
|
@@ -8,12 +8,20 @@ from the original bash run script.
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from pathlib import Path
|
|
11
|
-
from typing import Dict, Any, Optional, List, Tuple, Callable
|
|
11
|
+
from typing import Dict, Any, Optional, List, Tuple, Callable, Sequence
|
|
12
12
|
import copy
|
|
13
|
+
import os
|
|
13
14
|
import shutil
|
|
14
15
|
import yaml
|
|
15
16
|
|
|
16
17
|
|
|
18
|
+
#: The Savile Row entry a Conjure solver delegates to when its config does not
|
|
19
|
+
#: name one (``savilerow:``), and the entry a Savile Row horizon scan uses when
|
|
20
|
+
#: neither the caller nor the run names one. Kept for configs written before
|
|
21
|
+
#: those could be named; a new config should name its own.
|
|
22
|
+
LEGACY_CONJURE_SAVILEROW = 'kissat'
|
|
23
|
+
|
|
24
|
+
|
|
17
25
|
class ModelRegistryError(Exception):
|
|
18
26
|
"""Exception raised for model registry errors."""
|
|
19
27
|
pass
|
|
@@ -43,10 +51,13 @@ _user_config_finder: Optional[Callable[[], Optional[Path]]] = None
|
|
|
43
51
|
def set_default_config_path(path) -> None:
|
|
44
52
|
"""Register the default ``models.yaml`` the registry loads at startup.
|
|
45
53
|
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
A ``str`` or other path-like becomes a :class:`~pathlib.Path`; anything
|
|
55
|
+
else (an ``importlib.resources`` traversable) is stored as-is, so the loader
|
|
56
|
+
can ``open`` it directly regardless of how the consumer resolved it.
|
|
48
57
|
"""
|
|
49
58
|
global _default_config_path
|
|
59
|
+
if isinstance(path, (str, os.PathLike)):
|
|
60
|
+
path = Path(path)
|
|
50
61
|
_default_config_path = path
|
|
51
62
|
|
|
52
63
|
|
|
@@ -89,9 +100,15 @@ class ModelRegistry(CommandBuilderMixin):
|
|
|
89
100
|
# First, load the default YAML config from package
|
|
90
101
|
default_yaml = self._find_default_yaml_config()
|
|
91
102
|
if not default_yaml:
|
|
103
|
+
if _default_config_path is not None:
|
|
104
|
+
raise ModelRegistryError(
|
|
105
|
+
f"The registered models.yaml ({_default_config_path}) is not "
|
|
106
|
+
"a file."
|
|
107
|
+
)
|
|
92
108
|
raise ModelRegistryError(
|
|
93
|
-
"
|
|
94
|
-
"
|
|
109
|
+
"No models.yaml is registered: solver-support ships without one. "
|
|
110
|
+
"Call set_default_config_path() before first use; "
|
|
111
|
+
"write_starter_config() writes a starter to begin from."
|
|
95
112
|
)
|
|
96
113
|
|
|
97
114
|
self.config = {'models': {}, 'solvers': {}}
|
|
@@ -642,6 +659,33 @@ class ModelRegistry(CommandBuilderMixin):
|
|
|
642
659
|
return None, None
|
|
643
660
|
return model_config.get('status'), model_config.get('status-detail')
|
|
644
661
|
|
|
662
|
+
def get_horizon_params(self, model_type: str,
|
|
663
|
+
default: Sequence[str] = ()) -> List[str]:
|
|
664
|
+
"""The parameter names a horizon scan sets to the horizon for a model.
|
|
665
|
+
|
|
666
|
+
Each horizon N reaches the model as ``letting <name> be N`` appended to
|
|
667
|
+
the instance, so these must be exactly the names the model declares:
|
|
668
|
+
Conjure rejects a ``letting`` for a parameter the model does not have.
|
|
669
|
+
Read from the model's ``horizon-params``, then its solver's, then
|
|
670
|
+
``default`` - the route's own historical names, which configs written
|
|
671
|
+
before the key existed rely on. A single name may be given as a string.
|
|
672
|
+
Resolves aliases and never raises (an unknown model gets ``default``).
|
|
673
|
+
"""
|
|
674
|
+
try:
|
|
675
|
+
_, model_config = self.resolve_model_variant(model_type)
|
|
676
|
+
except (ValueError, ModelRegistryError):
|
|
677
|
+
return list(default)
|
|
678
|
+
names = model_config.get('horizon-params')
|
|
679
|
+
if names is None:
|
|
680
|
+
try:
|
|
681
|
+
names = self.get_solver_config(
|
|
682
|
+
model_config.get('solver', '')).get('horizon-params')
|
|
683
|
+
except ModelRegistryError:
|
|
684
|
+
names = None
|
|
685
|
+
if names is None:
|
|
686
|
+
return list(default)
|
|
687
|
+
return [names] if isinstance(names, str) else list(names)
|
|
688
|
+
|
|
645
689
|
def get_model_default_tune(self, model_type: str) -> Optional[str]:
|
|
646
690
|
"""Return a model's preferred ``--tune`` preset, or None.
|
|
647
691
|
|
|
@@ -656,17 +700,29 @@ class ModelRegistry(CommandBuilderMixin):
|
|
|
656
700
|
return None
|
|
657
701
|
return model_config.get('tune')
|
|
658
702
|
|
|
703
|
+
def get_conjure_savilerow(self, solver_name: str = 'conjure') -> str:
|
|
704
|
+
"""The Savile Row entry a Conjure solver hands each horizon's solve to.
|
|
705
|
+
|
|
706
|
+
Conjure only refines and translates. Savile Row then tailors the
|
|
707
|
+
instance and runs its backend, under the solver entry the ``conjure``
|
|
708
|
+
config names with ``savilerow:``. A config without the key gets
|
|
709
|
+
``kissat``, the name configs written before the key existed use.
|
|
710
|
+
"""
|
|
711
|
+
return self.get_solver_config(solver_name).get(
|
|
712
|
+
'savilerow', LEGACY_CONJURE_SAVILEROW)
|
|
713
|
+
|
|
659
714
|
def get_tune_backend(self, solver_name: str) -> str:
|
|
660
715
|
"""Return the backend whose tune presets govern ``--tune`` for a solver.
|
|
661
716
|
|
|
662
717
|
Almost always the solver itself. The exception is the Conjure/Essence
|
|
663
|
-
path: its per-horizon inner solve is delegated to
|
|
664
|
-
|
|
665
|
-
the tune-less ``conjure`` entry's. Keeping this
|
|
666
|
-
CLI validity check and ``build_*_command`` agree
|
|
718
|
+
path: its per-horizon inner solve is delegated to a Savile Row entry
|
|
719
|
+
(:meth:`get_conjure_savilerow`), so an Essence run's ``--tune`` presets
|
|
720
|
+
are that entry's, not the tune-less ``conjure`` entry's. Keeping this
|
|
721
|
+
mapping here means the CLI validity check and ``build_*_command`` agree
|
|
722
|
+
on what a tune means.
|
|
667
723
|
"""
|
|
668
724
|
if self.get_solver_type(solver_name) == 'conjure':
|
|
669
|
-
return
|
|
725
|
+
return self.get_conjure_savilerow(solver_name)
|
|
670
726
|
return solver_name
|
|
671
727
|
|
|
672
728
|
def get_valid_tunes(self, solver_name: str) -> List[str]:
|
|
@@ -828,8 +884,8 @@ class ModelRegistry(CommandBuilderMixin):
|
|
|
828
884
|
def starter_config_text() -> str:
|
|
829
885
|
"""The starter ``models.yaml`` shipped with the package, as text.
|
|
830
886
|
|
|
831
|
-
A commented example registry - Fast Downward, SymK
|
|
832
|
-
for a new user to copy and edit. It is never loaded implicitly: the core
|
|
887
|
+
A commented example registry - Conjure, Savile Row, Fast Downward, SymK
|
|
888
|
+
and ENHSP - for a new user to copy and edit. It is never loaded implicitly: the core
|
|
833
889
|
still ships unwired, and :class:`ModelRegistry` still raises until a config
|
|
834
890
|
path is set. Unlike :meth:`ModelRegistry.dump_config` it keeps its comments,
|
|
835
891
|
which is what makes it fit to start from.
|
|
@@ -10,7 +10,7 @@ import os
|
|
|
10
10
|
import shutil
|
|
11
11
|
import time
|
|
12
12
|
from pathlib import Path
|
|
13
|
-
from typing import Optional, List
|
|
13
|
+
from typing import Optional, List, Tuple
|
|
14
14
|
|
|
15
15
|
from .solver_stats import SolverStatsExtractor
|
|
16
16
|
from .solver_runner import SolverExecutableError
|
|
@@ -18,19 +18,90 @@ from .global_state import global_state
|
|
|
18
18
|
from .file_management import SolutionFileNamer, ObjectiveFileManager
|
|
19
19
|
from .solver_base import Solver
|
|
20
20
|
from .horizon_utils import horizon_search, calculate_horizon_timeout
|
|
21
|
+
from .command_builders import savilerow_backstop
|
|
21
22
|
from .objective import raw_objective_from_solution
|
|
23
|
+
from .model_registry import LEGACY_CONJURE_SAVILEROW
|
|
22
24
|
|
|
23
25
|
|
|
24
26
|
class HorizonBasedSolver(Solver):
|
|
25
27
|
"""Base class for solvers that use horizon search with optimization support."""
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
#: The parameter(s) this route sets to the horizon, for a model whose
|
|
30
|
+
#: registry entry names none (``horizon-params``). Kept for configs written
|
|
31
|
+
#: before the key existed; a new config should name its own.
|
|
32
|
+
DEFAULT_HORIZON_PARAMS: Tuple[str, ...] = ()
|
|
33
|
+
|
|
34
|
+
def __init__(self, solver_runner, solver_name: Optional[str] = None):
|
|
35
|
+
"""Initialize with solver runner and optimization tracking.
|
|
36
|
+
|
|
37
|
+
``solver_name`` is the Savile Row entry that solves each horizon. None
|
|
38
|
+
resolves it from the registry when first needed (see
|
|
39
|
+
:meth:`_default_solver_name`).
|
|
40
|
+
"""
|
|
29
41
|
super().__init__(solver_runner)
|
|
30
42
|
self._best_objective = None
|
|
31
43
|
self._has_timeout = False # Track if we're running with a timeout
|
|
32
44
|
self._stop_at_first = False # regime: first stops the scan at the first solving horizon
|
|
33
|
-
self.
|
|
45
|
+
self._solver_name = solver_name
|
|
46
|
+
self._backstop = False # size each horizon's kill above its in-band limits
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def solver_name(self) -> str:
|
|
50
|
+
"""The Savile Row entry that solves each horizon."""
|
|
51
|
+
if self._solver_name is None:
|
|
52
|
+
return self._default_solver_name()
|
|
53
|
+
return self._solver_name
|
|
54
|
+
|
|
55
|
+
@solver_name.setter
|
|
56
|
+
def solver_name(self, value: Optional[str]) -> None:
|
|
57
|
+
self._solver_name = value
|
|
58
|
+
|
|
59
|
+
def _default_solver_name(self) -> str:
|
|
60
|
+
"""The entry to use when the caller named none: the run's own solver
|
|
61
|
+
if it is a Savile Row entry, else the legacy name."""
|
|
62
|
+
try:
|
|
63
|
+
run_solver = self.runner.summary.solver
|
|
64
|
+
if global_state.model_registry.get_solver_type(run_solver) == 'savilerow':
|
|
65
|
+
return run_solver
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
return LEGACY_CONJURE_SAVILEROW
|
|
69
|
+
|
|
70
|
+
def _horizon_params(self) -> List[str]:
|
|
71
|
+
"""The parameter names this run sets to the horizon (see
|
|
72
|
+
:meth:`ModelRegistry.get_horizon_params`)."""
|
|
73
|
+
try:
|
|
74
|
+
return global_state.model_registry.get_horizon_params(
|
|
75
|
+
self.runner.summary.model, self.DEFAULT_HORIZON_PARAMS)
|
|
76
|
+
except Exception:
|
|
77
|
+
return list(self.DEFAULT_HORIZON_PARAMS)
|
|
78
|
+
|
|
79
|
+
def _horizon_watchdog(self, horizon_timeout: Optional[int]) -> Optional[int]:
|
|
80
|
+
"""The kill for one horizon's Savile Row run, or None for the runner's.
|
|
81
|
+
|
|
82
|
+
With ``backstop=True`` it is :func:`savilerow_backstop` over the same
|
|
83
|
+
budget the command was built with: Savile Row's ``-timelimit`` and the
|
|
84
|
+
solver's own limit each get ``horizon_timeout`` and run one after the
|
|
85
|
+
other, so a kill below their sum can land before Savile Row writes its
|
|
86
|
+
``.info`` and loses every statistic. Off by default, because a scan
|
|
87
|
+
killed at the backstop can overrun the runner's timeout by up to about
|
|
88
|
+
that timeout again.
|
|
89
|
+
"""
|
|
90
|
+
if self._backstop and horizon_timeout is not None:
|
|
91
|
+
return savilerow_backstop(horizon_timeout)
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def _write_horizon(self, horizon_param_file: Path, n: int,
|
|
95
|
+
names: List[str]) -> bool:
|
|
96
|
+
"""Append ``letting <name> be n`` for each horizon parameter."""
|
|
97
|
+
try:
|
|
98
|
+
with open(horizon_param_file, 'a') as f:
|
|
99
|
+
for name in names:
|
|
100
|
+
f.write(f"letting {name} be {n}\n")
|
|
101
|
+
return True
|
|
102
|
+
except Exception as e:
|
|
103
|
+
self.logger.error(f"Failed to add horizon constraints: {e}")
|
|
104
|
+
return False
|
|
34
105
|
|
|
35
106
|
def _generate_objective_file_path(self, n: int) -> Optional[Path]:
|
|
36
107
|
"""Generate the objective file path for a given horizon, or None.
|
|
@@ -339,7 +410,9 @@ class HorizonBasedSolver(Solver):
|
|
|
339
410
|
class SavileRowSolver(HorizonBasedSolver):
|
|
340
411
|
"""Solver implementation for Savile Row with horizon search."""
|
|
341
412
|
|
|
342
|
-
|
|
413
|
+
DEFAULT_HORIZON_PARAMS = ('noSteps',)
|
|
414
|
+
|
|
415
|
+
def __init__(self, solver_runner, solver_name: Optional[str] = None):
|
|
343
416
|
"""Initialize with solver runner and solver name."""
|
|
344
417
|
super().__init__(solver_runner, solver_name)
|
|
345
418
|
|
|
@@ -352,6 +425,10 @@ class SavileRowSolver(HorizonBasedSolver):
|
|
|
352
425
|
- model_path: Path to Essence Prime model file (required)
|
|
353
426
|
- startat: Starting horizon value (default: 1)
|
|
354
427
|
- stopat: Highest horizon to try (default: 100)
|
|
428
|
+
- backstop: kill each horizon at savilerow_backstop of its
|
|
429
|
+
budget rather than at the runner's timeout, so Savile Row's
|
|
430
|
+
own limits fire first and its statistics survive
|
|
431
|
+
(default: False)
|
|
355
432
|
- objbound: Objective bound constraint for optimization models
|
|
356
433
|
Returns:
|
|
357
434
|
Path to solution file if successful, None otherwise
|
|
@@ -363,6 +440,7 @@ class SavileRowSolver(HorizonBasedSolver):
|
|
|
363
440
|
|
|
364
441
|
startat = kwargs.get('startat', 1)
|
|
365
442
|
stopat = kwargs.get('stopat', 100)
|
|
443
|
+
self._backstop = bool(kwargs.get('backstop', False))
|
|
366
444
|
objbound = kwargs.get('objbound')
|
|
367
445
|
|
|
368
446
|
# Setup optimization parameters
|
|
@@ -372,6 +450,7 @@ class SavileRowSolver(HorizonBasedSolver):
|
|
|
372
450
|
# Track total elapsed time for timeout management
|
|
373
451
|
total_start_time = time.time()
|
|
374
452
|
original_timeout = self.runner.timeout
|
|
453
|
+
horizon_params = self._horizon_params()
|
|
375
454
|
|
|
376
455
|
def savilerow_horizon_callback(n: int, horizon_param_file: Path) -> Optional[Path]:
|
|
377
456
|
"""Execute Savile Row for a specific horizon."""
|
|
@@ -386,12 +465,7 @@ class SavileRowSolver(HorizonBasedSolver):
|
|
|
386
465
|
# Remove any existing objective file before this horizon run
|
|
387
466
|
self._prepare_objective_file(obj_file)
|
|
388
467
|
|
|
389
|
-
|
|
390
|
-
try:
|
|
391
|
-
with open(horizon_param_file, 'a') as f:
|
|
392
|
-
f.write(f"letting noSteps be {n}\n")
|
|
393
|
-
except Exception as e:
|
|
394
|
-
self.logger.error(f"Failed to add noSteps constraint: {e}")
|
|
468
|
+
if not self._write_horizon(horizon_param_file, n, horizon_params):
|
|
395
469
|
return None
|
|
396
470
|
|
|
397
471
|
# Build Savile Row command for this horizon using helper method
|
|
@@ -407,7 +481,8 @@ class SavileRowSolver(HorizonBasedSolver):
|
|
|
407
481
|
f"solving-horizon-{n}",
|
|
408
482
|
savilerow_cmd,
|
|
409
483
|
expected_outputs=[str(expected_solution)],
|
|
410
|
-
horizon_number=n
|
|
484
|
+
horizon_number=n,
|
|
485
|
+
timeout=self._horizon_watchdog(horizon_timeout)
|
|
411
486
|
)
|
|
412
487
|
|
|
413
488
|
# Record Savile Row's tailoring vs solver time split from the .info
|
|
@@ -454,13 +529,24 @@ class SavileRowSolver(HorizonBasedSolver):
|
|
|
454
529
|
return horizon_search(
|
|
455
530
|
self.runner, param_file, startat, savilerow_horizon_callback, self.logger,
|
|
456
531
|
stopat=stopat,
|
|
457
|
-
extra_constraints_callback=self._add_objective_constraint if (is_opt_model and continue_scan) else None
|
|
532
|
+
extra_constraints_callback=self._add_objective_constraint if (is_opt_model and continue_scan) else None,
|
|
533
|
+
horizon_params=horizon_params
|
|
458
534
|
)
|
|
459
535
|
|
|
460
536
|
|
|
461
537
|
class ConjureSolver(HorizonBasedSolver):
|
|
462
538
|
"""Solver implementation for Conjure (Essence to EPrime to solution)."""
|
|
463
539
|
|
|
540
|
+
DEFAULT_HORIZON_PARAMS = ('maxMoves', 'horizon')
|
|
541
|
+
|
|
542
|
+
def _default_solver_name(self) -> str:
|
|
543
|
+
"""The Savile Row entry the ``conjure`` config names, else the legacy
|
|
544
|
+
name (see :meth:`ModelRegistry.get_conjure_savilerow`)."""
|
|
545
|
+
try:
|
|
546
|
+
return global_state.model_registry.get_conjure_savilerow()
|
|
547
|
+
except Exception:
|
|
548
|
+
return LEGACY_CONJURE_SAVILEROW
|
|
549
|
+
|
|
464
550
|
def solve(self, param_file: Path, **kwargs) -> Optional[Path]:
|
|
465
551
|
"""
|
|
466
552
|
Execute Conjure solver with horizon search loop for Essence models.
|
|
@@ -470,6 +556,10 @@ class ConjureSolver(HorizonBasedSolver):
|
|
|
470
556
|
- model_path: Path to Essence model file (required)
|
|
471
557
|
- startat: Starting horizon value (default: 1)
|
|
472
558
|
- stopat: Highest horizon to try (default: 100)
|
|
559
|
+
- backstop: kill each horizon at savilerow_backstop of its
|
|
560
|
+
budget rather than at the runner's timeout, so Savile Row's
|
|
561
|
+
own limits fire first and its statistics survive
|
|
562
|
+
(default: False)
|
|
473
563
|
Returns:
|
|
474
564
|
Path to solution file if successful, None otherwise
|
|
475
565
|
"""
|
|
@@ -480,6 +570,7 @@ class ConjureSolver(HorizonBasedSolver):
|
|
|
480
570
|
|
|
481
571
|
startat = kwargs.get('startat', 1)
|
|
482
572
|
stopat = kwargs.get('stopat', 100)
|
|
573
|
+
self._backstop = bool(kwargs.get('backstop', False))
|
|
483
574
|
|
|
484
575
|
# Step 1: Modelling - create EPrime from Essence
|
|
485
576
|
epm_path = self._do_conjure_modelling(str(model_path))
|
|
@@ -493,19 +584,14 @@ class ConjureSolver(HorizonBasedSolver):
|
|
|
493
584
|
# Track total elapsed time for timeout management
|
|
494
585
|
total_start_time = time.time()
|
|
495
586
|
original_timeout = self.runner.timeout
|
|
587
|
+
horizon_params = self._horizon_params()
|
|
496
588
|
|
|
497
589
|
def conjure_horizon_callback(n: int, horizon_param_file: Path) -> Optional[Path]:
|
|
498
590
|
"""Execute Conjure solve and translate for a specific horizon."""
|
|
499
591
|
# Calculate remaining timeout for this horizon step
|
|
500
592
|
horizon_timeout = calculate_horizon_timeout(total_start_time, original_timeout, n, self.logger)
|
|
501
593
|
|
|
502
|
-
|
|
503
|
-
try:
|
|
504
|
-
with open(horizon_param_file, 'a') as f:
|
|
505
|
-
f.write(f"letting maxMoves be {n}\n")
|
|
506
|
-
f.write(f"letting horizon be {n}\n")
|
|
507
|
-
except Exception as e:
|
|
508
|
-
self.logger.error(f"Failed to add horizon constraints: {e}")
|
|
594
|
+
if not self._write_horizon(horizon_param_file, n, horizon_params):
|
|
509
595
|
return None
|
|
510
596
|
|
|
511
597
|
# Execute the solve and translate pipeline
|
|
@@ -541,7 +627,8 @@ class ConjureSolver(HorizonBasedSolver):
|
|
|
541
627
|
self.runner, param_file, startat, conjure_horizon_callback, self.logger,
|
|
542
628
|
min_horizon=2,
|
|
543
629
|
stopat=stopat,
|
|
544
|
-
extra_constraints_callback=self._add_objective_constraint if (is_opt_model and continue_scan) else None
|
|
630
|
+
extra_constraints_callback=self._add_objective_constraint if (is_opt_model and continue_scan) else None,
|
|
631
|
+
horizon_params=horizon_params
|
|
545
632
|
)
|
|
546
633
|
|
|
547
634
|
def _do_conjure_modelling(self, model_path: str) -> Optional[Path]:
|
|
@@ -629,7 +716,8 @@ class ConjureSolver(HorizonBasedSolver):
|
|
|
629
716
|
f"solving-horizon-{n}",
|
|
630
717
|
savilerow_cmd,
|
|
631
718
|
expected_outputs=[str(eps_file)],
|
|
632
|
-
horizon_number=n
|
|
719
|
+
horizon_number=n,
|
|
720
|
+
timeout=self._horizon_watchdog(horizon_timeout)
|
|
633
721
|
)
|
|
634
722
|
|
|
635
723
|
# Record Savile Row's tailoring vs solver time split. Here Savile Row is
|