solver-support 0.3.1__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.1/solver_support.egg-info → solver_support-0.4.0}/PKG-INFO +38 -5
- {solver_support-0.3.1 → solver_support-0.4.0}/README.md +36 -3
- {solver_support-0.3.1 → solver_support-0.4.0}/docs/injection-api.md +6 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/docs/solver-runner.md +26 -3
- {solver_support-0.3.1 → solver_support-0.4.0}/docs/timing-model.md +111 -2
- {solver_support-0.3.1 → solver_support-0.4.0}/pyproject.toml +7 -2
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support/__init__.py +17 -3
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support/command_builders.py +29 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support/model_registry.py +68 -10
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support/run_summary.py +55 -13
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support/solver_fd.py +65 -31
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support/solver_runner.py +45 -13
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support/solver_stats.py +163 -6
- solver_support-0.4.0/solver_support/starter/models.yaml +144 -0
- {solver_support-0.3.1 → solver_support-0.4.0/solver_support.egg-info}/PKG-INFO +38 -5
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support.egg-info/SOURCES.txt +4 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/tests/fixtures/models.yaml +44 -0
- {solver_support-0.3.1 → 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.1 → solver_support-0.4.0}/tests/test_run_summary_success.py +58 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/tests/test_solver_runner.py +53 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/tests/test_solver_stats.py +41 -0
- {solver_support-0.3.1 → 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.1 → solver_support-0.4.0}/LICENSE +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/MANIFEST.in +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/setup.cfg +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support/file_management.py +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support/global_state.py +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support/horizon_utils.py +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support/objective.py +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support/solver_base.py +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support/solver_enhsp.py +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support/solver_horizon.py +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support/solver_upfplan.py +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support/utilities.py +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support.egg-info/dependency_links.txt +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support.egg-info/not-zip-safe +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support.egg-info/requires.txt +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/solver_support.egg-info/top_level.txt +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/tests/conftest.py +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/tests/test_helpers.py +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/tests/test_horizon_utils.py +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/tests/test_import.py +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/tests/test_registry_dump.py +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/tests/test_run_summary.py +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/tests/test_savilerow_info_recording.py +0 -0
- {solver_support-0.3.1 → solver_support-0.4.0}/tests/test_solution_naming.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
|
|
@@ -193,7 +193,7 @@ establishes nothing. `SolverStatsExtractor.savilerow_verdict` separates them and
|
|
|
193
193
|
| verdict | evidence | meaning |
|
|
194
194
|
|---|---|---|
|
|
195
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` |
|
|
196
|
+
| `unknown` | absent, with `verdict_reason` | `solver-limit`, `tailoring-limit`, `memory`, `tailoring-memory`, `encoding-limit`, or `unexplained` |
|
|
197
197
|
| `error` | `SavileRowUncaughtException` | Savile Row itself failed |
|
|
198
198
|
| `killed` | no `.info` at all | recorded by the caller, from the phase's `timed_out` / `launch_failed` |
|
|
199
199
|
|
|
@@ -202,6 +202,23 @@ the solver having run: Savile Row can settle an instance during tailoring and
|
|
|
202
202
|
never invoke a solver, leaving `SolverTotalTime: 0` beside a perfectly good
|
|
203
203
|
verdict. Testing "did the solver run?" reports every one of those as undecided.
|
|
204
204
|
|
|
205
|
+
**Each limit names the process it bound.** `solver-limit` / `tailoring-limit`
|
|
206
|
+
are the two time limits (see above), and `memory` / `tailoring-memory` are the
|
|
207
|
+
same split for the heap: `SolverMemOut` is the backend, `SavileRowOutOfMemory`
|
|
208
|
+
the JVM running Savile Row. The front-end memory-out is `unknown` and not
|
|
209
|
+
`error` for the same reason its timeout is — a resource cap stopped the run
|
|
210
|
+
before it decided; nothing malfunctioned. It is checked **before** the verdict,
|
|
211
|
+
and before `SavileRowUncaughtException`: one heap exhaustion sets both flags,
|
|
212
|
+
because the `OutOfMemoryError` reaches Savile Row's top-level handler, and the
|
|
213
|
+
cause is a more useful report than the symptom. `error` is then what is left
|
|
214
|
+
when Savile Row has no account of what went wrong.
|
|
215
|
+
|
|
216
|
+
You will mostly see this flag if your limits are **in band**. A harness that
|
|
217
|
+
caps memory externally (`runsolver` and friends) kills the JVM before it reaches
|
|
218
|
+
its own `-Xmx`, so Savile Row never writes the flag and you get a killed process
|
|
219
|
+
with no statistics instead — the same trade the time limits make. Bounding a run
|
|
220
|
+
from the inside buys diagnostics that killing it from outside destroys.
|
|
221
|
+
|
|
205
222
|
**A horizon's `unsat` is about the bound, not the problem.** It means "not at
|
|
206
223
|
this horizon", never "not at all" — refuting horizons 1..N says nothing about
|
|
207
224
|
N+1. Only the registry knows which models decide the problem itself
|
|
@@ -221,6 +238,73 @@ N+1. Only the registry knows which models decide the problem itself
|
|
|
221
238
|
- **Timeout**: the final, cut-off layer is bounded by `end_hint = duration -
|
|
222
239
|
translate_time` (the search-relative kill time), so the blown-up deepest bound
|
|
223
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.
|
|
224
308
|
|
|
225
309
|
### UPF (via the consumer's UPF CLI; PDDL or constructed models)
|
|
226
310
|
|
|
@@ -302,16 +386,41 @@ run is ever concluded unsat, which understates rather than overstates what was
|
|
|
302
386
|
proved. A status that stopped early (`timeout`, `hlim`, `stopped`) is never
|
|
303
387
|
`unsat` however complete the model.
|
|
304
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
|
+
|
|
305
413
|
## what the core owns
|
|
306
414
|
|
|
307
415
|
| concern | location |
|
|
308
416
|
|---------|----------|
|
|
309
417
|
| per-solver stat parsers, cost-layer assembly, dispatch | `solver_stats.py` |
|
|
310
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`) |
|
|
311
420
|
| Savile Row `.info` split recording (per horizon) | `solver_horizon.py` (`_record_savilerow_times`) |
|
|
312
421
|
| stats attached to a phase after each solve | `solver_runner.py`, `solver_horizon.py` |
|
|
313
422
|
| 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`) |
|
|
423
|
+
| relating the in-band limits to the external kill | `command_builders.py` (`savilerow_backstop`, `fd_backstop`) |
|
|
315
424
|
|
|
316
425
|
Turning these per-phase stats into a unified per-horizon curve for display, a
|
|
317
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.
|