solver-support 0.2.1__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.1/solver_support.egg-info → solver_support-0.3.1}/PKG-INFO +26 -18
- {solver_support-0.2.1 → solver_support-0.3.1}/README.md +25 -17
- {solver_support-0.2.1 → solver_support-0.3.1}/docs/solver-runner.md +3 -3
- {solver_support-0.2.1 → solver_support-0.3.1}/docs/timing-model.md +122 -7
- {solver_support-0.2.1 → solver_support-0.3.1}/pyproject.toml +10 -7
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support/__init__.py +25 -1
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support/command_builders.py +66 -3
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support/model_registry.py +81 -4
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support/run_summary.py +98 -30
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support/solver_horizon.py +43 -5
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support/solver_stats.py +104 -0
- {solver_support-0.2.1 → solver_support-0.3.1/solver_support.egg-info}/PKG-INFO +26 -18
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support.egg-info/SOURCES.txt +2 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/tests/test_command_builders.py +56 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/tests/test_import.py +20 -0
- solver_support-0.3.1/tests/test_registry_dump.py +93 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/tests/test_run_summary.py +5 -4
- {solver_support-0.2.1 → solver_support-0.3.1}/tests/test_run_summary_success.py +58 -6
- solver_support-0.3.1/tests/test_savilerow_info_recording.py +173 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/tests/test_solver_runner.py +3 -2
- {solver_support-0.2.1 → solver_support-0.3.1}/tests/test_solver_stats.py +113 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/LICENSE +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/MANIFEST.in +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/docs/injection-api.md +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/setup.cfg +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support/file_management.py +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support/global_state.py +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support/horizon_utils.py +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support/objective.py +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support/solver_base.py +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support/solver_enhsp.py +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support/solver_fd.py +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support/solver_runner.py +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support/solver_upfplan.py +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support/utilities.py +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support.egg-info/dependency_links.txt +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support.egg-info/not-zip-safe +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support.egg-info/requires.txt +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/solver_support.egg-info/top_level.txt +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/tests/conftest.py +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/tests/fixtures/models.yaml +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/tests/test_helpers.py +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/tests/test_horizon_utils.py +0 -0
- {solver_support-0.2.1 → solver_support-0.3.1}/tests/test_solution_naming.py +0 -0
- {solver_support-0.2.1 → 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:
|
|
@@ -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
|
|
|
@@ -124,6 +146,68 @@ wires only the objective-file route will find continue-scan stops at the first
|
|
|
124
146
|
solving horizon on those backends instead of improving — wire
|
|
125
147
|
`set_raw_objective_from_solution` to use them for optimisation models.
|
|
126
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
|
+
|
|
127
211
|
### Fast Downward / SymK (PDDL models)
|
|
128
212
|
|
|
129
213
|
- **Shape**: not a horizon scan — one `solving` phase whose A\* (blind) / symbolic
|
|
@@ -188,15 +272,46 @@ solving horizon on those backends instead of improving — wire
|
|
|
188
272
|
- **Split is Savile-Row-only and best-effort.** `savilerow_time` / `solver_time`
|
|
189
273
|
are `None` for planners and for any Savile Row horizon whose `.info` was
|
|
190
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.
|
|
191
304
|
|
|
192
305
|
## what the core owns
|
|
193
306
|
|
|
194
307
|
| concern | location |
|
|
195
308
|
|---------|----------|
|
|
196
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`) |
|
|
197
311
|
| Savile Row `.info` split recording (per horizon) | `solver_horizon.py` (`_record_savilerow_times`) |
|
|
198
312
|
| stats attached to a phase after each solve | `solver_runner.py`, `solver_horizon.py` |
|
|
199
|
-
| 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`) |
|
|
200
315
|
|
|
201
316
|
Turning these per-phase stats into a unified per-horizon curve for display, a
|
|
202
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
|
|
@@ -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.
|