solver-support 0.2.2__tar.gz → 0.3.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.2.2/solver_support.egg-info → solver_support-0.3.1}/PKG-INFO +26 -18
- {solver_support-0.2.2 → solver_support-0.3.1}/README.md +25 -17
- {solver_support-0.2.2 → solver_support-0.3.1}/docs/solver-runner.md +3 -3
- {solver_support-0.2.2 → solver_support-0.3.1}/docs/timing-model.md +94 -1
- {solver_support-0.2.2 → solver_support-0.3.1}/pyproject.toml +10 -7
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/__init__.py +25 -1
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/command_builders.py +66 -3
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/run_summary.py +98 -30
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/solver_horizon.py +21 -6
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/solver_stats.py +104 -0
- {solver_support-0.2.2 → solver_support-0.3.1/solver_support.egg-info}/PKG-INFO +26 -18
- {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_command_builders.py +56 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_import.py +20 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_run_summary.py +5 -4
- {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_run_summary_success.py +58 -6
- {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_savilerow_info_recording.py +47 -1
- {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_solver_runner.py +3 -2
- {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_solver_stats.py +113 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/LICENSE +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/MANIFEST.in +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/docs/injection-api.md +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/setup.cfg +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/file_management.py +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/global_state.py +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/horizon_utils.py +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/model_registry.py +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/objective.py +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/solver_base.py +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/solver_enhsp.py +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/solver_fd.py +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/solver_runner.py +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/solver_upfplan.py +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/utilities.py +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support.egg-info/SOURCES.txt +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support.egg-info/dependency_links.txt +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support.egg-info/not-zip-safe +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support.egg-info/requires.txt +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/solver_support.egg-info/top_level.txt +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/tests/conftest.py +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/tests/fixtures/models.yaml +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_helpers.py +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_horizon_utils.py +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_registry_dump.py +0 -0
- {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_solution_naming.py +0 -0
- {solver_support-0.2.2 → solver_support-0.3.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.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -29,12 +29,13 @@ Dynamic: license-file
|
|
|
29
29
|
# solver-support
|
|
30
30
|
|
|
31
31
|
A generic, domain-agnostic pipeline for running constraint-modelling and
|
|
32
|
-
planning solvers: **Conjure** / **Savile Row** (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
and driving the horizon
|
|
32
|
+
planning solvers: **Conjure** / **Savile Row** (driving a SAT solver, **Minion**,
|
|
33
|
+
**OR-Tools CP-SAT**, **Gecode** or **Chuffed**), **Fast Downward**, **SymK**,
|
|
34
|
+
**ENHSP**, and **UPF**-constructed models. It handles the mechanics that every
|
|
35
|
+
such run needs — building solver command lines from a model registry, spawning
|
|
36
|
+
subprocesses with timeouts and clean teardown, naming the per-run files, parsing
|
|
37
|
+
solver output into a uniform timing / cost-layer model, and driving the horizon
|
|
38
|
+
(increasing-difficulty) loop.
|
|
38
39
|
|
|
39
40
|
It carries **no** notion of any particular problem domain or objective. A
|
|
40
41
|
consumer supplies:
|
|
@@ -47,14 +48,17 @@ consumer supplies:
|
|
|
47
48
|
|
|
48
49
|
## Status
|
|
49
50
|
|
|
50
|
-
**Alpha.** The pipeline is in day-to-day use by
|
|
51
|
-
its own test suite, but
|
|
52
|
-
|
|
51
|
+
**Alpha.** The pipeline is in day-to-day use by several consumers and is covered
|
|
52
|
+
by its own test suite, but the API may still shift between 0.x releases. Each
|
|
53
|
+
release's changes are listed on the
|
|
54
|
+
[releases page](https://github.com/ott2/solver-support/releases).
|
|
53
55
|
|
|
54
|
-
The package ships **unwired
|
|
55
|
-
that resolves no model registry and
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
The package ships **unwired**, by design and on every release: a bare
|
|
57
|
+
`import solver_support` gives a pipeline that resolves no model registry and
|
|
58
|
+
interprets no objective, because it has no way to guess either. Constructing a
|
|
59
|
+
`ModelRegistry` *raises* rather than falling back on a guess. A consumer
|
|
60
|
+
registers its scoring, registry paths, warning logger and UPF CLI once, before
|
|
61
|
+
first use — see [Usage](#usage).
|
|
58
62
|
|
|
59
63
|
## Installation
|
|
60
64
|
|
|
@@ -63,7 +67,8 @@ pip install solver-support
|
|
|
63
67
|
```
|
|
64
68
|
|
|
65
69
|
Requires Python 3.9 or newer (3.9.6 is the system Python on macOS, and is
|
|
66
|
-
supported deliberately
|
|
70
|
+
supported deliberately — each release is built and tested against it). The only
|
|
71
|
+
runtime dependencies are `psutil` and `PyYAML`.
|
|
67
72
|
|
|
68
73
|
## Usage
|
|
69
74
|
|
|
@@ -83,15 +88,18 @@ extraction, warning logger, UPF CLI — with a worked example.
|
|
|
83
88
|
|
|
84
89
|
## Development
|
|
85
90
|
|
|
86
|
-
|
|
87
|
-
normally installed into the same virtualenv, so both are importable and edits to
|
|
88
|
-
either take effect immediately:
|
|
91
|
+
To work on this package, clone and install it editable:
|
|
89
92
|
|
|
90
93
|
```bash
|
|
91
94
|
git clone https://github.com/ott2/solver-support
|
|
92
95
|
pip install -e solver-support
|
|
93
96
|
```
|
|
94
97
|
|
|
98
|
+
Consumers pin a **released** version from PyPI rather than the working tree, so a
|
|
99
|
+
change here reaches them only when it is released. To try an unreleased change
|
|
100
|
+
against a consumer, install this editable into that consumer's environment
|
|
101
|
+
deliberately — it is not the default arrangement.
|
|
102
|
+
|
|
95
103
|
## Tests
|
|
96
104
|
|
|
97
105
|
`pytest tests/` — the core's own suite, and the home for solver-level
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# solver-support
|
|
2
2
|
|
|
3
3
|
A generic, domain-agnostic pipeline for running constraint-modelling and
|
|
4
|
-
planning solvers: **Conjure** / **Savile Row** (
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
and driving the horizon
|
|
4
|
+
planning solvers: **Conjure** / **Savile Row** (driving a SAT solver, **Minion**,
|
|
5
|
+
**OR-Tools CP-SAT**, **Gecode** or **Chuffed**), **Fast Downward**, **SymK**,
|
|
6
|
+
**ENHSP**, and **UPF**-constructed models. It handles the mechanics that every
|
|
7
|
+
such run needs — building solver command lines from a model registry, spawning
|
|
8
|
+
subprocesses with timeouts and clean teardown, naming the per-run files, parsing
|
|
9
|
+
solver output into a uniform timing / cost-layer model, and driving the horizon
|
|
10
|
+
(increasing-difficulty) loop.
|
|
10
11
|
|
|
11
12
|
It carries **no** notion of any particular problem domain or objective. A
|
|
12
13
|
consumer supplies:
|
|
@@ -19,14 +20,17 @@ consumer supplies:
|
|
|
19
20
|
|
|
20
21
|
## Status
|
|
21
22
|
|
|
22
|
-
**Alpha.** The pipeline is in day-to-day use by
|
|
23
|
-
its own test suite, but
|
|
24
|
-
|
|
23
|
+
**Alpha.** The pipeline is in day-to-day use by several consumers and is covered
|
|
24
|
+
by its own test suite, but the API may still shift between 0.x releases. Each
|
|
25
|
+
release's changes are listed on the
|
|
26
|
+
[releases page](https://github.com/ott2/solver-support/releases).
|
|
25
27
|
|
|
26
|
-
The package ships **unwired
|
|
27
|
-
that resolves no model registry and
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
The package ships **unwired**, by design and on every release: a bare
|
|
29
|
+
`import solver_support` gives a pipeline that resolves no model registry and
|
|
30
|
+
interprets no objective, because it has no way to guess either. Constructing a
|
|
31
|
+
`ModelRegistry` *raises* rather than falling back on a guess. A consumer
|
|
32
|
+
registers its scoring, registry paths, warning logger and UPF CLI once, before
|
|
33
|
+
first use — see [Usage](#usage).
|
|
30
34
|
|
|
31
35
|
## Installation
|
|
32
36
|
|
|
@@ -35,7 +39,8 @@ pip install solver-support
|
|
|
35
39
|
```
|
|
36
40
|
|
|
37
41
|
Requires Python 3.9 or newer (3.9.6 is the system Python on macOS, and is
|
|
38
|
-
supported deliberately
|
|
42
|
+
supported deliberately — each release is built and tested against it). The only
|
|
43
|
+
runtime dependencies are `psutil` and `PyYAML`.
|
|
39
44
|
|
|
40
45
|
## Usage
|
|
41
46
|
|
|
@@ -55,15 +60,18 @@ extraction, warning logger, UPF CLI — with a worked example.
|
|
|
55
60
|
|
|
56
61
|
## Development
|
|
57
62
|
|
|
58
|
-
|
|
59
|
-
normally installed into the same virtualenv, so both are importable and edits to
|
|
60
|
-
either take effect immediately:
|
|
63
|
+
To work on this package, clone and install it editable:
|
|
61
64
|
|
|
62
65
|
```bash
|
|
63
66
|
git clone https://github.com/ott2/solver-support
|
|
64
67
|
pip install -e solver-support
|
|
65
68
|
```
|
|
66
69
|
|
|
70
|
+
Consumers pin a **released** version from PyPI rather than the working tree, so a
|
|
71
|
+
change here reaches them only when it is released. To try an unreleased change
|
|
72
|
+
against a consumer, install this editable into that consumer's environment
|
|
73
|
+
deliberately — it is not the default arrangement.
|
|
74
|
+
|
|
67
75
|
## Tests
|
|
68
76
|
|
|
69
77
|
`pytest tests/` — the core's own suite, and the home for solver-level
|
|
@@ -37,13 +37,13 @@ SolverResult(command, returncode, stdout, stderr, duration,
|
|
|
37
37
|
|
|
38
38
|
Three easily-confused numbers:
|
|
39
39
|
|
|
40
|
-
- `
|
|
40
|
+
- `timeout` (a `SolverRunner` parameter) — the *limit*;
|
|
41
41
|
- `duration` (a `SolverResult` field) — the *actual* time it ran, whether it
|
|
42
42
|
finished or was killed;
|
|
43
43
|
- `timed_out` (a `SolverResult` field) — whether that limit was hit.
|
|
44
44
|
|
|
45
45
|
```python
|
|
46
|
-
runner = SolverRunner(
|
|
46
|
+
runner = SolverRunner(timeout=10.0)
|
|
47
47
|
|
|
48
48
|
runner.run(['sleep', '3']) # duration ~3.0, timed_out False, returncode 0
|
|
49
49
|
runner.run(['sleep', '15']) # duration ~10.0, timed_out True, returncode -1
|
|
@@ -87,7 +87,7 @@ SolverResult(ls /nonexistent) -> FAILED (rc=1, t=0.01s)
|
|
|
87
87
|
```python
|
|
88
88
|
from solver_support import SolverRunner
|
|
89
89
|
|
|
90
|
-
runner = SolverRunner(
|
|
90
|
+
runner = SolverRunner(timeout=30.0)
|
|
91
91
|
result = runner.run(['savilerow', 'model.eprime', 'problem.param'])
|
|
92
92
|
|
|
93
93
|
if result.timed_out:
|
|
@@ -146,6 +146,68 @@ wires only the objective-file route will find continue-scan stops at the first
|
|
|
146
146
|
solving horizon on those backends instead of improving — wire
|
|
147
147
|
`set_raw_objective_from_solution` to use them for optimisation models.
|
|
148
148
|
|
|
149
|
+
#### The three time limits, and how they relate
|
|
150
|
+
|
|
151
|
+
A Savile Row run is bounded three times over, and the three are **not**
|
|
152
|
+
interchangeable. Getting the relationship wrong is silent, so it is worth
|
|
153
|
+
stating once:
|
|
154
|
+
|
|
155
|
+
| limit | set by | what it actually bounds |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `-timelimit T` | `build_savilerow_command(timeout=T)` | tailoring. Nominally translation *plus* solving, but Savile Row stops binding it once a solver is running |
|
|
158
|
+
| the solver's own (`--time=S`, …) | `build_savilerow_command(solver_timeout=S)` | search |
|
|
159
|
+
| the external kill | `SolverRunner(timeout=…)` | the whole process tree |
|
|
160
|
+
|
|
161
|
+
**The first two are consecutive, so they add.** Savile Row tailors, and only
|
|
162
|
+
then launches the solver, so the wall clock the pair permits is `T + S` — not
|
|
163
|
+
`max(T, S)`. Left unsplit (`S` defaulting to `T`) that is `2T`, which is why an
|
|
164
|
+
external kill set at `T` can fire while the toolchain is still inside the limits
|
|
165
|
+
it was given.
|
|
166
|
+
|
|
167
|
+
**An early kill costs every statistic, not just the tail.** Savile Row
|
|
168
|
+
accumulates `SATVars`, `SATClauses`, `SolverNodes` and the
|
|
169
|
+
`SavileRowTotalTime` / `SolverTotalTime` split in memory and writes the `.info`
|
|
170
|
+
once, after the solver returns. Kill the tree and the output directory holds an
|
|
171
|
+
`.eprime` and perhaps an `.eprime-minion`, never an `.eprime-info`, so
|
|
172
|
+
`extract_savilerow_info` has nothing to read and the run cannot say even whether
|
|
173
|
+
it reached the solver.
|
|
174
|
+
|
|
175
|
+
So size the external kill with `savilerow_backstop(T, S)` — `T + S` plus grace
|
|
176
|
+
for the flush — and it comes to mean "the toolchain ignored its limits", a
|
|
177
|
+
genuine fault rather than an ordinary and lossy way for a run to end.
|
|
178
|
+
|
|
179
|
+
**To hold a run to a wall-clock budget, shrink the pair, not the backstop.**
|
|
180
|
+
Give the solver `S = T - (what tailoring will cost)`. The core picks no reserve
|
|
181
|
+
policy: a horizon scan can estimate from the previous horizon's
|
|
182
|
+
`SavileRowTotalTime`, a single-shot run has nothing to estimate from, and only
|
|
183
|
+
the caller knows which it is.
|
|
184
|
+
|
|
185
|
+
#### Verdicts: refuted vs gave up
|
|
186
|
+
|
|
187
|
+
`No solution found.` is what Savile Row prints **both** when the solver proved
|
|
188
|
+
there is none and when it stopped at its own limit without deciding, and
|
|
189
|
+
`solution_found` is False for both. The first is a result; the second
|
|
190
|
+
establishes nothing. `SolverStatsExtractor.savilerow_verdict` separates them and
|
|
191
|
+
`_record_savilerow_times` records the answer on the phase as `verdict`:
|
|
192
|
+
|
|
193
|
+
| verdict | evidence | meaning |
|
|
194
|
+
|---|---|---|
|
|
195
|
+
| `sat` / `unsat` | `SolverSatisfiable` present | decided (`proven_optimal` too, on optimisation models) |
|
|
196
|
+
| `unknown` | absent, with `verdict_reason` | `solver-limit`, `tailoring-limit`, `memory`, `encoding-limit`, or `unexplained` |
|
|
197
|
+
| `error` | `SavileRowUncaughtException` | Savile Row itself failed |
|
|
198
|
+
| `killed` | no `.info` at all | recorded by the caller, from the phase's `timed_out` / `launch_failed` |
|
|
199
|
+
|
|
200
|
+
Decidedness keys on `SolverSatisfiable` being **present**, deliberately not on
|
|
201
|
+
the solver having run: Savile Row can settle an instance during tailoring and
|
|
202
|
+
never invoke a solver, leaving `SolverTotalTime: 0` beside a perfectly good
|
|
203
|
+
verdict. Testing "did the solver run?" reports every one of those as undecided.
|
|
204
|
+
|
|
205
|
+
**A horizon's `unsat` is about the bound, not the problem.** It means "not at
|
|
206
|
+
this horizon", never "not at all" — refuting horizons 1..N says nothing about
|
|
207
|
+
N+1. Only the registry knows which models decide the problem itself
|
|
208
|
+
(`get_proves_unsat`), which is why the run-level `outcome` is derived in
|
|
209
|
+
`RunSummary.finalize` and never aggregated from these.
|
|
210
|
+
|
|
149
211
|
### Fast Downward / SymK (PDDL models)
|
|
150
212
|
|
|
151
213
|
- **Shape**: not a horizon scan — one `solving` phase whose A\* (blind) / symbolic
|
|
@@ -210,15 +272,46 @@ solving horizon on those backends instead of improving — wire
|
|
|
210
272
|
- **Split is Savile-Row-only and best-effort.** `savilerow_time` / `solver_time`
|
|
211
273
|
are `None` for planners and for any Savile Row horizon whose `.info` was
|
|
212
274
|
missing.
|
|
275
|
+
- **In-band limits add; the external kill must sit above their sum.** See "the
|
|
276
|
+
three time limits" above. `savilerow_backstop` derives it from the same
|
|
277
|
+
numbers the builder gets.
|
|
278
|
+
- **A clean exit is not an answer.** A solver given its own limit stops *itself*
|
|
279
|
+
and exits 0, so every phase of a fruitless run can succeed. Run success is
|
|
280
|
+
decided by `outcome`, never by exit codes.
|
|
281
|
+
|
|
282
|
+
## outcomes
|
|
283
|
+
|
|
284
|
+
`final_status` answers two questions at once — what was learned, and why the run
|
|
285
|
+
stopped — so `RunSummary.finalize` records them separately. `final_status` keeps
|
|
286
|
+
its meaning; these sit alongside it.
|
|
287
|
+
|
|
288
|
+
| field | values |
|
|
289
|
+
|---|---|
|
|
290
|
+
| `outcome` | `solved` / `unsat` / `unknown` / `error` |
|
|
291
|
+
| `stop_reason` | `complete` / `time` / `horizons` / `interrupt` / `error` |
|
|
292
|
+
|
|
293
|
+
`success` is `outcome in {solved, unsat}`: **a run succeeded if it produced a
|
|
294
|
+
definitive answer in the budget it was given.** A proof that no solution exists
|
|
295
|
+
answers the question as surely as a solution does, even though the planner that
|
|
296
|
+
produced it exits non-zero.
|
|
297
|
+
|
|
298
|
+
`unsat` requires the caller to pass `finalize(..., proves_unsat=True)`, normally
|
|
299
|
+
from the registry's `get_proves_unsat`. The core cannot tell from the output — a
|
|
300
|
+
bounded CP model and a complete planner print the same thing — and left False no
|
|
301
|
+
run is ever concluded unsat, which understates rather than overstates what was
|
|
302
|
+
proved. A status that stopped early (`timeout`, `hlim`, `stopped`) is never
|
|
303
|
+
`unsat` however complete the model.
|
|
213
304
|
|
|
214
305
|
## what the core owns
|
|
215
306
|
|
|
216
307
|
| concern | location |
|
|
217
308
|
|---------|----------|
|
|
218
309
|
| per-solver stat parsers, cost-layer assembly, dispatch | `solver_stats.py` |
|
|
310
|
+
| per-run Savile Row verdict (refuted vs gave up) | `solver_stats.py` (`savilerow_verdict`) |
|
|
219
311
|
| Savile Row `.info` split recording (per horizon) | `solver_horizon.py` (`_record_savilerow_times`) |
|
|
220
312
|
| stats attached to a phase after each solve | `solver_runner.py`, `solver_horizon.py` |
|
|
221
|
-
| the phase model (`RunSummary` / `PhaseResult`) | `run_summary.py` |
|
|
313
|
+
| the phase model (`RunSummary` / `PhaseResult`), outcome and success | `run_summary.py` |
|
|
314
|
+
| relating the in-band limits to the external kill | `command_builders.py` (`savilerow_backstop`) |
|
|
222
315
|
|
|
223
316
|
Turning these per-phase stats into a unified per-horizon curve for display, a
|
|
224
317
|
single "preprocess" baseline, and plots is the **consumer's** job — it reads the
|
|
@@ -11,10 +11,16 @@ build-backend = "setuptools.build_meta"
|
|
|
11
11
|
|
|
12
12
|
[project]
|
|
13
13
|
name = "solver-support"
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
|
|
14
|
+
# The single source of the version. `solver_support.__version__` reads it back
|
|
15
|
+
# from the installed distribution metadata, so there is no literal in the
|
|
16
|
+
# package to drift from this one.
|
|
17
|
+
#
|
|
18
|
+
# Static rather than `dynamic = ["version"]` with a `{attr = ...}` pointing into
|
|
19
|
+
# the package: PEP 621 treats `version` as a static field, and `dynamic` is the
|
|
20
|
+
# escape hatch for a version that genuinely cannot be known without building.
|
|
21
|
+
# Ours can. Carrying a `.devN` suffix between releases keeps the working tree
|
|
22
|
+
# from claiming a version that is already tagged and published.
|
|
23
|
+
version = "0.3.1"
|
|
18
24
|
description = "Generic Conjure / Savile Row / Fast Downward / SymK / ENHSP / UPF solver pipeline"
|
|
19
25
|
readme = "README.md"
|
|
20
26
|
requires-python = ">=3.9"
|
|
@@ -72,9 +78,6 @@ dev = [
|
|
|
72
78
|
[tool.setuptools]
|
|
73
79
|
zip-safe = false
|
|
74
80
|
|
|
75
|
-
[tool.setuptools.dynamic]
|
|
76
|
-
version = {attr = "solver_support.__version__"}
|
|
77
|
-
|
|
78
81
|
[tool.setuptools.packages.find]
|
|
79
82
|
include = ["solver_support*"]
|
|
80
83
|
|
|
@@ -13,7 +13,28 @@ 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
|
-
|
|
16
|
+
# Read back from the installed distribution metadata; the version itself is
|
|
17
|
+
# declared once, in pyproject.toml. There is deliberately no literal here to
|
|
18
|
+
# drift from it - the failure being avoided is a release where the metadata says
|
|
19
|
+
# one thing and `__version__` another, each individually plausible and nothing
|
|
20
|
+
# catching the disagreement.
|
|
21
|
+
#
|
|
22
|
+
# Resolved eagerly rather than behind a PEP 562 `__getattr__`: the lookup scans
|
|
23
|
+
# sys.path for a matching .dist-info, which costs a fraction of a millisecond,
|
|
24
|
+
# and this module already imports psutil, PyYAML and every solver backend below.
|
|
25
|
+
# Deferring it would save nothing measurable against that.
|
|
26
|
+
#
|
|
27
|
+
# The fallback fires only in a source tree that was never installed. Note an
|
|
28
|
+
# *editable* install pins its .dist-info at install time, so a checkout whose
|
|
29
|
+
# version has moved on still reports the older one here until it is reinstalled
|
|
30
|
+
# - the price of single-sourcing from metadata rather than from a literal.
|
|
31
|
+
try:
|
|
32
|
+
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
|
|
33
|
+
from importlib.metadata import version as _version
|
|
34
|
+
|
|
35
|
+
__version__ = _version("solver-support")
|
|
36
|
+
except _PackageNotFoundError: # a source tree, never installed
|
|
37
|
+
__version__ = "0+unknown"
|
|
17
38
|
|
|
18
39
|
# --- model registry (loader + command builders + packaging injection) ---
|
|
19
40
|
from .model_registry import (
|
|
@@ -25,6 +46,7 @@ from .model_registry import (
|
|
|
25
46
|
set_resource_root,
|
|
26
47
|
set_user_config_finder,
|
|
27
48
|
)
|
|
49
|
+
from .command_builders import savilerow_backstop, SAVILEROW_FLUSH_GRACE
|
|
28
50
|
|
|
29
51
|
# --- run configuration singleton ---
|
|
30
52
|
from .global_state import global_state, GlobalState
|
|
@@ -85,6 +107,8 @@ __all__ = [
|
|
|
85
107
|
"set_default_config_path",
|
|
86
108
|
"set_resource_root",
|
|
87
109
|
"set_user_config_finder",
|
|
110
|
+
"savilerow_backstop",
|
|
111
|
+
"SAVILEROW_FLUSH_GRACE",
|
|
88
112
|
# run config
|
|
89
113
|
"global_state",
|
|
90
114
|
"GlobalState",
|
|
@@ -21,6 +21,41 @@ from typing import List, Optional
|
|
|
21
21
|
from .model_registry import ModelRegistryError
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
#: Seconds of slack between the in-band limits and the external kill. Savile Row
|
|
25
|
+
#: needs a moment *after* its own limit fires to write the ``.info`` file (it
|
|
26
|
+
#: accumulates every statistic in memory and flushes once), and Conjure to
|
|
27
|
+
#: translate a solution back. A kill set exactly at the in-band limit races that
|
|
28
|
+
#: flush and generally wins, so the run that was about to report its statistics
|
|
29
|
+
#: reports nothing at all - the failure is worst precisely for the instances
|
|
30
|
+
#: where all the work was tailoring, which are the ones the limit bounded.
|
|
31
|
+
SAVILEROW_FLUSH_GRACE = 30
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def savilerow_backstop(timeout: int, solver_timeout: Optional[int] = None,
|
|
35
|
+
grace: int = SAVILEROW_FLUSH_GRACE) -> int:
|
|
36
|
+
"""The external timeout that cannot preempt Savile Row's own limits.
|
|
37
|
+
|
|
38
|
+
Savile Row's ``-timelimit`` and the backend solver's own limit are budgets
|
|
39
|
+
spent *one after the other* - it tailors, then runs the solver - so the wall
|
|
40
|
+
clock they jointly permit is their SUM, not the larger of the two. An
|
|
41
|
+
external kill below that sum will sometimes fire while the toolchain is
|
|
42
|
+
still inside the limits it was given, and because Savile Row writes its
|
|
43
|
+
``.info`` only at the very end, firing early discards every statistic the
|
|
44
|
+
run had gathered.
|
|
45
|
+
|
|
46
|
+
Sized from the same numbers that reach :meth:`build_savilerow_command`, so
|
|
47
|
+
the two cannot drift apart. Passing this to the runner makes an external
|
|
48
|
+
kill mean "the toolchain ignored its limits", which is a genuine fault,
|
|
49
|
+
rather than an ordinary and silently lossy way for a run to end.
|
|
50
|
+
|
|
51
|
+
This is a *backstop*, not a wall-clock budget. To hold a run to ``T``
|
|
52
|
+
seconds of wall clock, do not shrink this - shrink the in-band pair so that
|
|
53
|
+
their sum is ``T`` (give the solver ``T`` minus what tailoring will cost)
|
|
54
|
+
and let the backstop sit above it, unreached.
|
|
55
|
+
"""
|
|
56
|
+
return timeout + (timeout if solver_timeout is None else solver_timeout) + grace
|
|
57
|
+
|
|
58
|
+
|
|
24
59
|
class CommandBuilderMixin:
|
|
25
60
|
"""The ``build_*_command`` methods mixed into :class:`ModelRegistry`."""
|
|
26
61
|
|
|
@@ -108,7 +143,8 @@ class CommandBuilderMixin:
|
|
|
108
143
|
objective_file_path: Optional[str] = None,
|
|
109
144
|
extra_options: Optional[List[str]] = None,
|
|
110
145
|
solver_name: str = 'kissat',
|
|
111
|
-
tune: Optional[str] = None
|
|
146
|
+
tune: Optional[str] = None,
|
|
147
|
+
solver_timeout: Optional[int] = None) -> List[str]:
|
|
112
148
|
"""
|
|
113
149
|
Build a Savile Row command with proper flags for horizon scanning.
|
|
114
150
|
|
|
@@ -120,7 +156,28 @@ class CommandBuilderMixin:
|
|
|
120
156
|
model_path: path to the model file
|
|
121
157
|
param_path: path to the parameter file
|
|
122
158
|
use_srjava: use Java savilerow instead of savilerow-native
|
|
123
|
-
timeout:
|
|
159
|
+
timeout: Savile Row's own ``-timelimit``, in seconds (uses
|
|
160
|
+
``default_timeout`` if None). Covers tailoring *plus* solving,
|
|
161
|
+
but stops binding once a solver is running - so in practice it
|
|
162
|
+
bounds tailoring.
|
|
163
|
+
solver_timeout: the backend solver's own limit, if it should differ
|
|
164
|
+
from ``timeout``. Defaults to ``timeout``, which is what every
|
|
165
|
+
caller got before this existed.
|
|
166
|
+
|
|
167
|
+
These are two budgets spent one after the other, not one budget
|
|
168
|
+
named twice: Savile Row tailors, and only then runs the solver.
|
|
169
|
+
Passing the same number to both grants up to ``2 x timeout`` of
|
|
170
|
+
wall clock, which is why an external kill set at ``timeout``
|
|
171
|
+
fires mid-search and takes the statistics with it - Savile Row
|
|
172
|
+
accumulates them in memory and writes the ``.info`` once, after
|
|
173
|
+
the solver it launched returns.
|
|
174
|
+
|
|
175
|
+
The split is what lets a caller keep a real wall-clock budget:
|
|
176
|
+
reserve what tailoring will cost and give the solver the rest.
|
|
177
|
+
The core picks no reserve policy - a horizon scan can measure
|
|
178
|
+
the previous horizon's ``SavileRowTotalTime``, a single-shot run
|
|
179
|
+
cannot - so the number is the caller's. See
|
|
180
|
+
:func:`savilerow_backstop` for sizing the external kill.
|
|
124
181
|
objective_file_path: path for intermediate objective values file (default: obj.txt)
|
|
125
182
|
extra_options: additional command line options to pass to Savile Row
|
|
126
183
|
solver_name: name of the solver configuration to use (default: 'kissat').
|
|
@@ -226,12 +283,18 @@ class CommandBuilderMixin:
|
|
|
226
283
|
# The solver's own limit, in whatever flag and unit that solver
|
|
227
284
|
# speaks. A config may override the dialect (or set it to null to
|
|
228
285
|
# suppress it) without the core having to learn a new backend.
|
|
286
|
+
#
|
|
287
|
+
# Defaults to `timeout`, so a caller that says nothing gets exactly
|
|
288
|
+
# the argv it always got. Note the two limits are consecutive
|
|
289
|
+
# budgets - Savile Row tailors, then runs the solver - so leaving
|
|
290
|
+
# them equal permits roughly twice `timeout` of wall clock.
|
|
229
291
|
option = solver_config.get('solver_timeout_option', spec['timeout_option'])
|
|
230
292
|
if option:
|
|
231
293
|
scale = spec['timeout_scale']
|
|
294
|
+
budget = timeout if solver_timeout is None else solver_timeout
|
|
232
295
|
# Left untouched when the solver already speaks seconds, so those
|
|
233
296
|
# argvs keep their exact previous form (--time=45, never 45.0).
|
|
234
|
-
value =
|
|
297
|
+
value = budget if scale == 1 else int(budget * scale)
|
|
235
298
|
args.extend(["-solver-options", option.format(value=value)])
|
|
236
299
|
# Savile Row's own limit covers translation plus solving and is always
|
|
237
300
|
# in seconds. Always added, so a backend with no limit of its own is
|
|
@@ -105,17 +105,49 @@ _NON_SOLVING_PHASES = frozenset({"translation", "modelling", "visualisation"})
|
|
|
105
105
|
# visualiser or .sol export would silently flip a solved run to success=False.)
|
|
106
106
|
_OPTIONAL_PHASES = frozenset({"visualisation", "export"})
|
|
107
107
|
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
|
|
108
|
+
# --- what a run concluded, and why it stopped ---
|
|
109
|
+
#
|
|
110
|
+
# `final_status` alone cannot answer "did this run learn anything?", because it
|
|
111
|
+
# conflates two independent questions. 'timeout' covers both a run that found a
|
|
112
|
+
# solution before the budget ran out and one that found nothing; 'failed' covers
|
|
113
|
+
# both a planner proving no plan exists and a crash. So the two questions are
|
|
114
|
+
# recorded separately:
|
|
115
|
+
#
|
|
116
|
+
# outcome what was learned: solved / unsat / unknown / error
|
|
117
|
+
# stop_reason why it ended: complete / time / horizons / interrupt / error
|
|
118
|
+
#
|
|
119
|
+
# `final_status` keeps its current meaning and is untouched - it is the
|
|
120
|
+
# orchestrator's own label, and consumers key on it.
|
|
121
|
+
OUTCOMES = ('solved', 'unsat', 'unknown', 'error')
|
|
122
|
+
STOP_REASONS = ('complete', 'time', 'horizons', 'interrupt', 'error')
|
|
123
|
+
|
|
124
|
+
# A run succeeded if it produced a DEFINITIVE ANSWER in the budget it was given
|
|
125
|
+
# - a solution, or a sound proof that none exists. Both answer the question the
|
|
126
|
+
# run was asked; 'unknown' and 'error' do not. Note this is decided on the
|
|
127
|
+
# outcome and not on exit codes: a planner proving unsatisfiability exits
|
|
128
|
+
# non-zero and its phase records success=False, yet the run succeeded.
|
|
129
|
+
_SUCCESS_OUTCOMES = frozenset({'solved', 'unsat'})
|
|
130
|
+
|
|
131
|
+
# final_status values where the search RAN TO COMPLETION, so "no solution" can
|
|
132
|
+
# be a verdict rather than an interruption. On any other status the search
|
|
133
|
+
# stopped early and the outcome can only be 'unknown', however complete the
|
|
134
|
+
# model - which is what stops 'hlim' being read as a proof. ('failed' is here
|
|
135
|
+
# because that is the status a planner's proven-unsat arrives under: it exits
|
|
136
|
+
# non-zero, having answered the question.)
|
|
137
|
+
_COMPLETED_STATUSES = frozenset({"done", "success", "failed"})
|
|
138
|
+
|
|
139
|
+
# final_status -> why the run stopped. Statuses absent here leave stop_reason
|
|
140
|
+
# empty rather than guessing: an unrecognised label is a consumer's own, and
|
|
141
|
+
# inventing a reason for it would put words in its mouth.
|
|
142
|
+
_STOP_REASONS_BY_STATUS = {
|
|
143
|
+
"done": "complete",
|
|
144
|
+
"success": "complete",
|
|
145
|
+
"failed": "complete",
|
|
146
|
+
"launch-failed": "error",
|
|
147
|
+
"timeout": "time",
|
|
148
|
+
"hlim": "horizons",
|
|
149
|
+
"stopped": "interrupt",
|
|
150
|
+
}
|
|
119
151
|
|
|
120
152
|
|
|
121
153
|
@dataclass
|
|
@@ -136,6 +168,12 @@ class RunSummary:
|
|
|
136
168
|
success: bool = False # Overall success
|
|
137
169
|
solution_found: bool = False # Whether solution was found
|
|
138
170
|
final_status: str = "" # Final status message
|
|
171
|
+
# What the run concluded and why it stopped - the two questions final_status
|
|
172
|
+
# conflates (see OUTCOMES / STOP_REASONS above). Derived by finalize();
|
|
173
|
+
# empty on a summary that was never finalized, and on one loaded from a file
|
|
174
|
+
# written before these existed.
|
|
175
|
+
outcome: str = "" # solved / unsat / unknown / error
|
|
176
|
+
stop_reason: str = "" # complete / time / horizons / interrupt / error
|
|
139
177
|
score: Optional[int] = None # Total score the model reported (scored/upf models only)
|
|
140
178
|
startat: Optional[int] = None # Starting horizon for Savile Row models (if specified)
|
|
141
179
|
stopat: Optional[int] = None # Highest horizon tried, if the default cap (100) was overridden
|
|
@@ -177,8 +215,42 @@ class RunSummary:
|
|
|
177
215
|
"""
|
|
178
216
|
self.success = all(p.success for p in self.required_phases)
|
|
179
217
|
|
|
180
|
-
def
|
|
181
|
-
|
|
218
|
+
def _derive_outcome(self, final_status: str, proves_unsat: bool,
|
|
219
|
+
has_timeout_solution: bool) -> str:
|
|
220
|
+
"""What this run learned: solved / unsat / unknown / error."""
|
|
221
|
+
# A solution in hand wins over any status label: a run that solved the
|
|
222
|
+
# instance and was then interrupted, or that banked an anytime solution
|
|
223
|
+
# before its budget expired, still solved it.
|
|
224
|
+
if self.solution_found or has_timeout_solution:
|
|
225
|
+
return 'solved'
|
|
226
|
+
|
|
227
|
+
if final_status == "launch-failed":
|
|
228
|
+
return 'error'
|
|
229
|
+
|
|
230
|
+
if final_status in _COMPLETED_STATUSES:
|
|
231
|
+
# The search finished and produced nothing. From a model that
|
|
232
|
+
# decides the problem that IS the answer; from any other model it
|
|
233
|
+
# means something went wrong, because a bounded model that finishes
|
|
234
|
+
# unsolved has not concluded anything -- it has merely stopped.
|
|
235
|
+
return 'unsat' if proves_unsat else 'error'
|
|
236
|
+
|
|
237
|
+
# 'timeout', 'hlim', 'stopped', or a label this core does not know: the
|
|
238
|
+
# search stopped early. Nothing was decided, and no completeness of the
|
|
239
|
+
# model can change that -- refuting horizons 1..N says nothing about
|
|
240
|
+
# N+1, which is exactly why 'hlim' must never become 'unsat'.
|
|
241
|
+
return 'unknown'
|
|
242
|
+
|
|
243
|
+
def finalize(self, final_status: str = "done",
|
|
244
|
+
proves_unsat: bool = False) -> None:
|
|
245
|
+
"""Finalize the summary: end time, outcome, stop reason, success.
|
|
246
|
+
|
|
247
|
+
``proves_unsat`` says whether this run's model is a complete decision
|
|
248
|
+
procedure, i.e. whether its "no solution" is a sound proof. The core
|
|
249
|
+
cannot know that from the output - a bounded CP model and a planner print
|
|
250
|
+
the same thing - so the caller supplies it, normally from the registry's
|
|
251
|
+
``get_proves_unsat``. Left False, a no-solution run is never concluded
|
|
252
|
+
unsat, which is the safe direction: understating what was proved.
|
|
253
|
+
"""
|
|
182
254
|
self.end_time = datetime.now()
|
|
183
255
|
self.final_status = final_status
|
|
184
256
|
|
|
@@ -191,23 +263,19 @@ class RunSummary:
|
|
|
191
263
|
for phase in self.phases
|
|
192
264
|
)
|
|
193
265
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
and all(p.success for p in required)) or has_timeout_solution
|
|
208
|
-
else:
|
|
209
|
-
self.success = False
|
|
210
|
-
|
|
266
|
+
self.outcome = self._derive_outcome(
|
|
267
|
+
final_status, proves_unsat, has_timeout_solution)
|
|
268
|
+
self.stop_reason = _STOP_REASONS_BY_STATUS.get(final_status, "")
|
|
269
|
+
# Decided on the answer, not on exit codes. The previous rule -- "no
|
|
270
|
+
# declared failure status AND every required phase exited 0" -- called a
|
|
271
|
+
# run successful whenever its subprocesses were tidy, so a horizon scan
|
|
272
|
+
# that ran out of time having found nothing reported success=True: the
|
|
273
|
+
# solvers had each stopped themselves cleanly at the limit they were
|
|
274
|
+
# given. Under this rule that run is 'unknown', and a planner's
|
|
275
|
+
# proven-unsat (which exits non-zero) is a success.
|
|
276
|
+
self.success = self.outcome in _SUCCESS_OUTCOMES
|
|
277
|
+
|
|
278
|
+
|
|
211
279
|
# Extract the highest score from any phase that has one
|
|
212
280
|
scores = []
|
|
213
281
|
for phase in self.phases:
|