solver-support 0.3.2__tar.gz → 0.4.0__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.3.2/solver_support.egg-info → solver_support-0.4.0}/PKG-INFO +38 -5
- {solver_support-0.3.2 → solver_support-0.4.0}/README.md +36 -3
- {solver_support-0.3.2 → solver_support-0.4.0}/docs/injection-api.md +6 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/docs/solver-runner.md +26 -3
- {solver_support-0.3.2 → solver_support-0.4.0}/docs/timing-model.md +93 -1
- {solver_support-0.3.2 → solver_support-0.4.0}/pyproject.toml +7 -2
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/__init__.py +17 -3
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/command_builders.py +29 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/model_registry.py +68 -10
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/run_summary.py +55 -13
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/solver_fd.py +65 -31
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/solver_runner.py +45 -13
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/solver_stats.py +136 -0
- solver_support-0.4.0/solver_support/starter/models.yaml +144 -0
- {solver_support-0.3.2 → solver_support-0.4.0/solver_support.egg-info}/PKG-INFO +38 -5
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support.egg-info/SOURCES.txt +4 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/tests/fixtures/models.yaml +44 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_command_builders.py +21 -0
- solver_support-0.4.0/tests/test_fd_verdict.py +148 -0
- solver_support-0.4.0/tests/test_proves_unsat.py +50 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_run_summary_success.py +58 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_solver_runner.py +53 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_solvers.py +86 -6
- solver_support-0.4.0/tests/test_starter_config.py +85 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/LICENSE +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/MANIFEST.in +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/setup.cfg +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/file_management.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/global_state.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/horizon_utils.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/objective.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/solver_base.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/solver_enhsp.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/solver_horizon.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/solver_upfplan.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/utilities.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support.egg-info/dependency_links.txt +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support.egg-info/not-zip-safe +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support.egg-info/requires.txt +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/solver_support.egg-info/top_level.txt +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/tests/conftest.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_helpers.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_horizon_utils.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_import.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_registry_dump.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_run_summary.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_savilerow_info_recording.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_solution_naming.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_solver_stats.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: solver-support
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
7
7
|
Project-URL: Homepage, https://github.com/ott2/solver-support
|
|
8
8
|
Project-URL: Repository, https://github.com/ott2/solver-support
|
|
9
9
|
Project-URL: Issues, https://github.com/ott2/solver-support/issues
|
|
10
|
-
Classifier: Development Status ::
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
11
|
Classifier: Intended Audience :: Developers
|
|
12
12
|
Classifier: Intended Audience :: Science/Research
|
|
13
13
|
Classifier: Operating System :: OS Independent
|
|
@@ -48,8 +48,10 @@ consumer supplies:
|
|
|
48
48
|
|
|
49
49
|
## Status
|
|
50
50
|
|
|
51
|
-
**
|
|
52
|
-
by its own test suite
|
|
51
|
+
**Beta.** The pipeline is in day-to-day use by several consumers and is covered
|
|
52
|
+
by its own test suite. The injection seams and the run-summary vocabulary
|
|
53
|
+
(`outcome`, `stop_reason`, `verdict`) are stable: later releases add to them
|
|
54
|
+
rather than rename them. Other API may still shift between 0.x releases. Each
|
|
53
55
|
release's changes are listed on the
|
|
54
56
|
[releases page](https://github.com/ott2/solver-support/releases).
|
|
55
57
|
|
|
@@ -70,6 +72,37 @@ Requires Python 3.9 or newer (3.9.6 is the system Python on macOS, and is
|
|
|
70
72
|
supported deliberately — each release is built and tested against it). The only
|
|
71
73
|
runtime dependencies are `psutil` and `PyYAML`.
|
|
72
74
|
|
|
75
|
+
## Quick start
|
|
76
|
+
|
|
77
|
+
The package ships no model registry, but it carries a commented starter one
|
|
78
|
+
(Fast Downward, SymK, ENHSP and Savile Row) to copy and edit. Writing it out
|
|
79
|
+
does not wire it; you point the registry at it:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from pathlib import Path
|
|
83
|
+
from solver_support import (
|
|
84
|
+
write_starter_config, set_default_config_path, set_resource_root,
|
|
85
|
+
global_state, SolverRunner, FastDownwardSolver, fd_backstop,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
config = write_starter_config("config/models.yaml") # once; then edit it
|
|
89
|
+
set_default_config_path(config)
|
|
90
|
+
set_resource_root(".") # where the models' `path:` entries resolve
|
|
91
|
+
|
|
92
|
+
registry = global_state.model_registry
|
|
93
|
+
runner = SolverRunner(instance="p01", model="pddl", solver="fd", timeout=60,
|
|
94
|
+
timestamp="run1", summary_file=Path("p01.json"))
|
|
95
|
+
try:
|
|
96
|
+
plan = FastDownwardSolver(runner).solve(
|
|
97
|
+
Path("p01.pddl"), model_path=str(registry.get_model_path("pddl")),
|
|
98
|
+
watchdog=fd_backstop(60)) # let the planner's own limit fire
|
|
99
|
+
status = "done" if plan else "failed"
|
|
100
|
+
except TimeoutError:
|
|
101
|
+
status = "timeout"
|
|
102
|
+
runner.finalize_summary(status, proves_unsat=registry.get_proves_unsat("pddl"))
|
|
103
|
+
print(runner.summary.outcome) # solved / unsat / unknown / error
|
|
104
|
+
```
|
|
105
|
+
|
|
73
106
|
## Usage
|
|
74
107
|
|
|
75
108
|
See [`docs/injection-api.md`](https://github.com/ott2/solver-support/blob/main/docs/injection-api.md)
|
|
@@ -125,4 +158,4 @@ config — that shape is the contract the loader implements.
|
|
|
125
158
|
BSD-3-Clause — see
|
|
126
159
|
[`LICENSE`](https://github.com/ott2/solver-support/blob/main/LICENSE).
|
|
127
160
|
|
|
128
|
-
By András Salamon, with Claude Opus 4.8 and 5.
|
|
161
|
+
By András Salamon, with Claude Opus 4.8, 5 and 5.5.
|
|
@@ -20,8 +20,10 @@ consumer supplies:
|
|
|
20
20
|
|
|
21
21
|
## Status
|
|
22
22
|
|
|
23
|
-
**
|
|
24
|
-
by its own test suite
|
|
23
|
+
**Beta.** The pipeline is in day-to-day use by several consumers and is covered
|
|
24
|
+
by its own test suite. The injection seams and the run-summary vocabulary
|
|
25
|
+
(`outcome`, `stop_reason`, `verdict`) are stable: later releases add to them
|
|
26
|
+
rather than rename them. Other API may still shift between 0.x releases. Each
|
|
25
27
|
release's changes are listed on the
|
|
26
28
|
[releases page](https://github.com/ott2/solver-support/releases).
|
|
27
29
|
|
|
@@ -42,6 +44,37 @@ Requires Python 3.9 or newer (3.9.6 is the system Python on macOS, and is
|
|
|
42
44
|
supported deliberately — each release is built and tested against it). The only
|
|
43
45
|
runtime dependencies are `psutil` and `PyYAML`.
|
|
44
46
|
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
The package ships no model registry, but it carries a commented starter one
|
|
50
|
+
(Fast Downward, SymK, ENHSP and Savile Row) to copy and edit. Writing it out
|
|
51
|
+
does not wire it; you point the registry at it:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
from solver_support import (
|
|
56
|
+
write_starter_config, set_default_config_path, set_resource_root,
|
|
57
|
+
global_state, SolverRunner, FastDownwardSolver, fd_backstop,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
config = write_starter_config("config/models.yaml") # once; then edit it
|
|
61
|
+
set_default_config_path(config)
|
|
62
|
+
set_resource_root(".") # where the models' `path:` entries resolve
|
|
63
|
+
|
|
64
|
+
registry = global_state.model_registry
|
|
65
|
+
runner = SolverRunner(instance="p01", model="pddl", solver="fd", timeout=60,
|
|
66
|
+
timestamp="run1", summary_file=Path("p01.json"))
|
|
67
|
+
try:
|
|
68
|
+
plan = FastDownwardSolver(runner).solve(
|
|
69
|
+
Path("p01.pddl"), model_path=str(registry.get_model_path("pddl")),
|
|
70
|
+
watchdog=fd_backstop(60)) # let the planner's own limit fire
|
|
71
|
+
status = "done" if plan else "failed"
|
|
72
|
+
except TimeoutError:
|
|
73
|
+
status = "timeout"
|
|
74
|
+
runner.finalize_summary(status, proves_unsat=registry.get_proves_unsat("pddl"))
|
|
75
|
+
print(runner.summary.outcome) # solved / unsat / unknown / error
|
|
76
|
+
```
|
|
77
|
+
|
|
45
78
|
## Usage
|
|
46
79
|
|
|
47
80
|
See [`docs/injection-api.md`](https://github.com/ott2/solver-support/blob/main/docs/injection-api.md)
|
|
@@ -97,4 +130,4 @@ config — that shape is the contract the loader implements.
|
|
|
97
130
|
BSD-3-Clause — see
|
|
98
131
|
[`LICENSE`](https://github.com/ott2/solver-support/blob/main/LICENSE).
|
|
99
132
|
|
|
100
|
-
By András Salamon, with Claude Opus 4.8 and 5.
|
|
133
|
+
By András Salamon, with Claude Opus 4.8, 5 and 5.5.
|
|
@@ -28,6 +28,12 @@ candidates bind when the registry is constructed rather than at import. Until a
|
|
|
28
28
|
default config path is registered, constructing `ModelRegistry()` raises
|
|
29
29
|
`ModelRegistryError` rather than guessing a consumer's config.
|
|
30
30
|
|
|
31
|
+
To start a config from scratch, `write_starter_config(path)` writes out the
|
|
32
|
+
commented starter registry that ships as package data (`starter_config_text()`
|
|
33
|
+
returns its text). Writing it out wires nothing. The registry still raises until
|
|
34
|
+
`set_default_config_path` names it, so a starter file on disk can never change
|
|
35
|
+
what an unwired core does.
|
|
36
|
+
|
|
31
37
|
### 2. objective extraction (`solver_support.objective`)
|
|
32
38
|
|
|
33
39
|
The core asks for a solution's objective at a few points but does not know how a
|
|
@@ -21,13 +21,15 @@ the pipeline branches on.
|
|
|
21
21
|
|
|
22
22
|
```python
|
|
23
23
|
SolverResult(command, returncode, stdout, stderr, duration,
|
|
24
|
-
timed_out=False, interrupted=False
|
|
24
|
+
timed_out=False, interrupted=False, launch_failed=False,
|
|
25
|
+
ok_returncodes=None)
|
|
25
26
|
```
|
|
26
27
|
|
|
27
28
|
| field | meaning |
|
|
28
29
|
|-------|---------|
|
|
29
30
|
| `command: List[str]` | the command and arguments that ran |
|
|
30
|
-
| `returncode: int` | process return code
|
|
31
|
+
| `returncode: int` | process return code |
|
|
32
|
+
| `ok_returncodes` | the codes that count as success; `{0}` unless the caller says otherwise |
|
|
31
33
|
| `stdout: str` / `stderr: str` | captured output, kept separate |
|
|
32
34
|
| `duration: float` | **actual** seconds the command ran |
|
|
33
35
|
| `timed_out: bool` | it was killed for exceeding the timeout |
|
|
@@ -54,9 +56,30 @@ a component of it — see the invariants in [`timing-model.md`](timing-model.md)
|
|
|
54
56
|
|
|
55
57
|
### properties
|
|
56
58
|
|
|
57
|
-
- `success: bool` —
|
|
59
|
+
- `success: bool` — exited with one of its `ok_returncodes`, no timeout, no
|
|
60
|
+
interruption, no launch failure.
|
|
58
61
|
- `failed: bool` — the inverse; useful as a direct `if` on a run.
|
|
59
62
|
|
|
63
|
+
### solvers that answer through the exit code
|
|
64
|
+
|
|
65
|
+
Some solvers put the *answer* in the exit code, so for them a non-zero exit is
|
|
66
|
+
the solver doing its job. Every DIMACS SAT solver exits 10 for SATISFIABLE and
|
|
67
|
+
20 for UNSATISFIABLE; read against `{0}`, a refutation would be logged as
|
|
68
|
+
`Command failed` and recorded as `success=False`. Pass the solver's set to `run`
|
|
69
|
+
or `run_phase`:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from solver_support.solver_runner import DIMACS_SAT_RETURNCODES
|
|
73
|
+
|
|
74
|
+
result = runner.run(['kissat', 'model.cnf'], ok_returncodes=DIMACS_SAT_RETURNCODES)
|
|
75
|
+
result.success # True for 0 (unknown), 10 (sat) and 20 (unsat)
|
|
76
|
+
result.returncode # which of them
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`success` then means "the process ran and answered", not what the answer was:
|
|
80
|
+
read `returncode` (or the parsed stats) for that. The runner never picks the set
|
|
81
|
+
by detecting the solver from the command, so detection cannot decide success.
|
|
82
|
+
|
|
60
83
|
Note that `failed` covers three *different* outcomes, and the pipeline usually
|
|
61
84
|
cares which: check `timed_out` and `interrupted` before treating a run as a
|
|
62
85
|
genuine solver failure. A timed-out anytime solver may still have banked a
|
|
@@ -238,6 +238,73 @@ N+1. Only the registry knows which models decide the problem itself
|
|
|
238
238
|
- **Timeout**: the final, cut-off layer is bounded by `end_hint = duration -
|
|
239
239
|
translate_time` (the search-relative kill time), so the blown-up deepest bound
|
|
240
240
|
does not collapse to ~0.
|
|
241
|
+
- **Its own limit, and the backstop above it**: `--search-time-limit T` is a CPU
|
|
242
|
+
rlimit on the search alone, while the runner's watchdog counts wall clock over
|
|
243
|
+
translation plus search. A watchdog at `T` therefore always fires first, and
|
|
244
|
+
the run loses the exit 23 and the final statistics block it would have
|
|
245
|
+
written. Size the watchdog with `fd_backstop(T, translate_allowance)`, which
|
|
246
|
+
is `T`, plus translation, plus `FD_EXIT_GRACE`, and pass it as
|
|
247
|
+
`FastDownwardSolver.solve(..., watchdog=...)`. The runner's own timeout stays
|
|
248
|
+
the in-band limit. Then an external kill once again means the planner ignored
|
|
249
|
+
its limit. As with Savile Row, this is a
|
|
250
|
+
backstop and not a budget: to hold a run to a wall-clock `T`, shrink the search
|
|
251
|
+
limit.
|
|
252
|
+
|
|
253
|
+
#### Verdicts: proof, limit, or rejection
|
|
254
|
+
|
|
255
|
+
Fast Downward answers through its **exit code** (`driver/returncodes.py`), so
|
|
256
|
+
`run_phase` is handed `FD_ANSWER_RETURNCODES` and a proof is not logged as a
|
|
257
|
+
failed command. `SolverStatsExtractor.fd_verdict` reads the code, plus the log
|
|
258
|
+
where the code is ambiguous, into the same vocabulary as the Savile Row verdict.
|
|
259
|
+
`FastDownwardSolver` records it on the `solving` phase:
|
|
260
|
+
|
|
261
|
+
| exit | verdict | `verdict_reason` |
|
|
262
|
+
|---|---|---|
|
|
263
|
+
| 0–3 (plan; 1–3 then hit a limit) | `sat` | |
|
|
264
|
+
| 10, 11 | `unsat` | (the planner's own claim) |
|
|
265
|
+
| 12, see below | `unsat` / `unknown` | `exhausted`, or `search-limit` / `bounded` / `portfolio` / `incomplete` |
|
|
266
|
+
| 13 (26.6+) | `unknown` | `bounded` |
|
|
267
|
+
| 20 / 21 | `unknown` | `translate-memory` / `translate-limit` |
|
|
268
|
+
| 22, 24 / 23 | `unknown` | `memory` / `search-limit` |
|
|
269
|
+
| 34, 37 | `error` | `unsupported` (`unknown`/`portfolio` for an anytime portfolio) |
|
|
270
|
+
| 31, 33, 36 | `error` | `input-error` |
|
|
271
|
+
| 30, 32, 35, a signal | `error` | `crash` |
|
|
272
|
+
| killed by the runner | `killed` | |
|
|
273
|
+
|
|
274
|
+
**Exit 12 is ambiguous by construction.** Before 26.6 the planner exits
|
|
275
|
+
`found_solution() ? 0 : 12` however the search ended, so the log decides:
|
|
276
|
+
|
|
277
|
+
- `Time limit reached. Abort search.` (the search's `max_time=`) gives
|
|
278
|
+
`search-limit`.
|
|
279
|
+
- `Completely explored state space -- no solution!` gives `unsat`/`exhausted`,
|
|
280
|
+
or `bounded` if the `--search` string has a `bound=`. The log looks the same
|
|
281
|
+
either way, so the argv is read for it. The translator's
|
|
282
|
+
`Generating unsolvable task` lands here too, since the search then exhausts a
|
|
283
|
+
trivially false goal.
|
|
284
|
+
- SymK prints no exhaustion line. Its last `BOUND: L < U` line, with `L ≥ U`, is
|
|
285
|
+
`exhausted` when `U` is infinity (`2147483647`) and `bounded` otherwise.
|
|
286
|
+
- A portfolio's 12 is always `portfolio`. It can mask its components'
|
|
287
|
+
time-outs, and its exhaustion line may come from a component that pruned
|
|
288
|
+
unsafely.
|
|
289
|
+
- Anything else is `incomplete`: EHC giving up, or a 26.6 search that the
|
|
290
|
+
planner itself judged incomplete (`Search terminated without finding a
|
|
291
|
+
plan!`).
|
|
292
|
+
|
|
293
|
+
**`exhausted` is this library's reading, not the planner's.** It is sound only
|
|
294
|
+
if the search is complete and prunes safely. A relaxation heuristic prunes
|
|
295
|
+
infinite-valued states even on a task with axioms, where infinity is not a
|
|
296
|
+
dead end, so `astar(ff())` there "exhausts" without proving anything. The log
|
|
297
|
+
cannot reveal this. From 26.6 the planner checks it (upstream issue1201): a
|
|
298
|
+
safe exhaustion exits 11 and an unsafe one exits 12 with no exhaustion line, so
|
|
299
|
+
`exhausted` only ever comes from older planners and SymK. Whether it becomes a
|
|
300
|
+
run-level `unsat` is decided by the registry's `proves-unsat`, which a tune
|
|
301
|
+
preset can switch off.
|
|
302
|
+
|
|
303
|
+
**The planner's own time limit is a timeout.** `search-limit` and
|
|
304
|
+
`translate-limit` make `solve()` raise `TimeoutError`, exactly as a watchdog
|
|
305
|
+
kill does, and so does an anytime portfolio that ends without a plan. Every other
|
|
306
|
+
no-plan ending returns `None`, and the run's `outcome` is derived from the
|
|
307
|
+
verdict.
|
|
241
308
|
|
|
242
309
|
### UPF (via the consumer's UPF CLI; PDDL or constructed models)
|
|
243
310
|
|
|
@@ -319,16 +386,41 @@ run is ever concluded unsat, which understates rather than overstates what was
|
|
|
319
386
|
proved. A status that stopped early (`timeout`, `hlim`, `stopped`) is never
|
|
320
387
|
`unsat` however complete the model.
|
|
321
388
|
|
|
389
|
+
For a planner, being able to prove depends on the **search** as well as the model.
|
|
390
|
+
`get_proves_unsat(model, tune=..., solver_name=...)` is the model's
|
|
391
|
+
`proves-unsat` AND the effective tune's. A tune preset may carry
|
|
392
|
+
`proves-unsat: false` (for example a relaxation heuristic that prunes unsafely
|
|
393
|
+
on tasks with axioms), which takes the claim away. A tune can never grant it to
|
|
394
|
+
a model that cannot prove. `finalize` keeps the value on the summary as
|
|
395
|
+
`proves_unsat`, and a later `finalize` that passes None reuses it. So a
|
|
396
|
+
re-finalize that folds in a late phase, as `horizon_utils` does, cannot drop it.
|
|
397
|
+
|
|
398
|
+
**A `solving` phase's verdict decides a completed run.** `failed` is the status
|
|
399
|
+
of both a planner's proof and its crash, so `proves_unsat` alone would promote
|
|
400
|
+
every crash of a complete model to `unsat`. Where the `solving` phase recorded a
|
|
401
|
+
`verdict` (Fast Downward and SymK do, see above), it decides instead:
|
|
402
|
+
|
|
403
|
+
| verdict | `proves_unsat` | outcome |
|
|
404
|
+
|---|---|---|
|
|
405
|
+
| `unsat` | True | `unsat` |
|
|
406
|
+
| `unsat` | False | `unknown`: the search ran out, but cannot prove |
|
|
407
|
+
| `unknown` / `killed` | either | `unknown` |
|
|
408
|
+
| `error` | either | `error` |
|
|
409
|
+
|
|
410
|
+
A run with no verdict is read as before. Per-horizon verdicts
|
|
411
|
+
(`solving-horizon-N`) are about one bound each and never decide the run.
|
|
412
|
+
|
|
322
413
|
## what the core owns
|
|
323
414
|
|
|
324
415
|
| concern | location |
|
|
325
416
|
|---------|----------|
|
|
326
417
|
| per-solver stat parsers, cost-layer assembly, dispatch | `solver_stats.py` |
|
|
327
418
|
| per-run Savile Row verdict (refuted vs gave up) | `solver_stats.py` (`savilerow_verdict`) |
|
|
419
|
+
| per-run Fast Downward / SymK verdict (exit code + log) | `solver_stats.py` (`fd_verdict`) |
|
|
328
420
|
| Savile Row `.info` split recording (per horizon) | `solver_horizon.py` (`_record_savilerow_times`) |
|
|
329
421
|
| stats attached to a phase after each solve | `solver_runner.py`, `solver_horizon.py` |
|
|
330
422
|
| the phase model (`RunSummary` / `PhaseResult`), outcome and success | `run_summary.py` |
|
|
331
|
-
| relating the in-band limits to the external kill | `command_builders.py` (`savilerow_backstop`) |
|
|
423
|
+
| relating the in-band limits to the external kill | `command_builders.py` (`savilerow_backstop`, `fd_backstop`) |
|
|
332
424
|
|
|
333
425
|
Turning these per-phase stats into a unified per-horizon curve for display, a
|
|
334
426
|
single "preprocess" baseline, and plots is the **consumer's** job — it reads the
|
|
@@ -20,7 +20,7 @@ name = "solver-support"
|
|
|
20
20
|
# escape hatch for a version that genuinely cannot be known without building.
|
|
21
21
|
# Ours can. Carrying a `.devN` suffix between releases keeps the working tree
|
|
22
22
|
# from claiming a version that is already tagged and published.
|
|
23
|
-
version = "0.
|
|
23
|
+
version = "0.4.0"
|
|
24
24
|
description = "Generic Conjure / Savile Row / Fast Downward / SymK / ENHSP / UPF solver pipeline"
|
|
25
25
|
readme = "README.md"
|
|
26
26
|
requires-python = ">=3.9"
|
|
@@ -29,7 +29,7 @@ authors = [
|
|
|
29
29
|
{name = "András Salamon"},
|
|
30
30
|
]
|
|
31
31
|
classifiers = [
|
|
32
|
-
"Development Status ::
|
|
32
|
+
"Development Status :: 4 - Beta",
|
|
33
33
|
"Intended Audience :: Developers",
|
|
34
34
|
"Intended Audience :: Science/Research",
|
|
35
35
|
"Operating System :: OS Independent",
|
|
@@ -81,6 +81,11 @@ zip-safe = false
|
|
|
81
81
|
[tool.setuptools.packages.find]
|
|
82
82
|
include = ["solver_support*"]
|
|
83
83
|
|
|
84
|
+
# The starter registry `write_starter_config` copies out. Package data, not a
|
|
85
|
+
# default: nothing loads it unless a user writes it out and wires it.
|
|
86
|
+
[tool.setuptools.package-data]
|
|
87
|
+
solver_support = ["starter/*.yaml"]
|
|
88
|
+
|
|
84
89
|
[tool.pytest.ini_options]
|
|
85
90
|
testpaths = ["tests"]
|
|
86
91
|
python_files = ["test_*.py"]
|
|
@@ -45,8 +45,12 @@ from .model_registry import (
|
|
|
45
45
|
set_default_config_path,
|
|
46
46
|
set_resource_root,
|
|
47
47
|
set_user_config_finder,
|
|
48
|
+
starter_config_text,
|
|
49
|
+
write_starter_config,
|
|
50
|
+
)
|
|
51
|
+
from .command_builders import (
|
|
52
|
+
savilerow_backstop, SAVILEROW_FLUSH_GRACE, fd_backstop, FD_EXIT_GRACE,
|
|
48
53
|
)
|
|
49
|
-
from .command_builders import savilerow_backstop, SAVILEROW_FLUSH_GRACE
|
|
50
54
|
|
|
51
55
|
# --- run configuration singleton ---
|
|
52
56
|
from .global_state import global_state, GlobalState
|
|
@@ -68,8 +72,12 @@ from .solver_upfplan import UpfPlanSolver
|
|
|
68
72
|
from .solver_horizon import HorizonBasedSolver, SavileRowSolver, ConjureSolver
|
|
69
73
|
|
|
70
74
|
# --- execution, stats, run model ---
|
|
71
|
-
from .solver_runner import
|
|
72
|
-
|
|
75
|
+
from .solver_runner import (
|
|
76
|
+
SolverRunner, SolverResult, SolverExecutableError, DIMACS_SAT_RETURNCODES,
|
|
77
|
+
)
|
|
78
|
+
from .solver_stats import (
|
|
79
|
+
SolverStatsExtractor, detect_solver_from_command, FD_ANSWER_RETURNCODES,
|
|
80
|
+
)
|
|
73
81
|
from .run_summary import RunSummary, PhaseResult, create_run_summary
|
|
74
82
|
|
|
75
83
|
# --- file naming and horizon loop ---
|
|
@@ -107,8 +115,12 @@ __all__ = [
|
|
|
107
115
|
"set_default_config_path",
|
|
108
116
|
"set_resource_root",
|
|
109
117
|
"set_user_config_finder",
|
|
118
|
+
"starter_config_text",
|
|
119
|
+
"write_starter_config",
|
|
110
120
|
"savilerow_backstop",
|
|
111
121
|
"SAVILEROW_FLUSH_GRACE",
|
|
122
|
+
"fd_backstop",
|
|
123
|
+
"FD_EXIT_GRACE",
|
|
112
124
|
# run config
|
|
113
125
|
"global_state",
|
|
114
126
|
"GlobalState",
|
|
@@ -130,6 +142,8 @@ __all__ = [
|
|
|
130
142
|
"SolverRunner",
|
|
131
143
|
"SolverResult",
|
|
132
144
|
"SolverExecutableError",
|
|
145
|
+
"DIMACS_SAT_RETURNCODES",
|
|
146
|
+
"FD_ANSWER_RETURNCODES",
|
|
133
147
|
"SolverStatsExtractor",
|
|
134
148
|
"detect_solver_from_command",
|
|
135
149
|
"RunSummary",
|
|
@@ -56,6 +56,35 @@ def savilerow_backstop(timeout: int, solver_timeout: Optional[int] = None,
|
|
|
56
56
|
return timeout + (timeout if solver_timeout is None else solver_timeout) + grace
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
#: Seconds of slack above Fast Downward's in-band search limit. The driver turns
|
|
60
|
+
#: SIGXCPU into exit 23 and the search then prints its final statistics block
|
|
61
|
+
#: ("Actual search time", expansions); the grace lets that land. It is also what
|
|
62
|
+
#: absorbs the gap between the CPU seconds the limit counts and the wall-clock
|
|
63
|
+
#: seconds the watchdog counts, which a loaded machine widens.
|
|
64
|
+
FD_EXIT_GRACE = 30
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def fd_backstop(timeout: int, translate_allowance: Optional[int] = None,
|
|
68
|
+
grace: int = FD_EXIT_GRACE) -> int:
|
|
69
|
+
"""The external timeout that cannot preempt Fast Downward's own limit.
|
|
70
|
+
|
|
71
|
+
:meth:`build_fd_command` passes ``--search-time-limit T``, a CPU rlimit
|
|
72
|
+
on the *search* alone, while the runner's watchdog kills at ``T`` seconds of
|
|
73
|
+
wall clock over translation *plus* search. CPU time never exceeds the wall
|
|
74
|
+
time since search began, so a watchdog at ``T`` always fires first and the
|
|
75
|
+
in-band limit never gets to: the run is SIGTERMed instead of exiting 23, its
|
|
76
|
+
statistics are lost, and its verdict can only say ``killed``.
|
|
77
|
+
|
|
78
|
+
Sized like :func:`savilerow_backstop`: the search limit, plus what
|
|
79
|
+
translation may take before it (``translate_allowance``, defaulting to the
|
|
80
|
+
search limit, since translation is unbounded in band and only the caller
|
|
81
|
+
knows its cost), plus grace. The same caveat applies: this is a backstop,
|
|
82
|
+
not a wall-clock budget. To hold a run to ``T``, shrink the search limit.
|
|
83
|
+
"""
|
|
84
|
+
allowance = timeout if translate_allowance is None else translate_allowance
|
|
85
|
+
return timeout + allowance + grace
|
|
86
|
+
|
|
87
|
+
|
|
59
88
|
class CommandBuilderMixin:
|
|
60
89
|
"""The ``build_*_command`` methods mixed into :class:`ModelRegistry`."""
|
|
61
90
|
|
|
@@ -581,26 +581,51 @@ class ModelRegistry(CommandBuilderMixin):
|
|
|
581
581
|
return False
|
|
582
582
|
return model_config.get('objective-in-solution', False)
|
|
583
583
|
|
|
584
|
-
def get_proves_unsat(self, model_type: str
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
'no solution' run is a genuine unsat
|
|
590
|
-
such field and so default to False -
|
|
591
|
-
|
|
584
|
+
def get_proves_unsat(self, model_type: str, tune: Optional[str] = None,
|
|
585
|
+
solver_name: Optional[str] = None) -> bool:
|
|
586
|
+
"""Whether a run of this model can soundly conclude unsatisfiability.
|
|
587
|
+
|
|
588
|
+
True only for complete decision procedures (planning models, flagged
|
|
589
|
+
``proves-unsat: true``) whose 'no solution' run is a genuine unsat
|
|
590
|
+
proof. Bounded CP models carry no such field and so default to False -
|
|
591
|
+
their 'failed'/'no solution' is an error or an unfinished horizon
|
|
592
|
+
search, never an unsat verdict.
|
|
593
|
+
|
|
594
|
+
For a planner, completeness also depends on the *search*: the same model
|
|
595
|
+
proves unsat under blind A* and not under a relaxation heuristic that
|
|
596
|
+
prunes unsafely on a task with axioms. So a tune preset may carry
|
|
597
|
+
``proves-unsat: false``, and the answer is the model's flag AND the
|
|
598
|
+
effective tune's. A tune can only take the claim away, never grant it to
|
|
599
|
+
a model that cannot prove.
|
|
592
600
|
|
|
593
601
|
Args:
|
|
594
602
|
model_type: The model type.
|
|
603
|
+
tune: The explicit ``--tune``, if any. Otherwise the model's pinned
|
|
604
|
+
``tune:``, then the solver's ``default_tune`` - the resolution
|
|
605
|
+
the solver used to build its command.
|
|
606
|
+
solver_name: The solver that ran, if not the model's own ``solver:``.
|
|
595
607
|
|
|
596
608
|
Returns:
|
|
597
|
-
True if the model declares ``proves-unsat: true
|
|
609
|
+
True if the model declares ``proves-unsat: true`` and the effective
|
|
610
|
+
tune does not declare ``proves-unsat: false``, else False.
|
|
598
611
|
"""
|
|
599
612
|
try:
|
|
600
613
|
_, model_config = self.resolve_model_variant(model_type)
|
|
601
614
|
except (ValueError, ModelRegistryError):
|
|
602
615
|
return False
|
|
603
|
-
|
|
616
|
+
if not model_config.get('proves-unsat', False):
|
|
617
|
+
return False
|
|
618
|
+
solver_name = solver_name or model_config.get('solver')
|
|
619
|
+
if not solver_name:
|
|
620
|
+
return True
|
|
621
|
+
try:
|
|
622
|
+
solver_config = self.get_solver_config(self.get_tune_backend(solver_name))
|
|
623
|
+
except ModelRegistryError:
|
|
624
|
+
return True
|
|
625
|
+
tunes = solver_config.get('tune', {})
|
|
626
|
+
tune = tune or model_config.get('tune')
|
|
627
|
+
name = tune if tune in tunes else solver_config.get('default_tune')
|
|
628
|
+
return bool(tunes.get(name, {}).get('proves-unsat', True))
|
|
604
629
|
|
|
605
630
|
def get_model_status(self, model_type: str) -> Tuple[Optional[str], Optional[str]]:
|
|
606
631
|
"""Return a model's health status and an optional detail string.
|
|
@@ -800,6 +825,39 @@ class ModelRegistry(CommandBuilderMixin):
|
|
|
800
825
|
return self.get_tune_regime(solver_name, tune) == 'anytime'
|
|
801
826
|
|
|
802
827
|
|
|
828
|
+
def starter_config_text() -> str:
|
|
829
|
+
"""The starter ``models.yaml`` shipped with the package, as text.
|
|
830
|
+
|
|
831
|
+
A commented example registry - Fast Downward, SymK, ENHSP and Savile Row -
|
|
832
|
+
for a new user to copy and edit. It is never loaded implicitly: the core
|
|
833
|
+
still ships unwired, and :class:`ModelRegistry` still raises until a config
|
|
834
|
+
path is set. Unlike :meth:`ModelRegistry.dump_config` it keeps its comments,
|
|
835
|
+
which is what makes it fit to start from.
|
|
836
|
+
"""
|
|
837
|
+
from importlib.resources import files
|
|
838
|
+
return files('solver_support').joinpath('starter', 'models.yaml').read_text(
|
|
839
|
+
encoding='utf-8')
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def write_starter_config(path, overwrite: bool = False) -> Path:
|
|
843
|
+
"""Write the starter ``models.yaml`` to ``path`` and return the path.
|
|
844
|
+
|
|
845
|
+
The quick start for a new user, who then wires it with
|
|
846
|
+
:func:`set_default_config_path`. Writing it out does *not* wire it: having
|
|
847
|
+
a starter file on disk must never change what an unwired registry does.
|
|
848
|
+
|
|
849
|
+
Raises:
|
|
850
|
+
FileExistsError: if ``path`` exists and ``overwrite`` is False - an edited
|
|
851
|
+
registry is the user's work, and a starter is not worth losing it for.
|
|
852
|
+
"""
|
|
853
|
+
path = Path(path)
|
|
854
|
+
if path.exists() and not overwrite:
|
|
855
|
+
raise FileExistsError(f"{path} already exists (pass overwrite=True to replace it)")
|
|
856
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
857
|
+
path.write_text(starter_config_text(), encoding='utf-8')
|
|
858
|
+
return path
|
|
859
|
+
|
|
860
|
+
|
|
803
861
|
def get_default_registry() -> ModelRegistry:
|
|
804
862
|
"""
|
|
805
863
|
Get a default model registry instance.
|
|
@@ -133,9 +133,20 @@ _SUCCESS_OUTCOMES = frozenset({'solved', 'unsat'})
|
|
|
133
133
|
# stopped early and the outcome can only be 'unknown', however complete the
|
|
134
134
|
# model - which is what stops 'hlim' being read as a proof. ('failed' is here
|
|
135
135
|
# because that is the status a planner's proven-unsat arrives under: it exits
|
|
136
|
-
# non-zero, having answered the question.
|
|
136
|
+
# non-zero, having answered the question. It is also the status of a crash, which
|
|
137
|
+
# is why a solving phase's `verdict`, where there is one, decides between them.)
|
|
137
138
|
_COMPLETED_STATUSES = frozenset({"done", "success", "failed"})
|
|
138
139
|
|
|
140
|
+
# A completed run with no solution, read through its solving phase's verdict
|
|
141
|
+
# (see SolverStatsExtractor.fd_verdict). `unsat` is handled separately: it needs
|
|
142
|
+
# the model's proves_unsat as well.
|
|
143
|
+
_OUTCOME_BY_VERDICT = {
|
|
144
|
+
'unknown': 'unknown', # stopped at a limit, or a search that cannot refute
|
|
145
|
+
'killed': 'unknown',
|
|
146
|
+
'error': 'error',
|
|
147
|
+
'sat': 'error', # claimed a plan, yet none reached the summary
|
|
148
|
+
}
|
|
149
|
+
|
|
139
150
|
# final_status -> why the run stopped. Statuses absent here leave stop_reason
|
|
140
151
|
# empty rather than guessing: an unrecognised label is a consumer's own, and
|
|
141
152
|
# inventing a reason for it would put words in its mouth.
|
|
@@ -174,6 +185,10 @@ class RunSummary:
|
|
|
174
185
|
# written before these existed.
|
|
175
186
|
outcome: str = "" # solved / unsat / unknown / error
|
|
176
187
|
stop_reason: str = "" # complete / time / horizons / interrupt / error
|
|
188
|
+
# Whether this run's model AND search can prove unsatisfiability (the
|
|
189
|
+
# registry's get_proves_unsat), as last given to finalize(). Kept so that a
|
|
190
|
+
# later re-finalize - after appending a phase, say - cannot drop it.
|
|
191
|
+
proves_unsat: bool = False
|
|
177
192
|
score: Optional[int] = None # Total score the model reported (scored/upf models only)
|
|
178
193
|
startat: Optional[int] = None # Starting horizon for Savile Row models (if specified)
|
|
179
194
|
stopat: Optional[int] = None # Highest horizon tried, if the default cap (100) was overridden
|
|
@@ -228,11 +243,22 @@ class RunSummary:
|
|
|
228
243
|
return 'error'
|
|
229
244
|
|
|
230
245
|
if final_status in _COMPLETED_STATUSES:
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
246
|
+
verdict = self._solving_verdict()
|
|
247
|
+
if verdict is None:
|
|
248
|
+
# No account of how the search ended, so read the status alone.
|
|
249
|
+
# The search finished and produced nothing. From a model that
|
|
250
|
+
# decides the problem that IS the answer; from any other model
|
|
251
|
+
# it means something went wrong, because a bounded model that
|
|
252
|
+
# finishes unsolved has not concluded anything -- it has merely
|
|
253
|
+
# stopped.
|
|
254
|
+
return 'unsat' if proves_unsat else 'error'
|
|
255
|
+
# The solver said how its search ended. Only a refutation from a
|
|
256
|
+
# search that can prove one is a proof; an exhausted search that
|
|
257
|
+
# cannot (an unsafe heuristic, a tune switched off) concluded
|
|
258
|
+
# nothing, but nothing went wrong either.
|
|
259
|
+
if verdict == 'unsat':
|
|
260
|
+
return 'unsat' if proves_unsat else 'unknown'
|
|
261
|
+
return _OUTCOME_BY_VERDICT.get(verdict, 'error')
|
|
236
262
|
|
|
237
263
|
# 'timeout', 'hlim', 'stopped', or a label this core does not know: the
|
|
238
264
|
# search stopped early. Nothing was decided, and no completeness of the
|
|
@@ -240,19 +266,35 @@ class RunSummary:
|
|
|
240
266
|
# N+1, which is exactly why 'hlim' must never become 'unsat'.
|
|
241
267
|
return 'unknown'
|
|
242
268
|
|
|
269
|
+
def _solving_verdict(self) -> Optional[str]:
|
|
270
|
+
"""The ``verdict`` the ``solving`` phase recorded, if it recorded one.
|
|
271
|
+
|
|
272
|
+
Only the single-phase solvers' ``solving`` phase: a horizon scan's
|
|
273
|
+
per-horizon verdicts are about one bound each, and never decide the run.
|
|
274
|
+
"""
|
|
275
|
+
phase = self.get_phase("solving")
|
|
276
|
+
if phase is None:
|
|
277
|
+
return None
|
|
278
|
+
return phase.solver_stats.get('verdict')
|
|
279
|
+
|
|
243
280
|
def finalize(self, final_status: str = "done",
|
|
244
|
-
proves_unsat: bool =
|
|
281
|
+
proves_unsat: Optional[bool] = None) -> None:
|
|
245
282
|
"""Finalize the summary: end time, outcome, stop reason, success.
|
|
246
283
|
|
|
247
|
-
``proves_unsat`` says whether this run's model
|
|
248
|
-
procedure, i.e. whether its "no solution" is
|
|
249
|
-
cannot know that from the output - a bounded CP
|
|
250
|
-
the same thing - so the caller supplies it,
|
|
251
|
-
``get_proves_unsat``. Left False, a
|
|
252
|
-
unsat, which is the safe direction:
|
|
284
|
+
``proves_unsat`` says whether this run's model, under the search it
|
|
285
|
+
ran, is a complete decision procedure, i.e. whether its "no solution" is
|
|
286
|
+
a sound proof. The core cannot know that from the output - a bounded CP
|
|
287
|
+
model and a planner print the same thing - so the caller supplies it,
|
|
288
|
+
normally from the registry's ``get_proves_unsat``. Left False, a
|
|
289
|
+
no-solution run is never concluded unsat, which is the safe direction:
|
|
290
|
+
understating what was proved. None keeps the value from the previous
|
|
291
|
+
finalize, so re-finalizing to fold in a late phase cannot drop it.
|
|
253
292
|
"""
|
|
254
293
|
self.end_time = datetime.now()
|
|
255
294
|
self.final_status = final_status
|
|
295
|
+
if proves_unsat is not None:
|
|
296
|
+
self.proves_unsat = proves_unsat
|
|
297
|
+
proves_unsat = self.proves_unsat
|
|
256
298
|
|
|
257
299
|
# Check if we have a successful timeout-based solution phase: an anytime
|
|
258
300
|
# run that found a solution before the budget ran out still succeeded.
|