solver-support 0.3.2__tar.gz → 0.4.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.3.2/solver_support.egg-info → solver_support-0.4.1}/PKG-INFO +38 -5
- {solver_support-0.3.2 → solver_support-0.4.1}/README.md +36 -3
- {solver_support-0.3.2 → solver_support-0.4.1}/docs/injection-api.md +6 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/docs/solver-runner.md +26 -3
- {solver_support-0.3.2 → solver_support-0.4.1}/docs/timing-model.md +113 -1
- {solver_support-0.3.2 → solver_support-0.4.1}/pyproject.toml +7 -2
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/__init__.py +17 -3
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/command_builders.py +29 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/horizon_utils.py +35 -10
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/model_registry.py +133 -19
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/run_summary.py +55 -13
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/solver_fd.py +65 -31
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/solver_horizon.py +110 -22
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/solver_runner.py +45 -13
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/solver_stats.py +136 -0
- solver_support-0.4.1/solver_support/starter/models.yaml +189 -0
- {solver_support-0.3.2 → solver_support-0.4.1/solver_support.egg-info}/PKG-INFO +38 -5
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support.egg-info/SOURCES.txt +5 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/tests/fixtures/models.yaml +70 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_command_builders.py +21 -0
- solver_support-0.4.1/tests/test_fd_verdict.py +148 -0
- solver_support-0.4.1/tests/test_horizon_params.py +139 -0
- solver_support-0.4.1/tests/test_proves_unsat.py +50 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_run_summary_success.py +58 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_solver_runner.py +53 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_solvers.py +86 -6
- solver_support-0.4.1/tests/test_starter_config.py +160 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/LICENSE +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/MANIFEST.in +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/setup.cfg +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/file_management.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/global_state.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/objective.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/solver_base.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/solver_enhsp.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/solver_upfplan.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/utilities.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support.egg-info/dependency_links.txt +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support.egg-info/not-zip-safe +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support.egg-info/requires.txt +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/solver_support.egg-info/top_level.txt +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/tests/conftest.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_helpers.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_horizon_utils.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_import.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_registry_dump.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_run_summary.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_savilerow_info_recording.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_solution_naming.py +0 -0
- {solver_support-0.3.2 → solver_support-0.4.1}/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.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
|
|
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
|
+
(Conjure, Savile Row, Fast Downward, SymK and ENHSP) 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
|
+
(Conjure, Savile Row, Fast Downward, SymK and ENHSP) 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
|
|
@@ -70,6 +70,18 @@ fields. All times are seconds unless noted.
|
|
|
70
70
|
### Savile Row (Conjure / Essence Prime models)
|
|
71
71
|
|
|
72
72
|
- **Shape**: a horizon scan — one `solving-horizon-N` phase per move budget.
|
|
73
|
+
Each horizon reaches the model as `letting <name> be N`, appended to the
|
|
74
|
+
instance for every name in the model's `horizon-params` (or its solver's).
|
|
75
|
+
These must be exactly the parameters the model declares, since Conjure
|
|
76
|
+
rejects a `letting` for one it lacks. A config without the key gets the
|
|
77
|
+
historical names, `noSteps` (Savile Row) or `maxMoves` + `horizon` (Conjure),
|
|
78
|
+
so models written for those keep working. Only `letting` lines for those names
|
|
79
|
+
are dropped from the instance first, not any line that mentions one.
|
|
80
|
+
- **Which Savile Row entry**: Conjure only refines and translates. Each horizon
|
|
81
|
+
is tailored and solved by the Savile Row entry the `conjure` config names
|
|
82
|
+
(`savilerow: <entry>`), and an Essence run's `--tune` picks among that
|
|
83
|
+
entry's presets. A direct Savile Row scan uses the run's own solver. Without
|
|
84
|
+
either, it is `kissat`, the name older configs use.
|
|
73
85
|
- **Split source**: Savile Row writes `<param>.info` per horizon;
|
|
74
86
|
`_record_savilerow_times` (`solver_horizon.py`) reads `SavileRowTotalTime`
|
|
75
87
|
(tailoring) → `savilerow_time` and `SolverTotalTime` (backend search) →
|
|
@@ -176,6 +188,14 @@ So size the external kill with `savilerow_backstop(T, S)` — `T + S` plus grace
|
|
|
176
188
|
for the flush — and it comes to mean "the toolchain ignored its limits", a
|
|
177
189
|
genuine fault rather than an ordinary and lossy way for a run to end.
|
|
178
190
|
|
|
191
|
+
The built-in horizon scans do this per horizon when asked:
|
|
192
|
+
`SavileRowSolver.solve(..., backstop=True)` and `ConjureSolver.solve(...,
|
|
193
|
+
backstop=True)` kill each horizon at `savilerow_backstop(R)`, where `R` is the
|
|
194
|
+
budget that horizon's command was built with. Without it, each horizon is
|
|
195
|
+
killed at the runner's own timeout, as before. It is opt-in because a scan
|
|
196
|
+
killed at the backstop can overrun the runner's timeout by up to about that
|
|
197
|
+
timeout again, which would change the run times of an existing consumer.
|
|
198
|
+
|
|
179
199
|
**To hold a run to a wall-clock budget, shrink the pair, not the backstop.**
|
|
180
200
|
Give the solver `S = T - (what tailoring will cost)`. The core picks no reserve
|
|
181
201
|
policy: a horizon scan can estimate from the previous horizon's
|
|
@@ -238,6 +258,73 @@ N+1. Only the registry knows which models decide the problem itself
|
|
|
238
258
|
- **Timeout**: the final, cut-off layer is bounded by `end_hint = duration -
|
|
239
259
|
translate_time` (the search-relative kill time), so the blown-up deepest bound
|
|
240
260
|
does not collapse to ~0.
|
|
261
|
+
- **Its own limit, and the backstop above it**: `--search-time-limit T` is a CPU
|
|
262
|
+
rlimit on the search alone, while the runner's watchdog counts wall clock over
|
|
263
|
+
translation plus search. A watchdog at `T` therefore always fires first, and
|
|
264
|
+
the run loses the exit 23 and the final statistics block it would have
|
|
265
|
+
written. Size the watchdog with `fd_backstop(T, translate_allowance)`, which
|
|
266
|
+
is `T`, plus translation, plus `FD_EXIT_GRACE`, and pass it as
|
|
267
|
+
`FastDownwardSolver.solve(..., watchdog=...)`. The runner's own timeout stays
|
|
268
|
+
the in-band limit. Then an external kill once again means the planner ignored
|
|
269
|
+
its limit. As with Savile Row, this is a
|
|
270
|
+
backstop and not a budget: to hold a run to a wall-clock `T`, shrink the search
|
|
271
|
+
limit.
|
|
272
|
+
|
|
273
|
+
#### Verdicts: proof, limit, or rejection
|
|
274
|
+
|
|
275
|
+
Fast Downward answers through its **exit code** (`driver/returncodes.py`), so
|
|
276
|
+
`run_phase` is handed `FD_ANSWER_RETURNCODES` and a proof is not logged as a
|
|
277
|
+
failed command. `SolverStatsExtractor.fd_verdict` reads the code, plus the log
|
|
278
|
+
where the code is ambiguous, into the same vocabulary as the Savile Row verdict.
|
|
279
|
+
`FastDownwardSolver` records it on the `solving` phase:
|
|
280
|
+
|
|
281
|
+
| exit | verdict | `verdict_reason` |
|
|
282
|
+
|---|---|---|
|
|
283
|
+
| 0–3 (plan; 1–3 then hit a limit) | `sat` | |
|
|
284
|
+
| 10, 11 | `unsat` | (the planner's own claim) |
|
|
285
|
+
| 12, see below | `unsat` / `unknown` | `exhausted`, or `search-limit` / `bounded` / `portfolio` / `incomplete` |
|
|
286
|
+
| 13 (26.6+) | `unknown` | `bounded` |
|
|
287
|
+
| 20 / 21 | `unknown` | `translate-memory` / `translate-limit` |
|
|
288
|
+
| 22, 24 / 23 | `unknown` | `memory` / `search-limit` |
|
|
289
|
+
| 34, 37 | `error` | `unsupported` (`unknown`/`portfolio` for an anytime portfolio) |
|
|
290
|
+
| 31, 33, 36 | `error` | `input-error` |
|
|
291
|
+
| 30, 32, 35, a signal | `error` | `crash` |
|
|
292
|
+
| killed by the runner | `killed` | |
|
|
293
|
+
|
|
294
|
+
**Exit 12 is ambiguous by construction.** Before 26.6 the planner exits
|
|
295
|
+
`found_solution() ? 0 : 12` however the search ended, so the log decides:
|
|
296
|
+
|
|
297
|
+
- `Time limit reached. Abort search.` (the search's `max_time=`) gives
|
|
298
|
+
`search-limit`.
|
|
299
|
+
- `Completely explored state space -- no solution!` gives `unsat`/`exhausted`,
|
|
300
|
+
or `bounded` if the `--search` string has a `bound=`. The log looks the same
|
|
301
|
+
either way, so the argv is read for it. The translator's
|
|
302
|
+
`Generating unsolvable task` lands here too, since the search then exhausts a
|
|
303
|
+
trivially false goal.
|
|
304
|
+
- SymK prints no exhaustion line. Its last `BOUND: L < U` line, with `L ≥ U`, is
|
|
305
|
+
`exhausted` when `U` is infinity (`2147483647`) and `bounded` otherwise.
|
|
306
|
+
- A portfolio's 12 is always `portfolio`. It can mask its components'
|
|
307
|
+
time-outs, and its exhaustion line may come from a component that pruned
|
|
308
|
+
unsafely.
|
|
309
|
+
- Anything else is `incomplete`: EHC giving up, or a 26.6 search that the
|
|
310
|
+
planner itself judged incomplete (`Search terminated without finding a
|
|
311
|
+
plan!`).
|
|
312
|
+
|
|
313
|
+
**`exhausted` is this library's reading, not the planner's.** It is sound only
|
|
314
|
+
if the search is complete and prunes safely. A relaxation heuristic prunes
|
|
315
|
+
infinite-valued states even on a task with axioms, where infinity is not a
|
|
316
|
+
dead end, so `astar(ff())` there "exhausts" without proving anything. The log
|
|
317
|
+
cannot reveal this. From 26.6 the planner checks it (upstream issue1201): a
|
|
318
|
+
safe exhaustion exits 11 and an unsafe one exits 12 with no exhaustion line, so
|
|
319
|
+
`exhausted` only ever comes from older planners and SymK. Whether it becomes a
|
|
320
|
+
run-level `unsat` is decided by the registry's `proves-unsat`, which a tune
|
|
321
|
+
preset can switch off.
|
|
322
|
+
|
|
323
|
+
**The planner's own time limit is a timeout.** `search-limit` and
|
|
324
|
+
`translate-limit` make `solve()` raise `TimeoutError`, exactly as a watchdog
|
|
325
|
+
kill does, and so does an anytime portfolio that ends without a plan. Every other
|
|
326
|
+
no-plan ending returns `None`, and the run's `outcome` is derived from the
|
|
327
|
+
verdict.
|
|
241
328
|
|
|
242
329
|
### UPF (via the consumer's UPF CLI; PDDL or constructed models)
|
|
243
330
|
|
|
@@ -319,16 +406,41 @@ run is ever concluded unsat, which understates rather than overstates what was
|
|
|
319
406
|
proved. A status that stopped early (`timeout`, `hlim`, `stopped`) is never
|
|
320
407
|
`unsat` however complete the model.
|
|
321
408
|
|
|
409
|
+
For a planner, being able to prove depends on the **search** as well as the model.
|
|
410
|
+
`get_proves_unsat(model, tune=..., solver_name=...)` is the model's
|
|
411
|
+
`proves-unsat` AND the effective tune's. A tune preset may carry
|
|
412
|
+
`proves-unsat: false` (for example a relaxation heuristic that prunes unsafely
|
|
413
|
+
on tasks with axioms), which takes the claim away. A tune can never grant it to
|
|
414
|
+
a model that cannot prove. `finalize` keeps the value on the summary as
|
|
415
|
+
`proves_unsat`, and a later `finalize` that passes None reuses it. So a
|
|
416
|
+
re-finalize that folds in a late phase, as `horizon_utils` does, cannot drop it.
|
|
417
|
+
|
|
418
|
+
**A `solving` phase's verdict decides a completed run.** `failed` is the status
|
|
419
|
+
of both a planner's proof and its crash, so `proves_unsat` alone would promote
|
|
420
|
+
every crash of a complete model to `unsat`. Where the `solving` phase recorded a
|
|
421
|
+
`verdict` (Fast Downward and SymK do, see above), it decides instead:
|
|
422
|
+
|
|
423
|
+
| verdict | `proves_unsat` | outcome |
|
|
424
|
+
|---|---|---|
|
|
425
|
+
| `unsat` | True | `unsat` |
|
|
426
|
+
| `unsat` | False | `unknown`: the search ran out, but cannot prove |
|
|
427
|
+
| `unknown` / `killed` | either | `unknown` |
|
|
428
|
+
| `error` | either | `error` |
|
|
429
|
+
|
|
430
|
+
A run with no verdict is read as before. Per-horizon verdicts
|
|
431
|
+
(`solving-horizon-N`) are about one bound each and never decide the run.
|
|
432
|
+
|
|
322
433
|
## what the core owns
|
|
323
434
|
|
|
324
435
|
| concern | location |
|
|
325
436
|
|---------|----------|
|
|
326
437
|
| per-solver stat parsers, cost-layer assembly, dispatch | `solver_stats.py` |
|
|
327
438
|
| per-run Savile Row verdict (refuted vs gave up) | `solver_stats.py` (`savilerow_verdict`) |
|
|
439
|
+
| per-run Fast Downward / SymK verdict (exit code + log) | `solver_stats.py` (`fd_verdict`) |
|
|
328
440
|
| Savile Row `.info` split recording (per horizon) | `solver_horizon.py` (`_record_savilerow_times`) |
|
|
329
441
|
| stats attached to a phase after each solve | `solver_runner.py`, `solver_horizon.py` |
|
|
330
442
|
| 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`) |
|
|
443
|
+
| relating the in-band limits to the external kill | `command_builders.py` (`savilerow_backstop`, `fd_backstop`) |
|
|
332
444
|
|
|
333
445
|
Turning these per-phase stats into a unified per-horizon curve for display, a
|
|
334
446
|
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.1"
|
|
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
|
|
|
@@ -6,10 +6,11 @@ that can be used by different solver types (Savile Row, Conjure, etc.).
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
|
+
import re
|
|
9
10
|
import shutil
|
|
10
11
|
import time
|
|
11
12
|
from pathlib import Path
|
|
12
|
-
from typing import Optional, Callable, Tuple
|
|
13
|
+
from typing import Optional, Callable, Sequence, Tuple
|
|
13
14
|
|
|
14
15
|
from .file_management import SolutionFileNamer
|
|
15
16
|
from .global_state import global_state
|
|
@@ -76,6 +77,20 @@ def _create_main_solution_from_best(runner: SolverRunner, best_solution_found: P
|
|
|
76
77
|
return main_solution
|
|
77
78
|
|
|
78
79
|
|
|
80
|
+
#: Every name a horizon scan has ever written, for a caller that does not say
|
|
81
|
+
#: which it writes. Only `letting` lines for these are dropped from the instance;
|
|
82
|
+
#: the scan's callback then appends its own for the horizon at hand.
|
|
83
|
+
LEGACY_HORIZON_PARAMS = ('noSteps', 'maxMoves', 'horizon')
|
|
84
|
+
|
|
85
|
+
_LETTING_NAME_RE = re.compile(r'^\s*letting\s+(\w+)\b')
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_horizon_letting(line: str, names: Sequence[str]) -> bool:
|
|
89
|
+
"""Whether ``line`` sets one of the horizon parameters ``names``."""
|
|
90
|
+
m = _LETTING_NAME_RE.match(line)
|
|
91
|
+
return bool(m) and m.group(1) in names
|
|
92
|
+
|
|
93
|
+
|
|
79
94
|
def horizon_search(runner: SolverRunner,
|
|
80
95
|
param_file: Path,
|
|
81
96
|
startat: int,
|
|
@@ -83,7 +98,8 @@ def horizon_search(runner: SolverRunner,
|
|
|
83
98
|
logger: logging.Logger,
|
|
84
99
|
min_horizon: int = 1,
|
|
85
100
|
stopat: int = 100,
|
|
86
|
-
extra_constraints_callback: Optional[Callable[[Path, int], None]] = None
|
|
101
|
+
extra_constraints_callback: Optional[Callable[[Path, int], None]] = None,
|
|
102
|
+
horizon_params: Optional[Sequence[str]] = None) -> Optional[Path]:
|
|
87
103
|
"""
|
|
88
104
|
Generic horizon-extending search loop.
|
|
89
105
|
|
|
@@ -97,6 +113,9 @@ def horizon_search(runner: SolverRunner,
|
|
|
97
113
|
min_horizon: Minimum allowed horizon value
|
|
98
114
|
stopat: Highest horizon to try (inclusive upper limit of the scan)
|
|
99
115
|
extra_constraints_callback: Optional function(horizon_param_file, n) to add extra constraints
|
|
116
|
+
horizon_params: The parameter names the callback sets to the horizon, so
|
|
117
|
+
an instance's own value for them is dropped first. None means
|
|
118
|
+
LEGACY_HORIZON_PARAMS.
|
|
100
119
|
|
|
101
120
|
Returns:
|
|
102
121
|
Path to solution file if found, None otherwise
|
|
@@ -137,7 +156,8 @@ def horizon_search(runner: SolverRunner,
|
|
|
137
156
|
return None
|
|
138
157
|
|
|
139
158
|
# Create parameter file with horizon constraints
|
|
140
|
-
horizon_param_file = create_horizon_param_file(
|
|
159
|
+
horizon_param_file = create_horizon_param_file(
|
|
160
|
+
param_file, output_dir, n, logger, runner, horizon_params=horizon_params)
|
|
141
161
|
if not horizon_param_file:
|
|
142
162
|
continue
|
|
143
163
|
|
|
@@ -200,7 +220,8 @@ def horizon_search(runner: SolverRunner,
|
|
|
200
220
|
return None
|
|
201
221
|
|
|
202
222
|
|
|
203
|
-
def create_horizon_param_file(param_file: Path, output_dir: Path, n: int, logger: logging.Logger, runner: SolverRunner = None
|
|
223
|
+
def create_horizon_param_file(param_file: Path, output_dir: Path, n: int, logger: logging.Logger, runner: SolverRunner = None,
|
|
224
|
+
horizon_params: Optional[Sequence[str]] = None) -> Optional[Path]:
|
|
204
225
|
"""
|
|
205
226
|
Create parameter file with base content, ready for horizon-specific constraints.
|
|
206
227
|
|
|
@@ -210,6 +231,9 @@ def create_horizon_param_file(param_file: Path, output_dir: Path, n: int, logger
|
|
|
210
231
|
n: Horizon value
|
|
211
232
|
logger: Logger instance
|
|
212
233
|
runner: SolverRunner instance for consistent naming (optional for backward compatibility)
|
|
234
|
+
horizon_params: Names whose `letting` lines are dropped from the
|
|
235
|
+
instance, since the caller sets them per horizon. None means
|
|
236
|
+
LEGACY_HORIZON_PARAMS.
|
|
213
237
|
|
|
214
238
|
Returns:
|
|
215
239
|
Path to created parameter file, or None if creation failed
|
|
@@ -233,12 +257,13 @@ def create_horizon_param_file(param_file: Path, output_dir: Path, n: int, logger
|
|
|
233
257
|
with open(param_file, 'r') as f:
|
|
234
258
|
param_content = f.read()
|
|
235
259
|
|
|
236
|
-
#
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
260
|
+
# Drop the instance's own value for each horizon parameter: the caller
|
|
261
|
+
# appends one per horizon, and two lettings of a name is an error. Only
|
|
262
|
+
# a `letting` of exactly that name goes - a line that merely mentions
|
|
263
|
+
# it (a comment, `letting horizonLimit`) is the instance's own.
|
|
264
|
+
names = LEGACY_HORIZON_PARAMS if horizon_params is None else tuple(horizon_params)
|
|
265
|
+
param_lines = [line for line in param_content.splitlines()
|
|
266
|
+
if not _is_horizon_letting(line, names)]
|
|
242
267
|
|
|
243
268
|
# Write base parameter file
|
|
244
269
|
with open(horizon_param_file, 'w') as f:
|