solver-support 0.4.0__tar.gz → 0.4.2__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.4.0/solver_support.egg-info → solver_support-0.4.2}/PKG-INFO +20 -2
- {solver_support-0.4.0 → solver_support-0.4.2}/README.md +19 -1
- {solver_support-0.4.0 → solver_support-0.4.2}/docs/timing-model.md +90 -15
- {solver_support-0.4.0 → solver_support-0.4.2}/pyproject.toml +1 -1
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/horizon_utils.py +35 -10
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/model_registry.py +68 -12
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/run_summary.py +8 -7
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/solver_enhsp.py +26 -11
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/solver_horizon.py +110 -22
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/solver_stats.py +129 -9
- solver_support-0.4.2/solver_support/starter/models.yaml +194 -0
- {solver_support-0.4.0 → solver_support-0.4.2/solver_support.egg-info}/PKG-INFO +20 -2
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support.egg-info/SOURCES.txt +2 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/tests/fixtures/models.yaml +26 -0
- solver_support-0.4.2/tests/test_enhsp_verdict.py +118 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_fd_verdict.py +60 -3
- solver_support-0.4.2/tests/test_horizon_params.py +139 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_run_summary_success.py +18 -6
- {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_solvers.py +28 -0
- solver_support-0.4.2/tests/test_starter_config.py +180 -0
- solver_support-0.4.0/solver_support/starter/models.yaml +0 -144
- solver_support-0.4.0/tests/test_starter_config.py +0 -85
- {solver_support-0.4.0 → solver_support-0.4.2}/LICENSE +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/MANIFEST.in +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/docs/injection-api.md +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/docs/solver-runner.md +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/setup.cfg +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/__init__.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/command_builders.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/file_management.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/global_state.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/objective.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/solver_base.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/solver_fd.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/solver_runner.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/solver_upfplan.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/utilities.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support.egg-info/dependency_links.txt +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support.egg-info/not-zip-safe +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support.egg-info/requires.txt +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/solver_support.egg-info/top_level.txt +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/tests/conftest.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_command_builders.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_helpers.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_horizon_utils.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_import.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_proves_unsat.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_registry_dump.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_run_summary.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_savilerow_info_recording.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_solution_naming.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_solver_runner.py +0 -0
- {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_solver_stats.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: solver-support
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
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
|
|
@@ -37,6 +37,24 @@ subprocesses with timeouts and clean teardown, naming the per-run files, parsing
|
|
|
37
37
|
solver output into a uniform timing / cost-layer model, and driving the horizon
|
|
38
38
|
(increasing-difficulty) loop.
|
|
39
39
|
|
|
40
|
+
Its main value is a **unified semantics for solver behaviour**. Each solver
|
|
41
|
+
reports how a run ended in its own way, and the raw signals are misleading
|
|
42
|
+
taken at face value:
|
|
43
|
+
|
|
44
|
+
- Fast Downward's exit 12 can mean a proof, a time limit or a search that gave up.
|
|
45
|
+
- ENHSP exits 0 after a proof and after failing to read its input alike.
|
|
46
|
+
- Savile Row prints the same "no solution" whether the backend refuted the
|
|
47
|
+
instance or stopped at its own limit.
|
|
48
|
+
- A SAT solver's 10 and 20 are answers, not failures.
|
|
49
|
+
|
|
50
|
+
solver-support reads each solver's exit codes, logs and info files. It reports
|
|
51
|
+
every run in one vocabulary: a `verdict` (`sat` / `unsat` / `unknown` / `error` /
|
|
52
|
+
`killed`, with a reason) for each solve, and an `outcome` and `stop_reason` for
|
|
53
|
+
the run. So `unsat` means a proof, whichever solver produced it. A run that
|
|
54
|
+
merely found nothing, or crashed, never reads as one. The rules for each solver
|
|
55
|
+
are checked against its source and its real output, and are documented in
|
|
56
|
+
[`timing-model.md`](https://github.com/ott2/solver-support/blob/main/docs/timing-model.md).
|
|
57
|
+
|
|
40
58
|
It carries **no** notion of any particular problem domain or objective. A
|
|
41
59
|
consumer supplies:
|
|
42
60
|
|
|
@@ -75,7 +93,7 @@ runtime dependencies are `psutil` and `PyYAML`.
|
|
|
75
93
|
## Quick start
|
|
76
94
|
|
|
77
95
|
The package ships no model registry, but it carries a commented starter one
|
|
78
|
-
(Fast Downward, SymK
|
|
96
|
+
(Conjure, Savile Row, Fast Downward, SymK and ENHSP) to copy and edit. Writing it out
|
|
79
97
|
does not wire it; you point the registry at it:
|
|
80
98
|
|
|
81
99
|
```python
|
|
@@ -9,6 +9,24 @@ subprocesses with timeouts and clean teardown, naming the per-run files, parsing
|
|
|
9
9
|
solver output into a uniform timing / cost-layer model, and driving the horizon
|
|
10
10
|
(increasing-difficulty) loop.
|
|
11
11
|
|
|
12
|
+
Its main value is a **unified semantics for solver behaviour**. Each solver
|
|
13
|
+
reports how a run ended in its own way, and the raw signals are misleading
|
|
14
|
+
taken at face value:
|
|
15
|
+
|
|
16
|
+
- Fast Downward's exit 12 can mean a proof, a time limit or a search that gave up.
|
|
17
|
+
- ENHSP exits 0 after a proof and after failing to read its input alike.
|
|
18
|
+
- Savile Row prints the same "no solution" whether the backend refuted the
|
|
19
|
+
instance or stopped at its own limit.
|
|
20
|
+
- A SAT solver's 10 and 20 are answers, not failures.
|
|
21
|
+
|
|
22
|
+
solver-support reads each solver's exit codes, logs and info files. It reports
|
|
23
|
+
every run in one vocabulary: a `verdict` (`sat` / `unsat` / `unknown` / `error` /
|
|
24
|
+
`killed`, with a reason) for each solve, and an `outcome` and `stop_reason` for
|
|
25
|
+
the run. So `unsat` means a proof, whichever solver produced it. A run that
|
|
26
|
+
merely found nothing, or crashed, never reads as one. The rules for each solver
|
|
27
|
+
are checked against its source and its real output, and are documented in
|
|
28
|
+
[`timing-model.md`](https://github.com/ott2/solver-support/blob/main/docs/timing-model.md).
|
|
29
|
+
|
|
12
30
|
It carries **no** notion of any particular problem domain or objective. A
|
|
13
31
|
consumer supplies:
|
|
14
32
|
|
|
@@ -47,7 +65,7 @@ runtime dependencies are `psutil` and `PyYAML`.
|
|
|
47
65
|
## Quick start
|
|
48
66
|
|
|
49
67
|
The package ships no model registry, but it carries a commented starter one
|
|
50
|
-
(Fast Downward, SymK
|
|
68
|
+
(Conjure, Savile Row, Fast Downward, SymK and ENHSP) to copy and edit. Writing it out
|
|
51
69
|
does not wire it; you point the registry at it:
|
|
52
70
|
|
|
53
71
|
```python
|
|
@@ -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
|
|
@@ -276,9 +296,13 @@ where the code is ambiguous, into the same vocabulary as the Savile Row verdict.
|
|
|
276
296
|
|
|
277
297
|
- `Time limit reached. Abort search.` (the search's `max_time=`) gives
|
|
278
298
|
`search-limit`.
|
|
279
|
-
- `Completely explored state space -- no solution!` gives `unsat`/`exhausted
|
|
280
|
-
|
|
281
|
-
|
|
299
|
+
- `Completely explored state space -- no solution!` gives `unsat`/`exhausted`.
|
|
300
|
+
It gives `bounded` instead if the search has a `bound=`, and
|
|
301
|
+
`unsafe-pruning` if an evaluator's dead ends are unreliable on this task
|
|
302
|
+
(below). The exhaustion line looks the same either way, so the search is read
|
|
303
|
+
from the driver's `search command line string`. That line has aliases
|
|
304
|
+
expanded and `--evaluator` predefinitions included. If the line is missing,
|
|
305
|
+
the search is read from the argv's `--search`. The translator's
|
|
282
306
|
`Generating unsolvable task` lands here too, since the search then exhausts a
|
|
283
307
|
trivially false goal.
|
|
284
308
|
- SymK prints no exhaustion line. Its last `BOUND: L < U` line, with `L ≥ U`, is
|
|
@@ -291,14 +315,24 @@ where the code is ambiguous, into the same vocabulary as the Savile Row verdict.
|
|
|
291
315
|
plan!`).
|
|
292
316
|
|
|
293
317
|
**`exhausted` is this library's reading, not the planner's.** It is sound only
|
|
294
|
-
if the search is complete and prunes safely.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
318
|
+
if the search is complete and prunes safely. Before 26.6, a search prunes
|
|
319
|
+
every state an evaluator calls a dead end, whether or not
|
|
320
|
+
`dead_ends_are_reliable()`. So a relaxation heuristic on a task with axioms,
|
|
321
|
+
where infinity is not a dead end, "exhausts" without proving anything. The log
|
|
322
|
+
does show the pruning half, and `fd_verdict` checks it as 26.6 does:
|
|
323
|
+
|
|
324
|
+
| evaluator in the logged search | dead ends unreliable when |
|
|
325
|
+
|---|---|
|
|
326
|
+
| `ff`, `add`, `hmax` | `Translator axioms:` is non-zero, or absent |
|
|
327
|
+
| `cg`, `cea` | always |
|
|
328
|
+
| `hm`, `lmcount` / `landmark_sum` | always: they are unreliable on tasks with conditional effects, which the log does not show |
|
|
329
|
+
|
|
330
|
+
Any such evaluator gives `unknown`/`unsafe-pruning`. From 26.6 the planner
|
|
331
|
+
checks it itself (upstream issue1201): a safe exhaustion exits 11 and an unsafe
|
|
332
|
+
one exits 12 with no exhaustion line. So `exhausted` only ever comes from older
|
|
333
|
+
planners and SymK. Completeness of the search algorithm itself (EHC, say) is
|
|
334
|
+
left to the registry's `proves-unsat`, which a tune preset can switch off, and
|
|
335
|
+
which decides whether `exhausted` becomes a run-level `unsat`.
|
|
302
336
|
|
|
303
337
|
**The planner's own time limit is a timeout.** `search-limit` and
|
|
304
338
|
`translate-limit` make `solve()` raise `TimeoutError`, exactly as a watchdog
|
|
@@ -335,6 +369,38 @@ verdict.
|
|
|
335
369
|
- **Direct vs via-UPF**: the direct route passes `end_hint = duration -
|
|
336
370
|
grounding_time`; the UPF route reuses the parser but passes no end hint.
|
|
337
371
|
|
|
372
|
+
#### Verdicts: ENHSP exits 0 for almost everything
|
|
373
|
+
|
|
374
|
+
ENHSP 0.21.0 exits 0 after a plan, after a proof, and after a run that failed to
|
|
375
|
+
load its input. A missing domain file prints `Domain parsed`, then
|
|
376
|
+
`Unsolvable Problem`, and exits 0. The exception goes to **stderr**. So
|
|
377
|
+
`SolverStatsExtractor.enhsp_verdict` reads stdout and stderr, and
|
|
378
|
+
`EnhspSolver` records the result on the `solving` phase, first match wins:
|
|
379
|
+
|
|
380
|
+
| evidence | verdict | `verdict_reason` |
|
|
381
|
+
|---|---|---|
|
|
382
|
+
| `Problem Solved` | `sat` | |
|
|
383
|
+
| `java.lang.OutOfMemoryError` (exit 1) | `unknown` | `memory` |
|
|
384
|
+
| a logged exception (`SEVERE:`, `Exception in thread`) | `error` | `crash` |
|
|
385
|
+
| `Some Syntax Error` (exit 255) | `error` | `input-error` |
|
|
386
|
+
| any other non-zero exit | `error` | `crash` (signal) / `unexplained` |
|
|
387
|
+
| `Problem unsolvable`, with `-anytime` / `-autoanytime` | `unknown` | `anytime` |
|
|
388
|
+
| `Problem unsolvable`, with `-dl` | `unknown` | `bounded` |
|
|
389
|
+
| `Problem unsolvable`, helpful actions (`sat-hmrph`, `sat-hmrphj`; or `-ha true` / `-ht true` with no `-planner`) | `unknown` | `incomplete` |
|
|
390
|
+
| `Problem unsolvable`, otherwise | `unsat` | `exhausted` |
|
|
391
|
+
| `Unsolvable Problem` (grounding returned nothing) | `unknown` | `grounding` |
|
|
392
|
+
| none of these | `error` | `unexplained` |
|
|
393
|
+
|
|
394
|
+
A `-planner` preset overrides `-ha` and `-ht`, as in ENHSP's own `setPlanner`.
|
|
395
|
+
As with Fast Downward, `exhausted` becomes a run-level `unsat` only through
|
|
396
|
+
`proves-unsat`.
|
|
397
|
+
|
|
398
|
+
**Via UPF there is no verdict.** The UPF statuses are wrong in both directions:
|
|
399
|
+
`up_enhsp` reports any exit 0 without a plan as `UNSOLVABLE_PROVEN`, and
|
|
400
|
+
`up_fast_downward` reports any exit 12 as `UNSOLVABLE_INCOMPLETELY`. The UPF CLI
|
|
401
|
+
is the consumer's, so if a consumer wants its UPF runs to conclude `unsat`, it
|
|
402
|
+
must read the engine's log and record a verdict on the `solving` phase itself.
|
|
403
|
+
|
|
338
404
|
## invariants and conventions
|
|
339
405
|
|
|
340
406
|
- **`duration` is ground truth; components are a subset.** Never assume the
|
|
@@ -389,8 +455,8 @@ proved. A status that stopped early (`timeout`, `hlim`, `stopped`) is never
|
|
|
389
455
|
For a planner, being able to prove depends on the **search** as well as the model.
|
|
390
456
|
`get_proves_unsat(model, tune=..., solver_name=...)` is the model's
|
|
391
457
|
`proves-unsat` AND the effective tune's. A tune preset may carry
|
|
392
|
-
`proves-unsat: false` (for example
|
|
393
|
-
|
|
458
|
+
`proves-unsat: false` (for example an incomplete search such as EHC, or an
|
|
459
|
+
anytime portfolio), which takes the claim away. A tune can never grant it to
|
|
394
460
|
a model that cannot prove. `finalize` keeps the value on the summary as
|
|
395
461
|
`proves_unsat`, and a later `finalize` that passes None reuses it. So a
|
|
396
462
|
re-finalize that folds in a late phase, as `horizon_utils` does, cannot drop it.
|
|
@@ -398,7 +464,7 @@ re-finalize that folds in a late phase, as `horizon_utils` does, cannot drop it.
|
|
|
398
464
|
**A `solving` phase's verdict decides a completed run.** `failed` is the status
|
|
399
465
|
of both a planner's proof and its crash, so `proves_unsat` alone would promote
|
|
400
466
|
every crash of a complete model to `unsat`. Where the `solving` phase recorded a
|
|
401
|
-
`verdict` (Fast Downward and
|
|
467
|
+
`verdict` (Fast Downward, SymK and ENHSP do, see above), it decides:
|
|
402
468
|
|
|
403
469
|
| verdict | `proves_unsat` | outcome |
|
|
404
470
|
|---|---|---|
|
|
@@ -407,7 +473,15 @@ every crash of a complete model to `unsat`. Where the `solving` phase recorded a
|
|
|
407
473
|
| `unknown` / `killed` | either | `unknown` |
|
|
408
474
|
| `error` | either | `error` |
|
|
409
475
|
|
|
410
|
-
|
|
476
|
+
| none | True | `unknown`: it may have been a proof, or a crash |
|
|
477
|
+
| none | False | `error` |
|
|
478
|
+
|
|
479
|
+
**No verdict, no `unsat`.** Without a verdict, a completed run's status is all
|
|
480
|
+
there is to go on, and `failed` cannot tell a proof from a crash, so the run is
|
|
481
|
+
`unknown` however complete the model. A UPF run is the usual case, and so is
|
|
482
|
+
any consumer that drives a solver itself. To let such a run conclude `unsat`,
|
|
483
|
+
record a verdict on its `solving` phase (`phase.solver_stats['verdict']`).
|
|
484
|
+
Per-horizon verdicts
|
|
411
485
|
(`solving-horizon-N`) are about one bound each and never decide the run.
|
|
412
486
|
|
|
413
487
|
## what the core owns
|
|
@@ -417,6 +491,7 @@ A run with no verdict is read as before. Per-horizon verdicts
|
|
|
417
491
|
| per-solver stat parsers, cost-layer assembly, dispatch | `solver_stats.py` |
|
|
418
492
|
| per-run Savile Row verdict (refuted vs gave up) | `solver_stats.py` (`savilerow_verdict`) |
|
|
419
493
|
| per-run Fast Downward / SymK verdict (exit code + log) | `solver_stats.py` (`fd_verdict`) |
|
|
494
|
+
| per-run ENHSP verdict (stdout + stderr) | `solver_stats.py` (`enhsp_verdict`) |
|
|
420
495
|
| Savile Row `.info` split recording (per horizon) | `solver_horizon.py` (`_record_savilerow_times`) |
|
|
421
496
|
| stats attached to a phase after each solve | `solver_runner.py`, `solver_horizon.py` |
|
|
422
497
|
| the phase model (`RunSummary` / `PhaseResult`), outcome and success | `run_summary.py` |
|
|
@@ -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.4.
|
|
23
|
+
version = "0.4.2"
|
|
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"
|
|
@@ -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:
|
|
@@ -8,12 +8,20 @@ from the original bash run script.
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from pathlib import Path
|
|
11
|
-
from typing import Dict, Any, Optional, List, Tuple, Callable
|
|
11
|
+
from typing import Dict, Any, Optional, List, Tuple, Callable, Sequence
|
|
12
12
|
import copy
|
|
13
|
+
import os
|
|
13
14
|
import shutil
|
|
14
15
|
import yaml
|
|
15
16
|
|
|
16
17
|
|
|
18
|
+
#: The Savile Row entry a Conjure solver delegates to when its config does not
|
|
19
|
+
#: name one (``savilerow:``), and the entry a Savile Row horizon scan uses when
|
|
20
|
+
#: neither the caller nor the run names one. Kept for configs written before
|
|
21
|
+
#: those could be named; a new config should name its own.
|
|
22
|
+
LEGACY_CONJURE_SAVILEROW = 'kissat'
|
|
23
|
+
|
|
24
|
+
|
|
17
25
|
class ModelRegistryError(Exception):
|
|
18
26
|
"""Exception raised for model registry errors."""
|
|
19
27
|
pass
|
|
@@ -43,10 +51,13 @@ _user_config_finder: Optional[Callable[[], Optional[Path]]] = None
|
|
|
43
51
|
def set_default_config_path(path) -> None:
|
|
44
52
|
"""Register the default ``models.yaml`` the registry loads at startup.
|
|
45
53
|
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
A ``str`` or other path-like becomes a :class:`~pathlib.Path`; anything
|
|
55
|
+
else (an ``importlib.resources`` traversable) is stored as-is, so the loader
|
|
56
|
+
can ``open`` it directly regardless of how the consumer resolved it.
|
|
48
57
|
"""
|
|
49
58
|
global _default_config_path
|
|
59
|
+
if isinstance(path, (str, os.PathLike)):
|
|
60
|
+
path = Path(path)
|
|
50
61
|
_default_config_path = path
|
|
51
62
|
|
|
52
63
|
|
|
@@ -89,9 +100,15 @@ class ModelRegistry(CommandBuilderMixin):
|
|
|
89
100
|
# First, load the default YAML config from package
|
|
90
101
|
default_yaml = self._find_default_yaml_config()
|
|
91
102
|
if not default_yaml:
|
|
103
|
+
if _default_config_path is not None:
|
|
104
|
+
raise ModelRegistryError(
|
|
105
|
+
f"The registered models.yaml ({_default_config_path}) is not "
|
|
106
|
+
"a file."
|
|
107
|
+
)
|
|
92
108
|
raise ModelRegistryError(
|
|
93
|
-
"
|
|
94
|
-
"
|
|
109
|
+
"No models.yaml is registered: solver-support ships without one. "
|
|
110
|
+
"Call set_default_config_path() before first use; "
|
|
111
|
+
"write_starter_config() writes a starter to begin from."
|
|
95
112
|
)
|
|
96
113
|
|
|
97
114
|
self.config = {'models': {}, 'solvers': {}}
|
|
@@ -642,6 +659,33 @@ class ModelRegistry(CommandBuilderMixin):
|
|
|
642
659
|
return None, None
|
|
643
660
|
return model_config.get('status'), model_config.get('status-detail')
|
|
644
661
|
|
|
662
|
+
def get_horizon_params(self, model_type: str,
|
|
663
|
+
default: Sequence[str] = ()) -> List[str]:
|
|
664
|
+
"""The parameter names a horizon scan sets to the horizon for a model.
|
|
665
|
+
|
|
666
|
+
Each horizon N reaches the model as ``letting <name> be N`` appended to
|
|
667
|
+
the instance, so these must be exactly the names the model declares:
|
|
668
|
+
Conjure rejects a ``letting`` for a parameter the model does not have.
|
|
669
|
+
Read from the model's ``horizon-params``, then its solver's, then
|
|
670
|
+
``default`` - the route's own historical names, which configs written
|
|
671
|
+
before the key existed rely on. A single name may be given as a string.
|
|
672
|
+
Resolves aliases and never raises (an unknown model gets ``default``).
|
|
673
|
+
"""
|
|
674
|
+
try:
|
|
675
|
+
_, model_config = self.resolve_model_variant(model_type)
|
|
676
|
+
except (ValueError, ModelRegistryError):
|
|
677
|
+
return list(default)
|
|
678
|
+
names = model_config.get('horizon-params')
|
|
679
|
+
if names is None:
|
|
680
|
+
try:
|
|
681
|
+
names = self.get_solver_config(
|
|
682
|
+
model_config.get('solver', '')).get('horizon-params')
|
|
683
|
+
except ModelRegistryError:
|
|
684
|
+
names = None
|
|
685
|
+
if names is None:
|
|
686
|
+
return list(default)
|
|
687
|
+
return [names] if isinstance(names, str) else list(names)
|
|
688
|
+
|
|
645
689
|
def get_model_default_tune(self, model_type: str) -> Optional[str]:
|
|
646
690
|
"""Return a model's preferred ``--tune`` preset, or None.
|
|
647
691
|
|
|
@@ -656,17 +700,29 @@ class ModelRegistry(CommandBuilderMixin):
|
|
|
656
700
|
return None
|
|
657
701
|
return model_config.get('tune')
|
|
658
702
|
|
|
703
|
+
def get_conjure_savilerow(self, solver_name: str = 'conjure') -> str:
|
|
704
|
+
"""The Savile Row entry a Conjure solver hands each horizon's solve to.
|
|
705
|
+
|
|
706
|
+
Conjure only refines and translates. Savile Row then tailors the
|
|
707
|
+
instance and runs its backend, under the solver entry the ``conjure``
|
|
708
|
+
config names with ``savilerow:``. A config without the key gets
|
|
709
|
+
``kissat``, the name configs written before the key existed use.
|
|
710
|
+
"""
|
|
711
|
+
return self.get_solver_config(solver_name).get(
|
|
712
|
+
'savilerow', LEGACY_CONJURE_SAVILEROW)
|
|
713
|
+
|
|
659
714
|
def get_tune_backend(self, solver_name: str) -> str:
|
|
660
715
|
"""Return the backend whose tune presets govern ``--tune`` for a solver.
|
|
661
716
|
|
|
662
717
|
Almost always the solver itself. The exception is the Conjure/Essence
|
|
663
|
-
path: its per-horizon inner solve is delegated to
|
|
664
|
-
|
|
665
|
-
the tune-less ``conjure`` entry's. Keeping this
|
|
666
|
-
CLI validity check and ``build_*_command`` agree
|
|
718
|
+
path: its per-horizon inner solve is delegated to a Savile Row entry
|
|
719
|
+
(:meth:`get_conjure_savilerow`), so an Essence run's ``--tune`` presets
|
|
720
|
+
are that entry's, not the tune-less ``conjure`` entry's. Keeping this
|
|
721
|
+
mapping here means the CLI validity check and ``build_*_command`` agree
|
|
722
|
+
on what a tune means.
|
|
667
723
|
"""
|
|
668
724
|
if self.get_solver_type(solver_name) == 'conjure':
|
|
669
|
-
return
|
|
725
|
+
return self.get_conjure_savilerow(solver_name)
|
|
670
726
|
return solver_name
|
|
671
727
|
|
|
672
728
|
def get_valid_tunes(self, solver_name: str) -> List[str]:
|
|
@@ -828,8 +884,8 @@ class ModelRegistry(CommandBuilderMixin):
|
|
|
828
884
|
def starter_config_text() -> str:
|
|
829
885
|
"""The starter ``models.yaml`` shipped with the package, as text.
|
|
830
886
|
|
|
831
|
-
A commented example registry - Fast Downward, SymK
|
|
832
|
-
for a new user to copy and edit. It is never loaded implicitly: the core
|
|
887
|
+
A commented example registry - Conjure, Savile Row, Fast Downward, SymK
|
|
888
|
+
and ENHSP - for a new user to copy and edit. It is never loaded implicitly: the core
|
|
833
889
|
still ships unwired, and :class:`ModelRegistry` still raises until a config
|
|
834
890
|
path is set. Unlike :meth:`ModelRegistry.dump_config` it keeps its comments,
|
|
835
891
|
which is what makes it fit to start from.
|
|
@@ -245,13 +245,14 @@ class RunSummary:
|
|
|
245
245
|
if final_status in _COMPLETED_STATUSES:
|
|
246
246
|
verdict = self._solving_verdict()
|
|
247
247
|
if verdict is None:
|
|
248
|
-
# No account of how the search ended, so
|
|
249
|
-
#
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
#
|
|
254
|
-
|
|
248
|
+
# No account of how the search ended, so the status is all
|
|
249
|
+
# there is, and it cannot prove anything: `failed` is equally
|
|
250
|
+
# what a proof, a rejected input and a crash look like. From a
|
|
251
|
+
# model that could prove, it may have been a proof, so the
|
|
252
|
+
# answer is unknown. From any other model, finishing unsolved
|
|
253
|
+
# means something went wrong. To conclude unsat, the solving
|
|
254
|
+
# phase must record a verdict.
|
|
255
|
+
return 'unknown' if proves_unsat else 'error'
|
|
255
256
|
# The solver said how its search ended. Only a refutation from a
|
|
256
257
|
# search that can prove one is a proof; an exhausted search that
|
|
257
258
|
# cannot (an unsafe heuristic, a tune switched off) concluded
|
|
@@ -9,9 +9,10 @@ sas_plan, so the PDDLSolutionParser consumes the result unchanged.
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from typing import Optional
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
13
|
|
|
14
14
|
from .solver_base import Solver
|
|
15
|
+
from .solver_stats import SolverStatsExtractor
|
|
15
16
|
from .global_state import global_state
|
|
16
17
|
|
|
17
18
|
|
|
@@ -71,6 +72,17 @@ class EnhspSolver(Solver):
|
|
|
71
72
|
# run_phase's expected_outputs, which would list it even on a failed parse.
|
|
72
73
|
result = self.runner.run_phase("solving", cmd)
|
|
73
74
|
|
|
75
|
+
# ENHSP exits 0 for a proof and for a run that failed to load its input
|
|
76
|
+
# alike, so the summary's outcome is decided by this, not by `failed`.
|
|
77
|
+
verdict: Dict[str, Any] = {}
|
|
78
|
+
if not result.launch_failed:
|
|
79
|
+
verdict = SolverStatsExtractor.enhsp_verdict(
|
|
80
|
+
result.returncode, result.stdout, result.stderr, cmd,
|
|
81
|
+
timed_out=result.timed_out, interrupted=result.interrupted)
|
|
82
|
+
solving_phase = self.runner.summary.get_phase("solving")
|
|
83
|
+
if solving_phase is not None:
|
|
84
|
+
solving_phase.solver_stats.update(verdict)
|
|
85
|
+
|
|
74
86
|
if result.timed_out:
|
|
75
87
|
# No internal time limit outside anytime mode: the runner terminated
|
|
76
88
|
# ENHSP mid-search, so no plan was written - a genuine timeout.
|
|
@@ -80,16 +92,19 @@ class EnhspSolver(Solver):
|
|
|
80
92
|
self.logger.warning("ENHSP solving was interrupted")
|
|
81
93
|
return self._finalise(solution_file)
|
|
82
94
|
|
|
83
|
-
if
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
# A plan may have been written even on a failing path, so take it if
|
|
96
|
+
# present. Without one, the verdict says whether anything went wrong:
|
|
97
|
+
# ENHSP exits 0 after a missing input file, and non-zero on a syntax
|
|
98
|
+
# error (e.g. the axioms it does not support) or out of memory.
|
|
99
|
+
plan = self._finalise(solution_file)
|
|
100
|
+
if plan is None:
|
|
101
|
+
failed = (verdict.get('verdict') == 'error' if verdict
|
|
102
|
+
else not result.success)
|
|
103
|
+
if failed:
|
|
104
|
+
self.logger.error(f"ENHSP solver failed: {verdict} {result.stderr}")
|
|
105
|
+
else:
|
|
106
|
+
self.logger.info(f"ENHSP found no plan: {verdict}")
|
|
107
|
+
return plan
|
|
93
108
|
|
|
94
109
|
def _finalise(self, solution_file: Path) -> Optional[Path]:
|
|
95
110
|
"""Register and return the plan file if ENHSP wrote one, else None."""
|