solver-support 0.1.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.1.1/solver_support.egg-info → solver_support-0.2.2}/PKG-INFO +1 -1
- {solver_support-0.1.1 → solver_support-0.2.2}/docs/timing-model.md +67 -6
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/__init__.py +1 -1
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/command_builders.py +149 -35
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/model_registry.py +81 -4
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/solver_horizon.py +42 -5
- {solver_support-0.1.1 → solver_support-0.2.2/solver_support.egg-info}/PKG-INFO +1 -1
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support.egg-info/SOURCES.txt +3 -0
- solver_support-0.2.2/tests/fixtures/models.yaml +205 -0
- solver_support-0.2.2/tests/test_command_builders.py +203 -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.1.1/tests/fixtures/models.yaml +0 -57
- {solver_support-0.1.1 → solver_support-0.2.2}/LICENSE +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/MANIFEST.in +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/README.md +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/docs/injection-api.md +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/docs/solver-runner.md +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/pyproject.toml +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/setup.cfg +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/file_management.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/global_state.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/horizon_utils.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/objective.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/run_summary.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/solver_base.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/solver_enhsp.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/solver_fd.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/solver_runner.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/solver_stats.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/solver_upfplan.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/utilities.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support.egg-info/dependency_links.txt +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support.egg-info/not-zip-safe +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support.egg-info/requires.txt +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/solver_support.egg-info/top_level.txt +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/tests/conftest.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_helpers.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_horizon_utils.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_import.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_run_summary.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_run_summary_success.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_solution_naming.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_solver_runner.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_solver_stats.py +0 -0
- {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_solvers.py +0 -0
|
@@ -76,15 +76,76 @@ 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
|
|
|
110
|
+
#### Backends
|
|
111
|
+
|
|
112
|
+
Savile Row drives five backends, chosen by a solver config's `backend:` key and
|
|
113
|
+
described by one row each in `CommandBuilderMixin.SAVILEROW_BACKENDS`. A config
|
|
114
|
+
with no `backend:` key is the SAT arm, which is what every pre-existing config
|
|
115
|
+
relies on. The row supplies three things that vary and nothing that does not; the
|
|
116
|
+
arg-set key is *derived* as `<backend>_args`, so it needs no row.
|
|
117
|
+
|
|
118
|
+
| `backend:` | arg-set key | solver's own time limit | binary flag |
|
|
119
|
+
|---|---|---|---|
|
|
120
|
+
| *(absent)* / `sat` | `sat_args` | `--time=N` (s) | `-satsolver-bin` |
|
|
121
|
+
| `minion` | `minion_args` | *(none)* | `-minion-bin` |
|
|
122
|
+
| `ortools` | `ortools_args` | `--time_limit=N` (s) | `-fzn-bin` |
|
|
123
|
+
| `gecode` | `gecode_args` | `-time N` (**ms**) | `-gecode-bin` |
|
|
124
|
+
| `chuffed` | `chuffed_args` | `--time-out N` (**ms**) | `-chuffed-bin` |
|
|
125
|
+
|
|
126
|
+
Savile Row's own `-timelimit` is always added, always in seconds, and covers
|
|
127
|
+
translation plus solving. The solver's limit is passed through `-solver-options`
|
|
128
|
+
in addition, so the solver stops itself and reports rather than being killed with
|
|
129
|
+
nothing to show. Repeated `-solver-options` accumulate, so a tune preset may
|
|
130
|
+
carry its own without displacing the timeout.
|
|
131
|
+
|
|
132
|
+
**The units are a trap.** Gecode and Chuffed take milliseconds and neither says
|
|
133
|
+
so in its flag name. An unscaled value does not fail loudly — a 30s budget
|
|
134
|
+
becomes 30ms and the solver gives up instantly, which reads as a hard instance.
|
|
135
|
+
`timeout_scale` in the table is what converts; a config overriding
|
|
136
|
+
`solver_timeout_option` changes the flag's *name* only, since the unit belongs to
|
|
137
|
+
the solver rather than to the spelling.
|
|
138
|
+
|
|
139
|
+
**Only the SAT arm records intermediate objectives.** It is the one configuration
|
|
140
|
+
where Savile Row runs the optimisation loop itself; every other backend optimises
|
|
141
|
+
internally, leaving nothing for `-record-intermediate-objective-values` to see.
|
|
142
|
+
So for a non-SAT backend `_generate_objective_file_path` returns `None` and no
|
|
143
|
+
`.obj` file is named or cleared, and `_check_continue_scan` falls back to the raw
|
|
144
|
+
objective read out of the *solution* by the injected extractor. A consumer that
|
|
145
|
+
wires only the objective-file route will find continue-scan stops at the first
|
|
146
|
+
solving horizon on those backends instead of improving — wire
|
|
147
|
+
`set_raw_objective_from_solution` to use them for optimisation models.
|
|
148
|
+
|
|
88
149
|
### Fast Downward / SymK (PDDL models)
|
|
89
150
|
|
|
90
151
|
- **Shape**: not a horizon scan — one `solving` phase whose A\* (blind) / symbolic
|
|
@@ -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.
|
|
16
|
+
__version__ = "0.2.2"
|
|
17
17
|
|
|
18
18
|
# --- model registry (loader + command builders + packaging injection) ---
|
|
19
19
|
from .model_registry import (
|
|
@@ -24,6 +24,84 @@ from .model_registry import ModelRegistryError
|
|
|
24
24
|
class CommandBuilderMixin:
|
|
25
25
|
"""The ``build_*_command`` methods mixed into :class:`ModelRegistry`."""
|
|
26
26
|
|
|
27
|
+
#: The Savile Row backends, one row each. Everything the argv assembler needs
|
|
28
|
+
#: to know about a backend lives here, so adding one is a single entry plus
|
|
29
|
+
#: its ``<backend>_args`` presets in the config - not an edit spread across an
|
|
30
|
+
#: arg-set lookup, a binary flag and a timeout dialect. The arg-set key is
|
|
31
|
+
#: *derived* (``<backend>_args``) rather than tabulated, so it needs no row.
|
|
32
|
+
#:
|
|
33
|
+
#: ``sat`` is the implicit default: a config with no ``backend:`` key is the
|
|
34
|
+
#: kissat SAT backend every pre-existing config relies on.
|
|
35
|
+
#:
|
|
36
|
+
#: ``timeout_option`` is the *solver's own* time limit, handed to it through
|
|
37
|
+
#: Savile Row's ``-solver-options``; Savile Row's own ``-timelimit`` (covering
|
|
38
|
+
#: translation plus solving) is added for every backend regardless. Giving the
|
|
39
|
+
#: solver its own budget means it stops itself and reports, rather than being
|
|
40
|
+
#: killed with nothing to show.
|
|
41
|
+
#:
|
|
42
|
+
#: ``timeout_scale`` converts the seconds this API speaks into the unit that
|
|
43
|
+
#: solver expects. Gecode and Chuffed both want milliseconds and neither says
|
|
44
|
+
#: so in its flag name, which makes the mistake silent rather than loud: a 30s
|
|
45
|
+
#: budget passed unscaled becomes 30ms, and the solver gives up instantly
|
|
46
|
+
#: looking for all the world like a hard instance.
|
|
47
|
+
SAVILEROW_BACKENDS = {
|
|
48
|
+
'sat': {
|
|
49
|
+
'bin_option': '-satsolver-bin',
|
|
50
|
+
'timeout_option': '--time={value}',
|
|
51
|
+
'timeout_scale': 1,
|
|
52
|
+
# Savile Row drives the optimisation loop itself when the backend is a
|
|
53
|
+
# SAT solver, so it can record each improving objective as it goes.
|
|
54
|
+
# Every other backend optimises internally, leaving SR nothing to see.
|
|
55
|
+
'records_objectives': True,
|
|
56
|
+
},
|
|
57
|
+
'minion': {
|
|
58
|
+
'bin_option': '-minion-bin',
|
|
59
|
+
# Minion takes no time limit of its own here: -timelimit is enough.
|
|
60
|
+
'timeout_option': None,
|
|
61
|
+
'timeout_scale': 1,
|
|
62
|
+
'records_objectives': False,
|
|
63
|
+
},
|
|
64
|
+
'ortools': {
|
|
65
|
+
# SR calls OR-Tools through its "standard FlatZinc" route, so the
|
|
66
|
+
# generic -fzn-bin names the binary; Gecode and Chuffed have their own.
|
|
67
|
+
'bin_option': '-fzn-bin',
|
|
68
|
+
'timeout_option': '--time_limit={value}', # an Abseil flag, seconds
|
|
69
|
+
'timeout_scale': 1,
|
|
70
|
+
'records_objectives': False,
|
|
71
|
+
},
|
|
72
|
+
'gecode': {
|
|
73
|
+
'bin_option': '-gecode-bin',
|
|
74
|
+
'timeout_option': '-time {value}', # "time (in ms) cutoff"
|
|
75
|
+
'timeout_scale': 1000,
|
|
76
|
+
'records_objectives': False,
|
|
77
|
+
},
|
|
78
|
+
'chuffed': {
|
|
79
|
+
'bin_option': '-chuffed-bin',
|
|
80
|
+
# Named --time-out but documented "Time out in milliseconds".
|
|
81
|
+
'timeout_option': '--time-out {value}',
|
|
82
|
+
'timeout_scale': 1000,
|
|
83
|
+
'records_objectives': False,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def records_intermediate_objectives(self, solver_name: str) -> bool:
|
|
88
|
+
"""Whether this solver's backend writes the intermediate-objective file.
|
|
89
|
+
|
|
90
|
+
True only for Savile Row's SAT arm, which is the one configuration where
|
|
91
|
+
Savile Row runs the optimisation loop and can report improving objectives
|
|
92
|
+
as it finds them. Callers use this to avoid naming (and clearing) a file
|
|
93
|
+
that will never be written; with no such file, continue-scan falls back to
|
|
94
|
+
the injected solution extractor. Resolves through
|
|
95
|
+
:meth:`get_tune_backend`, so the Conjure path answers for the kissat
|
|
96
|
+
solve it delegates to, and non-savilerow solvers answer False.
|
|
97
|
+
"""
|
|
98
|
+
backend_solver = self.get_tune_backend(solver_name)
|
|
99
|
+
if self.get_solver_type(backend_solver) != 'savilerow':
|
|
100
|
+
return False
|
|
101
|
+
backend = self.get_solver_config(backend_solver).get('backend', 'sat')
|
|
102
|
+
spec = self.SAVILEROW_BACKENDS.get(backend)
|
|
103
|
+
return bool(spec and spec['records_objectives'])
|
|
104
|
+
|
|
27
105
|
def build_savilerow_command(self, model_path: str, param_path: str,
|
|
28
106
|
use_srjava: bool = False,
|
|
29
107
|
timeout: Optional[int] = None,
|
|
@@ -46,31 +124,50 @@ class CommandBuilderMixin:
|
|
|
46
124
|
objective_file_path: path for intermediate objective values file (default: obj.txt)
|
|
47
125
|
extra_options: additional command line options to pass to Savile Row
|
|
48
126
|
solver_name: name of the solver configuration to use (default: 'kissat').
|
|
49
|
-
The backend
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
127
|
+
The backend is a property of this config, named by its
|
|
128
|
+
``backend:`` key and described by ``SAVILEROW_BACKENDS``: it
|
|
129
|
+
selects the arg-set key, the solver's own timeout dialect, and
|
|
130
|
+
the flag that names its binary. No ``backend:`` key means the
|
|
131
|
+
kissat SAT backend, which is what every pre-existing config
|
|
132
|
+
relies on.
|
|
53
133
|
tune: name of a ``tune:`` preset to apply instead of the config's
|
|
54
134
|
``default_tune`` (kissat: 'safe'/'bare'/'srdefault'/'gac';
|
|
55
|
-
minion: 'bare'). The preset supplies this backend's arg-set
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
``default_tune``. This is the
|
|
59
|
-
dial fd uses for its search
|
|
135
|
+
minion: 'bare'). The preset supplies this backend's arg-set,
|
|
136
|
+
under the key ``<backend>_args`` - sat_args, minion_args,
|
|
137
|
+
ortools_args, and so on. A name that is unknown or lacks this
|
|
138
|
+
backend's arg key falls back to ``default_tune``. This is the
|
|
139
|
+
savilerow arm of the same ``--tune`` dial fd uses for its search
|
|
140
|
+
presets.
|
|
60
141
|
|
|
61
142
|
Returns:
|
|
62
143
|
Complete command as list of strings.
|
|
63
144
|
|
|
64
145
|
Raises:
|
|
65
|
-
ModelRegistryError: If savilerow solver not found in config,
|
|
66
|
-
|
|
146
|
+
ModelRegistryError: If savilerow solver not found in config, if the
|
|
147
|
+
config names a backend that is not in ``SAVILEROW_BACKENDS``, or
|
|
148
|
+
if the resolved tune has no arg-set for this backend.
|
|
67
149
|
"""
|
|
68
150
|
solver_config = self.get_solver_config(solver_name)
|
|
69
151
|
|
|
70
|
-
# The backend solver
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
|
|
152
|
+
# The backend solver is declared by the config, not passed in: `--solver
|
|
153
|
+
# minion` selects the minion backend config. This replaced the old
|
|
154
|
+
# cross-cutting `--minion` boolean. Absent means the kissat SAT backend,
|
|
155
|
+
# which is what every pre-existing config relies on.
|
|
156
|
+
#
|
|
157
|
+
# An unrecognised name is an error rather than a fallback. The lenient
|
|
158
|
+
# `_effective_tune` lookup below can afford to shrug at a typo because a
|
|
159
|
+
# wrong tune still runs the right solver; a typo'd backend would silently
|
|
160
|
+
# build the SAT arm's argv - a run that succeeds while comparing the wrong
|
|
161
|
+
# solver, which is worse than no run at all. Same reasoning as
|
|
162
|
+
# `is_valid_tune`, applied one level up.
|
|
163
|
+
backend = solver_config.get('backend', 'sat')
|
|
164
|
+
spec = self.SAVILEROW_BACKENDS.get(backend)
|
|
165
|
+
if spec is None:
|
|
166
|
+
known = ', '.join(sorted(self.SAVILEROW_BACKENDS))
|
|
167
|
+
raise ModelRegistryError(
|
|
168
|
+
f"Solver '{solver_name}' declares unknown backend '{backend}' "
|
|
169
|
+
f"(known backends: {known})"
|
|
170
|
+
)
|
|
74
171
|
|
|
75
172
|
# Choose command variant
|
|
76
173
|
if use_srjava:
|
|
@@ -90,12 +187,14 @@ class CommandBuilderMixin:
|
|
|
90
187
|
self._savilerow_native_warning_shown = True
|
|
91
188
|
command = solver_config['command']
|
|
92
189
|
|
|
93
|
-
# Choose the argument set from the active tune preset.
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
190
|
+
# Choose the argument set from the active tune preset. Each backend reads
|
|
191
|
+
# its own key, named after it: sat_args, minion_args, ortools_args, and so
|
|
192
|
+
# on. Deriving the name rather than tabulating it is what lets a new
|
|
193
|
+
# backend arrive without touching this line. A tune that is unknown or
|
|
194
|
+
# lacks this backend's key falls back to default_tune (the lenient
|
|
195
|
+
# behaviour the fd builder uses too). The model-default tune is resolved
|
|
196
|
+
# by the caller, so `tune` here is already the effective name.
|
|
197
|
+
arg_key = f'{backend}_args'
|
|
99
198
|
tunes = solver_config.get('tune', {})
|
|
100
199
|
tune_name = self._effective_tune(solver_config, tune, arg_key)
|
|
101
200
|
preset = tunes.get(tune_name, {})
|
|
@@ -104,26 +203,41 @@ class CommandBuilderMixin:
|
|
|
104
203
|
f"Solver '{solver_name}' tune '{tune_name}' has no {arg_key}"
|
|
105
204
|
)
|
|
106
205
|
args = preset[arg_key].copy()
|
|
107
|
-
if not
|
|
108
|
-
#
|
|
109
|
-
|
|
110
|
-
|
|
206
|
+
if spec['records_objectives'] and objective_file_path is not None:
|
|
207
|
+
# Only the SAT arm has intermediate objectives to record, because it
|
|
208
|
+
# is the only one where Savile Row runs the optimisation loop rather
|
|
209
|
+
# than the solver. Asking any other backend for them names a file that
|
|
210
|
+
# is never written.
|
|
211
|
+
args.extend(["-record-intermediate-objective-values", objective_file_path])
|
|
212
|
+
|
|
213
|
+
# Name the backend's binary only when the config pins one. Each backend
|
|
214
|
+
# has its own flag for this (-fzn-bin, -gecode-bin, -minion-bin, ...), and
|
|
215
|
+
# left unset Savile Row's default applies - so the registry never has to
|
|
216
|
+
# bake one machine's build into a shared config.
|
|
217
|
+
solver_bin = solver_config.get('solver_bin')
|
|
218
|
+
if solver_bin:
|
|
219
|
+
args.extend([spec['bin_option'], solver_bin])
|
|
111
220
|
|
|
112
221
|
# Add timeout arguments
|
|
113
222
|
if timeout is None:
|
|
114
223
|
timeout = solver_config['default_timeout']
|
|
115
224
|
|
|
116
|
-
# Different timeout handling for minion vs SAT solver
|
|
117
225
|
if timeout is not None:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
226
|
+
# The solver's own limit, in whatever flag and unit that solver
|
|
227
|
+
# speaks. A config may override the dialect (or set it to null to
|
|
228
|
+
# suppress it) without the core having to learn a new backend.
|
|
229
|
+
option = solver_config.get('solver_timeout_option', spec['timeout_option'])
|
|
230
|
+
if option:
|
|
231
|
+
scale = spec['timeout_scale']
|
|
232
|
+
# Left untouched when the solver already speaks seconds, so those
|
|
233
|
+
# argvs keep their exact previous form (--time=45, never 45.0).
|
|
234
|
+
value = timeout if scale == 1 else int(timeout * scale)
|
|
235
|
+
args.extend(["-solver-options", option.format(value=value)])
|
|
236
|
+
# Savile Row's own limit covers translation plus solving and is always
|
|
237
|
+
# in seconds. Always added, so a backend with no limit of its own is
|
|
238
|
+
# still bounded - and one that has its own stops itself and reports,
|
|
239
|
+
# rather than being killed with nothing to show.
|
|
240
|
+
args.extend(["-timelimit", str(timeout)])
|
|
127
241
|
|
|
128
242
|
# Add extra options if provided
|
|
129
243
|
if extra_options:
|
|
@@ -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.
|
|
@@ -32,8 +32,20 @@ class HorizonBasedSolver(Solver):
|
|
|
32
32
|
self._stop_at_first = False # regime: first stops the scan at the first solving horizon
|
|
33
33
|
self.solver_name = solver_name # the backend config (kissat/minion/...)
|
|
34
34
|
|
|
35
|
-
def _generate_objective_file_path(self, n: int) -> Path:
|
|
36
|
-
"""Generate the objective file path for a given horizon.
|
|
35
|
+
def _generate_objective_file_path(self, n: int) -> Optional[Path]:
|
|
36
|
+
"""Generate the objective file path for a given horizon, or None.
|
|
37
|
+
|
|
38
|
+
None when the active backend does not write one. Only Savile Row's SAT
|
|
39
|
+
arm runs the optimisation loop that emits intermediate objectives, so for
|
|
40
|
+
a Minion or FlatZinc backend the file would be named, cleared, and then
|
|
41
|
+
never appear. Returning None here rather than gating each call site keeps
|
|
42
|
+
the three of them identical, and leaves continue-scan to read the
|
|
43
|
+
objective out of the solution via the injected extractor - which is the
|
|
44
|
+
only route those backends have.
|
|
45
|
+
"""
|
|
46
|
+
if not global_state.model_registry.records_intermediate_objectives(
|
|
47
|
+
self.solver_name):
|
|
48
|
+
return None
|
|
37
49
|
return ObjectiveFileManager.generate_objective_path(
|
|
38
50
|
self.runner.summary.instance,
|
|
39
51
|
self.runner.summary.solver,
|
|
@@ -43,8 +55,10 @@ class HorizonBasedSolver(Solver):
|
|
|
43
55
|
tune=self.runner.summary.tune_slot
|
|
44
56
|
)
|
|
45
57
|
|
|
46
|
-
def _prepare_objective_file(self, obj_file: Path) -> None:
|
|
58
|
+
def _prepare_objective_file(self, obj_file: Optional[Path]) -> None:
|
|
47
59
|
"""Prepare the objective file by removing any existing file."""
|
|
60
|
+
if obj_file is None:
|
|
61
|
+
return
|
|
48
62
|
ObjectiveFileManager.prepare_objective_file(obj_file, self.logger)
|
|
49
63
|
|
|
50
64
|
def _parse_objective_file(self, obj_file: Path) -> Optional[int]:
|
|
@@ -213,11 +227,31 @@ class HorizonBasedSolver(Solver):
|
|
|
213
227
|
self.runner.summary.phases[-1].solver_stats['objbound'] = self._best_objective
|
|
214
228
|
|
|
215
229
|
def _record_savilerow_times(self, param_file: Path, n: int) -> None:
|
|
216
|
-
"""Attach Savile Row's
|
|
230
|
+
"""Attach Savile Row's ``.info`` stats to horizon n's phase.
|
|
217
231
|
|
|
218
232
|
Reads the ``<param_file>.info`` stats file Savile Row writes for this
|
|
219
233
|
horizon and records ``savilerow_time`` (tailoring) and ``solver_time``
|
|
220
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.
|
|
221
255
|
``param_file`` is whatever parameter file Savile Row was run on - the
|
|
222
256
|
Essence Prime ``.param`` for the direct Savile Row path, or the
|
|
223
257
|
Conjure-translated ``.eprime-param`` for the Essence path - since Savile
|
|
@@ -228,12 +262,15 @@ class HorizonBasedSolver(Solver):
|
|
|
228
262
|
the matching ``solving-horizon-{n}`` phase so it cannot scribble on an
|
|
229
263
|
unrelated phase.
|
|
230
264
|
"""
|
|
265
|
+
info = SolverStatsExtractor.extract_savilerow_info(f"{param_file}.info")
|
|
231
266
|
times = SolverStatsExtractor.extract_savilerow_info_times(f"{param_file}.info")
|
|
232
|
-
if not times:
|
|
267
|
+
if not times and not info:
|
|
233
268
|
return
|
|
234
269
|
phases = self.runner.summary.phases
|
|
235
270
|
if phases and phases[-1].name == f"solving-horizon-{n}":
|
|
236
271
|
phases[-1].solver_stats.update(times)
|
|
272
|
+
if info:
|
|
273
|
+
phases[-1].solver_stats['savilerow_info'] = info
|
|
237
274
|
|
|
238
275
|
def _build_savilerow_command(self, model_path: str, param_path: str, timeout: Optional[int], obj_file: Optional[Path] = None) -> List[str]:
|
|
239
276
|
"""
|
|
@@ -28,11 +28,14 @@ solver_support.egg-info/not-zip-safe
|
|
|
28
28
|
solver_support.egg-info/requires.txt
|
|
29
29
|
solver_support.egg-info/top_level.txt
|
|
30
30
|
tests/conftest.py
|
|
31
|
+
tests/test_command_builders.py
|
|
31
32
|
tests/test_helpers.py
|
|
32
33
|
tests/test_horizon_utils.py
|
|
33
34
|
tests/test_import.py
|
|
35
|
+
tests/test_registry_dump.py
|
|
34
36
|
tests/test_run_summary.py
|
|
35
37
|
tests/test_run_summary_success.py
|
|
38
|
+
tests/test_savilerow_info_recording.py
|
|
36
39
|
tests/test_solution_naming.py
|
|
37
40
|
tests/test_solver_runner.py
|
|
38
41
|
tests/test_solver_stats.py
|