solver-support 0.2.1__tar.gz → 0.2.2__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.2.1/solver_support.egg-info → solver_support-0.2.2}/PKG-INFO +1 -1
- {solver_support-0.2.1 → solver_support-0.2.2}/docs/timing-model.md +28 -6
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/__init__.py +1 -1
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/model_registry.py +81 -4
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/solver_horizon.py +25 -2
- {solver_support-0.2.1 → solver_support-0.2.2/solver_support.egg-info}/PKG-INFO +1 -1
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support.egg-info/SOURCES.txt +2 -0
- solver_support-0.2.2/tests/test_registry_dump.py +93 -0
- solver_support-0.2.2/tests/test_savilerow_info_recording.py +127 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/LICENSE +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/MANIFEST.in +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/README.md +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/docs/injection-api.md +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/docs/solver-runner.md +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/pyproject.toml +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/setup.cfg +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/command_builders.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/file_management.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/global_state.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/horizon_utils.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/objective.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/run_summary.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/solver_base.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/solver_enhsp.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/solver_fd.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/solver_runner.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/solver_stats.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/solver_upfplan.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/utilities.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support.egg-info/dependency_links.txt +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support.egg-info/not-zip-safe +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support.egg-info/requires.txt +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/solver_support.egg-info/top_level.txt +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/tests/conftest.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/tests/fixtures/models.yaml +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_command_builders.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_helpers.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_horizon_utils.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_import.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_run_summary.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_run_summary_success.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_solution_naming.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_solver_runner.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_solver_stats.py +0 -0
- {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_solvers.py +0 -0
|
@@ -76,12 +76,34 @@ fields. All times are seconds unless noted.
|
|
|
76
76
|
`solver_time`, attaching them to the matching phase. Recorded for *every*
|
|
77
77
|
horizon, so intermediate UNSAT horizons carry the split too.
|
|
78
78
|
- **The rest of the file**: those two are what the phase model needs, but the
|
|
79
|
-
same `.info` carries the encoding size
|
|
80
|
-
|
|
81
|
-
`
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
same `.info` carries the encoding size, the search counters, and the outcome
|
|
80
|
+
flags. All of it is recorded verbatim on the phase under
|
|
81
|
+
`solver_stats['savilerow_info']`, so a consumer never has to go back to the
|
|
82
|
+
`.info` files — which matters because that would depend on the run's output
|
|
83
|
+
directory rather than its summary, and so stops working once those files are
|
|
84
|
+
cleaned up. `SolverStatsExtractor.extract_savilerow_info(path)` is the parser
|
|
85
|
+
(keys verbatim, values coerced to int/float, `{}` for a missing file);
|
|
86
|
+
`extract_savilerow_info_times` is the renaming subset that produces the two
|
|
87
|
+
fields above.
|
|
88
|
+
- **Nested, not flattened, and deliberately not curated.** `solver_stats` is the
|
|
89
|
+
core's normalised cross-solver vocabulary; `savilerow_info` is one solver's own
|
|
90
|
+
keys in its own spelling, so nesting keeps CamelCase off the snake_case
|
|
91
|
+
namespace. It is verbatim because the backends do not agree on which keys
|
|
92
|
+
exist — measured on one model:
|
|
93
|
+
|
|
94
|
+
| key | minion | ortools | gecode | chuffed | kissat |
|
|
95
|
+
|---|:-:|:-:|:-:|:-:|:-:|
|
|
96
|
+
| `SavileRowTotalTime` `SolverTotalTime` `SolverSatisfiable` `SolverTimeOut` `SavileRowTimeOut` `SavileRowClauseOut` | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
97
|
+
| `SolverNodes` | ✓ | — | ✓ | ✓ | ✓ |
|
|
98
|
+
| `SolverFailures` `SolverTotalWallTime` | — | ✓ | ✓ | ✓ | — |
|
|
99
|
+
| `SolverSetupTime` `SolverSolveTime` `SolverSolutionsFound` | ✓ | — | — | — | — |
|
|
100
|
+
| `SATVars` `SATClauses` `SolverMemOut` | — | — | — | — | ✓ |
|
|
101
|
+
|
|
102
|
+
Any allow-list would already be wrong, and would need extending for every new
|
|
103
|
+
backend or Savile Row version. Note `SolverSatisfiable` is one of the six every
|
|
104
|
+
backend emits: it distinguishes a **refuted horizon** from one that gave up —
|
|
105
|
+
which is *not* problem-level unsat, since a bounded model cannot prove that
|
|
106
|
+
(see `hlim`, above).
|
|
85
107
|
- **Best-effort**: a hard timeout can kill Savile Row before it writes `.info`,
|
|
86
108
|
leaving the split absent (`None`).
|
|
87
109
|
|
|
@@ -13,7 +13,7 @@ unconfigured pipeline. A consumer registers its scoring (see :mod:`objective`)
|
|
|
13
13
|
and its packaging paths (see :mod:`model_registry`) once at its own import time.
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
-
__version__ = "0.2.
|
|
16
|
+
__version__ = "0.2.2"
|
|
17
17
|
|
|
18
18
|
# --- model registry (loader + command builders + packaging injection) ---
|
|
19
19
|
from .model_registry import (
|
|
@@ -9,6 +9,7 @@ from the original bash run script.
|
|
|
9
9
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Dict, Any, Optional, List, Tuple, Callable
|
|
12
|
+
import copy
|
|
12
13
|
import shutil
|
|
13
14
|
import yaml
|
|
14
15
|
|
|
@@ -95,8 +96,14 @@ class ModelRegistry(CommandBuilderMixin):
|
|
|
95
96
|
|
|
96
97
|
self.config = {'models': {}, 'solvers': {}}
|
|
97
98
|
self._savilerow_native_warning_shown = False # Track if we've shown the warning
|
|
99
|
+
# Every file merged in, in the order merged, so a caller can report what
|
|
100
|
+
# actually contributed. Without this the answer is only reconstructible
|
|
101
|
+
# by re-running the same two searches by hand - and the user-override one
|
|
102
|
+
# depends on the cwd at construction time, so it is not reproducible
|
|
103
|
+
# later. See `dump_config`.
|
|
104
|
+
self.config_sources: List[Path] = []
|
|
98
105
|
self._load_yaml_config(default_yaml)
|
|
99
|
-
|
|
106
|
+
|
|
100
107
|
# Then, load user configuration to override defaults
|
|
101
108
|
user_yaml = config_path or self._find_user_yaml_config()
|
|
102
109
|
if user_yaml:
|
|
@@ -145,10 +152,11 @@ class ModelRegistry(CommandBuilderMixin):
|
|
|
145
152
|
try:
|
|
146
153
|
with open(config_path, 'r') as f:
|
|
147
154
|
user_config = yaml.safe_load(f)
|
|
148
|
-
|
|
155
|
+
|
|
149
156
|
# Deep merge user config with existing config
|
|
150
157
|
self._deep_merge(self.config, user_config)
|
|
151
|
-
|
|
158
|
+
self.config_sources.append(Path(str(config_path)))
|
|
159
|
+
|
|
152
160
|
except Exception as e:
|
|
153
161
|
raise ModelRegistryError(f"Failed to load config file {config_path}: {e}")
|
|
154
162
|
|
|
@@ -165,7 +173,76 @@ class ModelRegistry(CommandBuilderMixin):
|
|
|
165
173
|
self._deep_merge(base[key], value)
|
|
166
174
|
else:
|
|
167
175
|
base[key] = value
|
|
168
|
-
|
|
176
|
+
|
|
177
|
+
def effective_config(self, resolved: bool = False) -> Dict[str, Any]:
|
|
178
|
+
"""The configuration actually in force, as a plain dict.
|
|
179
|
+
|
|
180
|
+
A deep copy of the merge of every file in ``config_sources``: the
|
|
181
|
+
consumer's bundled config first, then any user override merged over it.
|
|
182
|
+
Answering "what is in force?" otherwise means reading both files and
|
|
183
|
+
performing the merge by eye - and the override's location depends on the
|
|
184
|
+
working directory at construction time, so it cannot reliably be
|
|
185
|
+
reconstructed afterwards.
|
|
186
|
+
|
|
187
|
+
With ``resolved``, values a *builder* supplies when a key is absent are
|
|
188
|
+
written out explicitly - currently a savilerow solver's implicit
|
|
189
|
+
``backend: sat``, the one default whose absence changes which arg-set,
|
|
190
|
+
timeout dialect and binary flag are used. Only real config keys are ever
|
|
191
|
+
added, so a resolved dump still loads; see :meth:`dump_config` for why
|
|
192
|
+
loading one back is not what it is for.
|
|
193
|
+
|
|
194
|
+
The copy is deep, so a caller may edit the result without disturbing the
|
|
195
|
+
live registry.
|
|
196
|
+
"""
|
|
197
|
+
config = copy.deepcopy(self.config)
|
|
198
|
+
if resolved:
|
|
199
|
+
for solver_config in config.get('solvers', {}).values():
|
|
200
|
+
if (isinstance(solver_config, dict)
|
|
201
|
+
and solver_config.get('solver_type') == 'savilerow'):
|
|
202
|
+
solver_config.setdefault('backend', 'sat')
|
|
203
|
+
return config
|
|
204
|
+
|
|
205
|
+
def dump_config(self, resolved: bool = False, header: bool = True) -> str:
|
|
206
|
+
"""The effective configuration as YAML text, for inspection.
|
|
207
|
+
|
|
208
|
+
Intended for a consumer's ``--show-registry``-style flag: it answers what
|
|
209
|
+
the registry resolved to and which files contributed. It is deliberately
|
|
210
|
+
*not* a config generator. Two reasons, both stated in the emitted header:
|
|
211
|
+
|
|
212
|
+
- Comments are not preserved. A config's comments carry the reasoning for
|
|
213
|
+
its arg-sets and timeouts, and a YAML round-trip drops all of them, so
|
|
214
|
+
the dump is a view of the configuration rather than a replacement.
|
|
215
|
+
- Overrides are deep-merged, so adopting a full dump as a user config
|
|
216
|
+
pins every key at today's value and silently shadows later changes to
|
|
217
|
+
the bundled config. A partial override is the supported shape: copy
|
|
218
|
+
only the keys being changed.
|
|
219
|
+
"""
|
|
220
|
+
text = yaml.safe_dump(self.effective_config(resolved=resolved),
|
|
221
|
+
sort_keys=False, default_flow_style=False,
|
|
222
|
+
allow_unicode=True)
|
|
223
|
+
if not header:
|
|
224
|
+
return text
|
|
225
|
+
|
|
226
|
+
lines = ["# Effective solver-support registry, merged in this order:"]
|
|
227
|
+
lines.extend(f"# {i}. {source}"
|
|
228
|
+
for i, source in enumerate(self.config_sources, start=1))
|
|
229
|
+
if not self.config_sources:
|
|
230
|
+
lines.append("# (none)")
|
|
231
|
+
if resolved:
|
|
232
|
+
lines.append("#")
|
|
233
|
+
lines.append("# Defaults a builder would supply are written out "
|
|
234
|
+
"explicitly here.")
|
|
235
|
+
lines.extend([
|
|
236
|
+
"#",
|
|
237
|
+
"# For reading, not for adopting wholesale. Comments in the source",
|
|
238
|
+
"# files are not preserved, and overrides are deep-merged - so using",
|
|
239
|
+
"# this whole file as an override would pin every key at today's",
|
|
240
|
+
"# value and silently shadow later changes. Copy only what you mean",
|
|
241
|
+
"# to change.",
|
|
242
|
+
"",
|
|
243
|
+
])
|
|
244
|
+
return "\n".join(lines) + "\n" + text
|
|
245
|
+
|
|
169
246
|
def get_model_path(self, model_type: str) -> Path:
|
|
170
247
|
"""
|
|
171
248
|
Get the file path for a model type.
|
|
@@ -227,11 +227,31 @@ class HorizonBasedSolver(Solver):
|
|
|
227
227
|
self.runner.summary.phases[-1].solver_stats['objbound'] = self._best_objective
|
|
228
228
|
|
|
229
229
|
def _record_savilerow_times(self, param_file: Path, n: int) -> None:
|
|
230
|
-
"""Attach Savile Row's
|
|
230
|
+
"""Attach Savile Row's ``.info`` stats to horizon n's phase.
|
|
231
231
|
|
|
232
232
|
Reads the ``<param_file>.info`` stats file Savile Row writes for this
|
|
233
233
|
horizon and records ``savilerow_time`` (tailoring) and ``solver_time``
|
|
234
234
|
on the phase, alongside the wall-clock ``duration`` already stored.
|
|
235
|
+
|
|
236
|
+
The whole file is also kept verbatim under ``savilerow_info``. The two
|
|
237
|
+
renamed times are what the phase model needs, but the same file carries
|
|
238
|
+
the encoding-size and search-effort numbers (``SATVars``, ``SolverNodes``,
|
|
239
|
+
``SolverFailures``, ...) and the outcome flags (``SolverSatisfiable``
|
|
240
|
+
distinguishes a refuted horizon from one that gave up; ``SolverTimeOut``
|
|
241
|
+
/ ``SavileRowTimeOut`` attribute a timeout to the solver or to
|
|
242
|
+
tailoring). Without this a consumer wanting any of them has to re-parse
|
|
243
|
+
the ``.info`` files itself, which duplicates the parser and needs the
|
|
244
|
+
run's output directory rather than just its summary - so it stops working
|
|
245
|
+
once those files are cleaned up.
|
|
246
|
+
|
|
247
|
+
Kept **nested and verbatim** rather than flattened in. ``solver_stats``
|
|
248
|
+
is the core's normalised, cross-solver vocabulary; these are one solver's
|
|
249
|
+
own keys in its own spelling, and the backends do not even agree on which
|
|
250
|
+
of them exist - Minion alone emits ``SolverSetupTime`` /
|
|
251
|
+
``SolverSolveTime`` / ``SolverSolutionsFound``, the FlatZinc backends
|
|
252
|
+
alone ``SolverTotalWallTime``, kissat alone ``SATVars`` / ``SATClauses``.
|
|
253
|
+
A curated allow-list would therefore be wrong already, and nesting also
|
|
254
|
+
keeps CamelCase keys from colliding with the snake_case ones beside them.
|
|
235
255
|
``param_file`` is whatever parameter file Savile Row was run on - the
|
|
236
256
|
Essence Prime ``.param`` for the direct Savile Row path, or the
|
|
237
257
|
Conjure-translated ``.eprime-param`` for the Essence path - since Savile
|
|
@@ -242,12 +262,15 @@ class HorizonBasedSolver(Solver):
|
|
|
242
262
|
the matching ``solving-horizon-{n}`` phase so it cannot scribble on an
|
|
243
263
|
unrelated phase.
|
|
244
264
|
"""
|
|
265
|
+
info = SolverStatsExtractor.extract_savilerow_info(f"{param_file}.info")
|
|
245
266
|
times = SolverStatsExtractor.extract_savilerow_info_times(f"{param_file}.info")
|
|
246
|
-
if not times:
|
|
267
|
+
if not times and not info:
|
|
247
268
|
return
|
|
248
269
|
phases = self.runner.summary.phases
|
|
249
270
|
if phases and phases[-1].name == f"solving-horizon-{n}":
|
|
250
271
|
phases[-1].solver_stats.update(times)
|
|
272
|
+
if info:
|
|
273
|
+
phases[-1].solver_stats['savilerow_info'] = info
|
|
251
274
|
|
|
252
275
|
def _build_savilerow_command(self, model_path: str, param_path: str, timeout: Optional[int], obj_file: Optional[Path] = None) -> List[str]:
|
|
253
276
|
"""
|
|
@@ -32,8 +32,10 @@ tests/test_command_builders.py
|
|
|
32
32
|
tests/test_helpers.py
|
|
33
33
|
tests/test_horizon_utils.py
|
|
34
34
|
tests/test_import.py
|
|
35
|
+
tests/test_registry_dump.py
|
|
35
36
|
tests/test_run_summary.py
|
|
36
37
|
tests/test_run_summary_success.py
|
|
38
|
+
tests/test_savilerow_info_recording.py
|
|
37
39
|
tests/test_solution_naming.py
|
|
38
40
|
tests/test_solver_runner.py
|
|
39
41
|
tests/test_solver_stats.py
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Reporting what the registry actually resolved to.
|
|
2
|
+
|
|
3
|
+
``effective_config`` / ``dump_config`` exist to answer "what is in force?", which
|
|
4
|
+
is otherwise only answerable by reading the bundled config and any user override
|
|
5
|
+
and merging them by eye -- and the override's location depends on the working
|
|
6
|
+
directory at construction time, so after the fact it may not be reconstructible
|
|
7
|
+
at all.
|
|
8
|
+
|
|
9
|
+
The dump is deliberately a *view*, not a config generator, and the tests below
|
|
10
|
+
pin the two properties that keep it honest: it reports its sources, and its
|
|
11
|
+
header warns against adopting it wholesale. Both matter because overrides are
|
|
12
|
+
deep-merged, so a full dump adopted as an override pins every key at today's
|
|
13
|
+
value and silently shadows later changes to the bundled config.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
from solver_support.model_registry import ModelRegistry
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def registry(fixture_registry):
|
|
25
|
+
return ModelRegistry()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestEffectiveConfig:
|
|
29
|
+
|
|
30
|
+
def test_returns_the_merged_config(self, registry):
|
|
31
|
+
config = registry.effective_config()
|
|
32
|
+
assert set(config) >= {"models", "solvers"}
|
|
33
|
+
assert "srortools" in config["solvers"]
|
|
34
|
+
|
|
35
|
+
def test_copy_is_deep(self, registry):
|
|
36
|
+
config = registry.effective_config()
|
|
37
|
+
config["solvers"]["srortools"]["backend"] = "mutated"
|
|
38
|
+
# The live registry must be untouched, or a caller inspecting the config
|
|
39
|
+
# could silently reconfigure the run it is inspecting.
|
|
40
|
+
assert registry.get_solver_config("srortools")["backend"] == "ortools"
|
|
41
|
+
|
|
42
|
+
def test_unresolved_leaves_implicit_backend_absent(self, registry):
|
|
43
|
+
# srsat has no `backend:` key; unresolved output mirrors the source.
|
|
44
|
+
assert "backend" not in registry.effective_config()["solvers"]["srsat"]
|
|
45
|
+
|
|
46
|
+
def test_resolved_writes_out_the_implicit_backend(self, registry):
|
|
47
|
+
config = registry.effective_config(resolved=True)
|
|
48
|
+
assert config["solvers"]["srsat"]["backend"] == "sat"
|
|
49
|
+
# An explicit one is left alone.
|
|
50
|
+
assert config["solvers"]["srortools"]["backend"] == "ortools"
|
|
51
|
+
|
|
52
|
+
def test_resolved_leaves_non_savilerow_solvers_alone(self, registry):
|
|
53
|
+
# `backend` means nothing to a upfplan solver, so inventing one would be
|
|
54
|
+
# a lie that also happens to load.
|
|
55
|
+
assert "backend" not in registry.effective_config(resolved=True)["solvers"]["upffd"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestConfigSources:
|
|
59
|
+
|
|
60
|
+
def test_records_the_files_merged(self, registry):
|
|
61
|
+
assert len(registry.config_sources) == 1
|
|
62
|
+
assert registry.config_sources[0].name == "models.yaml"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestDumpConfig:
|
|
66
|
+
|
|
67
|
+
def test_dump_round_trips_as_yaml(self, registry):
|
|
68
|
+
loaded = yaml.safe_load(registry.dump_config())
|
|
69
|
+
assert loaded == registry.effective_config()
|
|
70
|
+
|
|
71
|
+
def test_resolved_dump_also_round_trips(self, registry):
|
|
72
|
+
# Only real config keys are ever added, so a resolved dump still loads.
|
|
73
|
+
loaded = yaml.safe_load(registry.dump_config(resolved=True))
|
|
74
|
+
assert loaded["solvers"]["srsat"]["backend"] == "sat"
|
|
75
|
+
|
|
76
|
+
def test_header_names_the_sources(self, registry):
|
|
77
|
+
assert "models.yaml" in registry.dump_config().splitlines()[1]
|
|
78
|
+
|
|
79
|
+
def test_header_warns_against_adopting_it(self, registry):
|
|
80
|
+
header = registry.dump_config().split("\n\n")[0]
|
|
81
|
+
assert "not for adopting wholesale" in header
|
|
82
|
+
assert "deep-merged" in header
|
|
83
|
+
|
|
84
|
+
def test_header_can_be_suppressed(self, registry):
|
|
85
|
+
text = registry.dump_config(header=False)
|
|
86
|
+
assert not text.startswith("#")
|
|
87
|
+
assert yaml.safe_load(text) == registry.effective_config()
|
|
88
|
+
|
|
89
|
+
def test_key_order_follows_the_source(self, registry):
|
|
90
|
+
# sort_keys=False: alphabetising a registry makes it unreadable, and the
|
|
91
|
+
# source order is how a maintainer arranged it.
|
|
92
|
+
text = registry.dump_config(header=False)
|
|
93
|
+
assert text.index("models:") < text.index("solvers:")
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""What ``_record_savilerow_times`` keeps from each horizon's ``.info`` file.
|
|
2
|
+
|
|
3
|
+
Savile Row writes a ``<param>.info`` per horizon. Two of its fields are renamed
|
|
4
|
+
into the core's normalised vocabulary (``savilerow_time`` / ``solver_time``); the
|
|
5
|
+
rest used to be parsed and dropped, so a consumer wanting the encoding size or
|
|
6
|
+
the search-effort counters had to re-parse the files itself -- duplicating the
|
|
7
|
+
parser, and depending on the run's output directory rather than its summary, so
|
|
8
|
+
it stopped working once those files were cleaned up.
|
|
9
|
+
|
|
10
|
+
The whole file is now kept verbatim under ``savilerow_info``, nested rather than
|
|
11
|
+
flattened. Nesting is the point of several tests below: ``solver_stats`` is the
|
|
12
|
+
core's cross-solver vocabulary, while these are one solver's own keys in its own
|
|
13
|
+
spelling, and the backends do not agree on which of them exist.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from types import SimpleNamespace
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
from solver_support.run_summary import PhaseResult
|
|
21
|
+
from solver_support.solver_horizon import SavileRowSolver
|
|
22
|
+
|
|
23
|
+
# A real Minion .info, which carries three keys no other backend emits.
|
|
24
|
+
MINION_INFO = """SavileRowClauseOut:0
|
|
25
|
+
SavileRowTotalTime:0.484
|
|
26
|
+
SolverNodes:1
|
|
27
|
+
SolverSatisfiable:1
|
|
28
|
+
SolverSetupTime:0.001
|
|
29
|
+
SolverSolutionsFound:1
|
|
30
|
+
SolverSolveTime:0.000123
|
|
31
|
+
SolverTimeOut:0
|
|
32
|
+
SolverTotalTime:0.000123
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
# A real OR-Tools .info: no SolverNodes, but SolverFailures and a wall time.
|
|
36
|
+
ORTOOLS_INFO = """SavileRowClauseOut:0
|
|
37
|
+
SavileRowTotalTime:0.51
|
|
38
|
+
SolverFailures:0
|
|
39
|
+
SolverSatisfiable:1
|
|
40
|
+
SolverTimeOut:0
|
|
41
|
+
SolverTotalTime:0.002
|
|
42
|
+
SolverTotalWallTime:0.003
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def solver(tmp_path, monkeypatch):
|
|
48
|
+
"""A SavileRowSolver whose summary already holds a horizon-3 phase."""
|
|
49
|
+
monkeypatch.chdir(tmp_path)
|
|
50
|
+
phase = PhaseResult(name="solving-horizon-3", duration=1.0)
|
|
51
|
+
runner = SimpleNamespace(
|
|
52
|
+
summary=SimpleNamespace(phases=[phase]),
|
|
53
|
+
logger=None,
|
|
54
|
+
)
|
|
55
|
+
return SavileRowSolver(runner), phase
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def write_info(tmp_path, text, stem="inst.param"):
|
|
59
|
+
(tmp_path / f"{stem}.info").write_text(text)
|
|
60
|
+
return tmp_path / stem
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestVerbatimInfo:
|
|
64
|
+
|
|
65
|
+
def test_whole_file_is_kept(self, solver, tmp_path):
|
|
66
|
+
sr, phase = solver
|
|
67
|
+
sr._record_savilerow_times(write_info(tmp_path, MINION_INFO), 3)
|
|
68
|
+
info = phase.solver_stats["savilerow_info"]
|
|
69
|
+
assert info["SolverNodes"] == 1
|
|
70
|
+
assert info["SolverSatisfiable"] == 1
|
|
71
|
+
assert info["SolverSolutionsFound"] == 1
|
|
72
|
+
|
|
73
|
+
def test_renamed_times_still_recorded(self, solver, tmp_path):
|
|
74
|
+
# The existing contract must not move: these two are what the phase model
|
|
75
|
+
# reads, and they stay top-level and snake_case.
|
|
76
|
+
sr, phase = solver
|
|
77
|
+
sr._record_savilerow_times(write_info(tmp_path, MINION_INFO), 3)
|
|
78
|
+
assert phase.solver_stats["savilerow_time"] == 0.484
|
|
79
|
+
assert phase.solver_stats["solver_time"] == 0.000123
|
|
80
|
+
|
|
81
|
+
def test_verbatim_keys_are_nested_not_flattened(self, solver, tmp_path):
|
|
82
|
+
# Savile Row's CamelCase must not land beside the core's snake_case.
|
|
83
|
+
sr, phase = solver
|
|
84
|
+
sr._record_savilerow_times(write_info(tmp_path, MINION_INFO), 3)
|
|
85
|
+
assert "SolverNodes" not in phase.solver_stats
|
|
86
|
+
assert set(phase.solver_stats) == {"savilerow_time", "solver_time",
|
|
87
|
+
"savilerow_info"}
|
|
88
|
+
|
|
89
|
+
def test_keys_are_verbatim_and_coerced(self, solver, tmp_path):
|
|
90
|
+
sr, phase = solver
|
|
91
|
+
sr._record_savilerow_times(write_info(tmp_path, ORTOOLS_INFO), 3)
|
|
92
|
+
info = phase.solver_stats["savilerow_info"]
|
|
93
|
+
assert info["SolverTotalWallTime"] == 0.003 # float
|
|
94
|
+
assert info["SolverTimeOut"] == 0 # int, not "0"
|
|
95
|
+
|
|
96
|
+
def test_backends_may_disagree_on_which_keys_exist(self, solver, tmp_path):
|
|
97
|
+
# Which is why this is verbatim rather than an allow-list: Minion emits
|
|
98
|
+
# SolverNodes and no wall time, OR-Tools the reverse.
|
|
99
|
+
sr, phase = solver
|
|
100
|
+
sr._record_savilerow_times(write_info(tmp_path, ORTOOLS_INFO), 3)
|
|
101
|
+
info = phase.solver_stats["savilerow_info"]
|
|
102
|
+
assert "SolverNodes" not in info
|
|
103
|
+
assert "SolverFailures" in info and "SolverTotalWallTime" in info
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TestBestEffort:
|
|
107
|
+
|
|
108
|
+
def test_missing_info_file_records_nothing(self, solver, tmp_path):
|
|
109
|
+
# A hard timeout can kill Savile Row before it writes the file.
|
|
110
|
+
sr, phase = solver
|
|
111
|
+
sr._record_savilerow_times(tmp_path / "absent.param", 3)
|
|
112
|
+
assert phase.solver_stats == {}
|
|
113
|
+
|
|
114
|
+
def test_only_the_matching_horizon_phase_is_touched(self, solver, tmp_path):
|
|
115
|
+
# Guarded so a mismatched horizon cannot scribble on an unrelated phase.
|
|
116
|
+
sr, phase = solver
|
|
117
|
+
sr._record_savilerow_times(write_info(tmp_path, MINION_INFO), 7)
|
|
118
|
+
assert phase.solver_stats == {}
|
|
119
|
+
|
|
120
|
+
def test_info_without_the_two_times_is_still_kept(self, solver, tmp_path):
|
|
121
|
+
# The times are absent if Savile Row was interrupted mid-write; whatever
|
|
122
|
+
# did land is still worth keeping.
|
|
123
|
+
sr, phase = solver
|
|
124
|
+
sr._record_savilerow_times(
|
|
125
|
+
write_info(tmp_path, "SolverSatisfiable:0\nSolverTimeOut:1\n"), 3)
|
|
126
|
+
assert phase.solver_stats["savilerow_info"] == {"SolverSatisfiable": 0,
|
|
127
|
+
"SolverTimeOut": 1}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|