solver-support 0.4.1__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.1/solver_support.egg-info → solver_support-0.4.2}/PKG-INFO +19 -1
- {solver_support-0.4.1 → solver_support-0.4.2}/README.md +18 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/docs/timing-model.md +70 -15
- {solver_support-0.4.1 → solver_support-0.4.2}/pyproject.toml +1 -1
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/run_summary.py +8 -7
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/solver_enhsp.py +26 -11
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/solver_stats.py +129 -9
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/starter/models.yaml +10 -5
- {solver_support-0.4.1 → solver_support-0.4.2/solver_support.egg-info}/PKG-INFO +19 -1
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support.egg-info/SOURCES.txt +1 -0
- solver_support-0.4.2/tests/test_enhsp_verdict.py +118 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_fd_verdict.py +60 -3
- {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_run_summary_success.py +18 -6
- {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_solvers.py +28 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_starter_config.py +22 -2
- {solver_support-0.4.1 → solver_support-0.4.2}/LICENSE +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/MANIFEST.in +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/docs/injection-api.md +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/docs/solver-runner.md +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/setup.cfg +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/__init__.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/command_builders.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/file_management.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/global_state.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/horizon_utils.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/model_registry.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/objective.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/solver_base.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/solver_fd.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/solver_horizon.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/solver_runner.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/solver_upfplan.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/utilities.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support.egg-info/dependency_links.txt +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support.egg-info/not-zip-safe +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support.egg-info/requires.txt +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/solver_support.egg-info/top_level.txt +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/tests/conftest.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/tests/fixtures/models.yaml +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_command_builders.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_helpers.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_horizon_params.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_horizon_utils.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_import.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_proves_unsat.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_registry_dump.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_run_summary.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_savilerow_info_recording.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_solution_naming.py +0 -0
- {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_solver_runner.py +0 -0
- {solver_support-0.4.1 → 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
|
|
|
@@ -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
|
|
|
@@ -296,9 +296,13 @@ where the code is ambiguous, into the same vocabulary as the Savile Row verdict.
|
|
|
296
296
|
|
|
297
297
|
- `Time limit reached. Abort search.` (the search's `max_time=`) gives
|
|
298
298
|
`search-limit`.
|
|
299
|
-
- `Completely explored state space -- no solution!` gives `unsat`/`exhausted
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
302
306
|
`Generating unsolvable task` lands here too, since the search then exhausts a
|
|
303
307
|
trivially false goal.
|
|
304
308
|
- SymK prints no exhaustion line. Its last `BOUND: L < U` line, with `L ≥ U`, is
|
|
@@ -311,14 +315,24 @@ where the code is ambiguous, into the same vocabulary as the Savile Row verdict.
|
|
|
311
315
|
plan!`).
|
|
312
316
|
|
|
313
317
|
**`exhausted` is this library's reading, not the planner's.** It is sound only
|
|
314
|
-
if the search is complete and prunes safely.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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`.
|
|
322
336
|
|
|
323
337
|
**The planner's own time limit is a timeout.** `search-limit` and
|
|
324
338
|
`translate-limit` make `solve()` raise `TimeoutError`, exactly as a watchdog
|
|
@@ -355,6 +369,38 @@ verdict.
|
|
|
355
369
|
- **Direct vs via-UPF**: the direct route passes `end_hint = duration -
|
|
356
370
|
grounding_time`; the UPF route reuses the parser but passes no end hint.
|
|
357
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
|
+
|
|
358
404
|
## invariants and conventions
|
|
359
405
|
|
|
360
406
|
- **`duration` is ground truth; components are a subset.** Never assume the
|
|
@@ -409,8 +455,8 @@ proved. A status that stopped early (`timeout`, `hlim`, `stopped`) is never
|
|
|
409
455
|
For a planner, being able to prove depends on the **search** as well as the model.
|
|
410
456
|
`get_proves_unsat(model, tune=..., solver_name=...)` is the model's
|
|
411
457
|
`proves-unsat` AND the effective tune's. A tune preset may carry
|
|
412
|
-
`proves-unsat: false` (for example
|
|
413
|
-
|
|
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
|
|
414
460
|
a model that cannot prove. `finalize` keeps the value on the summary as
|
|
415
461
|
`proves_unsat`, and a later `finalize` that passes None reuses it. So a
|
|
416
462
|
re-finalize that folds in a late phase, as `horizon_utils` does, cannot drop it.
|
|
@@ -418,7 +464,7 @@ re-finalize that folds in a late phase, as `horizon_utils` does, cannot drop it.
|
|
|
418
464
|
**A `solving` phase's verdict decides a completed run.** `failed` is the status
|
|
419
465
|
of both a planner's proof and its crash, so `proves_unsat` alone would promote
|
|
420
466
|
every crash of a complete model to `unsat`. Where the `solving` phase recorded a
|
|
421
|
-
`verdict` (Fast Downward and
|
|
467
|
+
`verdict` (Fast Downward, SymK and ENHSP do, see above), it decides:
|
|
422
468
|
|
|
423
469
|
| verdict | `proves_unsat` | outcome |
|
|
424
470
|
|---|---|---|
|
|
@@ -427,7 +473,15 @@ every crash of a complete model to `unsat`. Where the `solving` phase recorded a
|
|
|
427
473
|
| `unknown` / `killed` | either | `unknown` |
|
|
428
474
|
| `error` | either | `error` |
|
|
429
475
|
|
|
430
|
-
|
|
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
|
|
431
485
|
(`solving-horizon-N`) are about one bound each and never decide the run.
|
|
432
486
|
|
|
433
487
|
## what the core owns
|
|
@@ -437,6 +491,7 @@ A run with no verdict is read as before. Per-horizon verdicts
|
|
|
437
491
|
| per-solver stat parsers, cost-layer assembly, dispatch | `solver_stats.py` |
|
|
438
492
|
| per-run Savile Row verdict (refuted vs gave up) | `solver_stats.py` (`savilerow_verdict`) |
|
|
439
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`) |
|
|
440
495
|
| Savile Row `.info` split recording (per horizon) | `solver_horizon.py` (`_record_savilerow_times`) |
|
|
441
496
|
| stats attached to a phase after each solve | `solver_runner.py`, `solver_horizon.py` |
|
|
442
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"
|
|
@@ -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."""
|
|
@@ -85,6 +85,20 @@ _ENHSP_LAYER_RE = re.compile(r'f\(n\)\s*=\s*([\d.]+)\s*\([^)]*Time:\s*([\d.]+)\)
|
|
|
85
85
|
# success, neither on a timeout / unsolvable.
|
|
86
86
|
_ENHSP_SOLVED_RE = re.compile(r'Problem Solved|Found Plan:')
|
|
87
87
|
|
|
88
|
+
# ENHSP 0.21.0's verdict lines (see enhsp_verdict). It exits 0 for nearly every
|
|
89
|
+
# ending, so these lines and stderr are all there is to go on.
|
|
90
|
+
_ENHSP_SOLVED_LINE = 'Problem Solved'
|
|
91
|
+
_ENHSP_EXHAUSTED_LINE = 'Problem unsolvable' # the search ran out of states
|
|
92
|
+
_ENHSP_REFUSED_LINE = 'Unsolvable Problem' # parsing/grounding returned null
|
|
93
|
+
_ENHSP_SYNTAX_LINE = 'Some Syntax Error' # exits 255
|
|
94
|
+
# A `catch (Exception)` logs through java.util.logging (to stderr) and carries
|
|
95
|
+
# on; an uncaught one ends the JVM with exit 1.
|
|
96
|
+
_ENHSP_EXCEPTION_RE = re.compile(r'^SEVERE: |^Exception in thread ', re.MULTILINE)
|
|
97
|
+
_ENHSP_OOM = 'java.lang.OutOfMemoryError'
|
|
98
|
+
# `-planner` presets that prune with helpful actions (ENHSP.setPlanner); with no
|
|
99
|
+
# preset, `-ha true` / `-ht true` do the same. A preset overrides those flags.
|
|
100
|
+
_ENHSP_HELPFUL_PLANNERS = frozenset({'sat-hmrph', 'sat-hmrphj'})
|
|
101
|
+
|
|
88
102
|
|
|
89
103
|
# What a single Savile Row run concluded, derived from its ``.info`` file. The
|
|
90
104
|
# two *decided* verdicts come first: they are the only ones that answer the
|
|
@@ -181,6 +195,18 @@ _FD_EXHAUSTED_LINE = 'Completely explored state space -- no solution!' # befor
|
|
|
181
195
|
_SYMK_BOUND_RE = re.compile(r'BOUND: (\d+) < (\d+)')
|
|
182
196
|
_SYMK_INFINITY = 2147483647
|
|
183
197
|
_FD_BOUND_ARG_RE = re.compile(r'\bbound\s*=')
|
|
198
|
+
# The search the driver actually ran, aliases expanded and evaluators predefined
|
|
199
|
+
# with --evaluator included. Read in preference to the argv the caller passes.
|
|
200
|
+
_FD_SEARCH_LINE_RE = re.compile(r'search command line string: (.*)')
|
|
201
|
+
_FD_AXIOMS_RE = re.compile(r'^Translator axioms: (\d+)', re.MULTILINE)
|
|
202
|
+
# A pre-26.6 search prunes every state an evaluator calls a dead end, reliable or
|
|
203
|
+
# not, so its exhaustion is a proof only if every evaluator's dead ends are. From
|
|
204
|
+
# `dead_ends_are_reliable()` in the 26.6 sources: `cg` and `cea` never are; `hm`
|
|
205
|
+
# is not on tasks with conditional effects, nor the landmark sum on most of them,
|
|
206
|
+
# which the log does not show, so both are treated as never. The relaxation
|
|
207
|
+
# heuristics are unreliable exactly when the task has axioms.
|
|
208
|
+
_FD_UNRELIABLE_RE = re.compile(r'\b(?:cg|cea|hm|lmcount|landmark_sum)\(')
|
|
209
|
+
_FD_UNRELIABLE_WITH_AXIOMS_RE = re.compile(r'\b(?:ff|add|hmax)\(')
|
|
184
210
|
|
|
185
211
|
|
|
186
212
|
class SolverStatsExtractor:
|
|
@@ -621,17 +647,21 @@ class SolverStatsExtractor:
|
|
|
621
647
|
|
|
622
648
|
``unsat`` is about *the configured search*, not the task. With reason
|
|
623
649
|
``exhausted`` it is this function's own reading of a pre-26.6 planner
|
|
624
|
-
whose search ran out of states
|
|
625
|
-
and prunes safely
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
650
|
+
whose search ran out of states. That is sound only if the search is
|
|
651
|
+
complete and prunes safely. The pruning half is checked here, as 26.6
|
|
652
|
+
checks it itself: an evaluator whose dead ends are unreliable on this
|
|
653
|
+
task (``ff`` on a task with axioms, ``cg`` on any) gives ``unknown`` /
|
|
654
|
+
``unsafe-pruning``. Completeness of the search algorithm is not
|
|
655
|
+
checked here. Without a reason, ``unsat`` is the planner's own claim.
|
|
656
|
+
Either way, turning it into a run-level outcome is the caller's job,
|
|
657
|
+
through the registry's ``get_proves_unsat``.
|
|
629
658
|
|
|
630
659
|
Args:
|
|
631
660
|
returncode: The planner driver's exit code.
|
|
632
661
|
stdout: Its stdout, where the search logs.
|
|
633
662
|
command: The argv that ran, read for a ``bound=`` in ``--search``
|
|
634
|
-
(a pre-26.6 bounded search logs exactly as an unbounded one)
|
|
663
|
+
(a pre-26.6 bounded search logs exactly as an unbounded one)
|
|
664
|
+
when the log lacks the driver's ``search command line string``.
|
|
635
665
|
timed_out / interrupted: the runner killed it, so the planner's own
|
|
636
666
|
account of how the search ended was never written.
|
|
637
667
|
anytime: it was a portfolio (``--alias``), whose 12 can mask its
|
|
@@ -667,13 +697,21 @@ class SolverStatsExtractor:
|
|
|
667
697
|
return 'unknown', 'portfolio'
|
|
668
698
|
if _FD_TIME_LIMIT_LINE in stdout:
|
|
669
699
|
return 'unknown', 'search-limit'
|
|
670
|
-
|
|
671
|
-
if
|
|
700
|
+
logged = _FD_SEARCH_LINE_RE.findall(stdout)
|
|
701
|
+
if logged:
|
|
702
|
+
search = logged[-1]
|
|
703
|
+
elif '--search' in command:
|
|
672
704
|
i = command.index('--search')
|
|
673
705
|
search = command[i + 1] if i + 1 < len(command) else ''
|
|
706
|
+
else:
|
|
707
|
+
search = ''
|
|
674
708
|
bounded = bool(_FD_BOUND_ARG_RE.search(search))
|
|
675
709
|
if _FD_EXHAUSTED_LINE in stdout:
|
|
676
|
-
|
|
710
|
+
if bounded:
|
|
711
|
+
return 'unknown', 'bounded'
|
|
712
|
+
if SolverStatsExtractor._fd_pruned_unsafely(search, stdout):
|
|
713
|
+
return 'unknown', 'unsafe-pruning'
|
|
714
|
+
return 'unsat', 'exhausted'
|
|
677
715
|
bounds = _SYMK_BOUND_RE.findall(stdout)
|
|
678
716
|
if bounds:
|
|
679
717
|
lower, upper = (int(b) for b in bounds[-1])
|
|
@@ -687,6 +725,88 @@ class SolverStatsExtractor:
|
|
|
687
725
|
# (`Search terminated without finding a plan!`).
|
|
688
726
|
return 'unknown', 'incomplete'
|
|
689
727
|
|
|
728
|
+
@staticmethod
|
|
729
|
+
def _fd_pruned_unsafely(search: str, stdout: str) -> bool:
|
|
730
|
+
"""Whether a search that ran out of states may have pruned a live one."""
|
|
731
|
+
if _FD_UNRELIABLE_RE.search(search):
|
|
732
|
+
return True
|
|
733
|
+
if _FD_UNRELIABLE_WITH_AXIOMS_RE.search(search):
|
|
734
|
+
# No axiom count in the log reads as "may have axioms".
|
|
735
|
+
axioms = _FD_AXIOMS_RE.findall(stdout)
|
|
736
|
+
return not axioms or int(axioms[-1]) > 0
|
|
737
|
+
return False
|
|
738
|
+
|
|
739
|
+
@staticmethod
|
|
740
|
+
def enhsp_verdict(returncode: int, stdout: str, stderr: str = '',
|
|
741
|
+
command: Optional[List[str]] = None,
|
|
742
|
+
timed_out: bool = False,
|
|
743
|
+
interrupted: bool = False) -> Dict[str, Any]:
|
|
744
|
+
"""What an ENHSP run concluded, from its output: the twin of
|
|
745
|
+
:meth:`fd_verdict`, in the same vocabulary.
|
|
746
|
+
|
|
747
|
+
ENHSP 0.21.0 exits 0 for nearly every ending: a plan, a proof, a
|
|
748
|
+
search that pruned or was bounded, and a parse or grounding failure
|
|
749
|
+
that it logged and then reported as ``Unsolvable Problem``. So the exit
|
|
750
|
+
code says almost nothing, and stderr matters: a logged exception goes
|
|
751
|
+
there while the run carries on to exit 0.
|
|
752
|
+
|
|
753
|
+
``unsat`` (reason ``exhausted``) means the configured search ran out of
|
|
754
|
+
states. Whether that search is complete is still the registry's
|
|
755
|
+
``get_proves_unsat`` to say. Helpful-action pruning and a ``-dl`` cost
|
|
756
|
+
bound are read off the argv and give ``unknown``.
|
|
757
|
+
"""
|
|
758
|
+
if timed_out or interrupted:
|
|
759
|
+
return {'verdict': 'killed'}
|
|
760
|
+
stdout = stdout or ''
|
|
761
|
+
stderr = stderr or ''
|
|
762
|
+
argv = command or []
|
|
763
|
+
if _ENHSP_SOLVED_LINE in stdout:
|
|
764
|
+
verdict, reason = 'sat', None
|
|
765
|
+
elif _ENHSP_OOM in stderr or _ENHSP_OOM in stdout:
|
|
766
|
+
verdict, reason = 'unknown', 'memory'
|
|
767
|
+
elif _ENHSP_EXCEPTION_RE.search(stderr):
|
|
768
|
+
verdict, reason = 'error', 'crash'
|
|
769
|
+
elif _ENHSP_SYNTAX_LINE in stdout:
|
|
770
|
+
verdict, reason = 'error', 'input-error'
|
|
771
|
+
elif returncode != 0:
|
|
772
|
+
verdict, reason = 'error', 'crash' if returncode < 0 else 'unexplained'
|
|
773
|
+
elif _ENHSP_EXHAUSTED_LINE in stdout:
|
|
774
|
+
verdict, reason = SolverStatsExtractor._enhsp_exhausted_verdict(argv)
|
|
775
|
+
elif _ENHSP_REFUSED_LINE in stdout:
|
|
776
|
+
# prepareForSearch returned false. That may be a sound reachability
|
|
777
|
+
# refutation, but the log does not say which, so it proves nothing.
|
|
778
|
+
verdict, reason = 'unknown', 'grounding'
|
|
779
|
+
else:
|
|
780
|
+
verdict, reason = 'error', 'unexplained'
|
|
781
|
+
stats: Dict[str, Any] = {'verdict': verdict}
|
|
782
|
+
if reason:
|
|
783
|
+
stats['verdict_reason'] = reason
|
|
784
|
+
return stats
|
|
785
|
+
|
|
786
|
+
@staticmethod
|
|
787
|
+
def _enhsp_exhausted_verdict(argv: List[str]):
|
|
788
|
+
"""Read ``Problem unsolvable`` against the configuration that ran."""
|
|
789
|
+
def value(flag):
|
|
790
|
+
if flag in argv:
|
|
791
|
+
i = argv.index(flag)
|
|
792
|
+
return argv[i + 1] if i + 1 < len(argv) else ''
|
|
793
|
+
return None
|
|
794
|
+
|
|
795
|
+
if '-anytime' in argv or '-autoanytime' in argv:
|
|
796
|
+
# Each round is bounded by the last plan's cost, and its own
|
|
797
|
+
# -timeout ends the run the same way.
|
|
798
|
+
return 'unknown', 'anytime'
|
|
799
|
+
if value('-dl') is not None:
|
|
800
|
+
return 'unknown', 'bounded'
|
|
801
|
+
planner = value('-planner')
|
|
802
|
+
if planner is not None:
|
|
803
|
+
helpful = planner in _ENHSP_HELPFUL_PLANNERS
|
|
804
|
+
else:
|
|
805
|
+
helpful = value('-ha') == 'true' or value('-ht') == 'true'
|
|
806
|
+
if helpful:
|
|
807
|
+
return 'unknown', 'incomplete'
|
|
808
|
+
return 'unsat', 'exhausted'
|
|
809
|
+
|
|
690
810
|
@staticmethod
|
|
691
811
|
def extract_fast_downward_stats(stdout: str, stderr: str,
|
|
692
812
|
duration: Optional[float] = None) -> Dict[str, Any]:
|
|
@@ -51,9 +51,14 @@ models:
|
|
|
51
51
|
|
|
52
52
|
# A PDDL planning model: `path` is the domain file, and the instance (problem)
|
|
53
53
|
# file is given per run. Fast Downward runs it by default; SymK and ENHSP
|
|
54
|
-
# can run it too. `proves-unsat: true` says
|
|
55
|
-
#
|
|
56
|
-
#
|
|
54
|
+
# can run it too. `proves-unsat: true` says this model can *prove* there is
|
|
55
|
+
# no plan, given a complete search. It is safe to leave on for these three
|
|
56
|
+
# planners, because each records a verdict read off its own log: a run is
|
|
57
|
+
# concluded unsat only when its search ran out of states without being
|
|
58
|
+
# bounded or pruned unsafely, never just because it found no plan. The one
|
|
59
|
+
# tune that cannot prove (the `soup` portfolio) opts out below. A solver
|
|
60
|
+
# whose backend records no verdict, such as a UPF planner, never concludes
|
|
61
|
+
# unsat, whatever this says.
|
|
57
62
|
pddl:
|
|
58
63
|
path: models/domain.pddl
|
|
59
64
|
solver: fd
|
|
@@ -92,8 +97,8 @@ solvers:
|
|
|
92
97
|
ff:
|
|
93
98
|
search: "lazy_greedy([ff()])"
|
|
94
99
|
# FF prunes states it scores as infinite, which is unsafe on tasks with
|
|
95
|
-
# axioms
|
|
96
|
-
|
|
100
|
+
# axioms. The verdict sees that in the log and claims no proof there;
|
|
101
|
+
# on a task without axioms, running out of states is a proof.
|
|
97
102
|
description: satisficing greedy best-first search with FF
|
|
98
103
|
soup:
|
|
99
104
|
alias: seq-sat-fdss-2018
|
|
@@ -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
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for ``SolverStatsExtractor.enhsp_verdict`` (issue #6).
|
|
3
|
+
|
|
4
|
+
ENHSP exits 0 for a plan, for a proof, and for a run that never loaded its
|
|
5
|
+
input, so the verdict is read from the log. As with Fast Downward, the cases run
|
|
6
|
+
against REAL planner output (tests/fixtures/enhsp, see its README).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from solver_support.solver_stats import SAVILEROW_VERDICTS, SolverStatsExtractor
|
|
14
|
+
|
|
15
|
+
FIXTURES = Path(__file__).parent / 'fixtures' / 'enhsp'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _read(name, stream):
|
|
19
|
+
path = FIXTURES / f'{name}.{stream}'
|
|
20
|
+
return path.read_text() if path.exists() else ''
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _enhsp(*extra, planner='opt-blind'):
|
|
24
|
+
return ['enhsp', '-o', 'd.pddl', '-f', 'p.pddl', '-planner', planner,
|
|
25
|
+
'-sp', 'plan', *extra]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _verdict(name, returncode, command, **kwargs):
|
|
29
|
+
return SolverStatsExtractor.enhsp_verdict(
|
|
30
|
+
returncode, _read(name, 'stdout'), _read(name, 'stderr'), command,
|
|
31
|
+
**kwargs)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize('fixture, code, command, expected', [
|
|
35
|
+
('enhsp-sat', 0, _enhsp(), {'verdict': 'sat'}),
|
|
36
|
+
('enhsp-exhaust', 0, _enhsp(),
|
|
37
|
+
{'verdict': 'unsat', 'verdict_reason': 'exhausted'}),
|
|
38
|
+
# the same `Problem unsolvable`, from a search that pruned with helpful actions
|
|
39
|
+
('enhsp-ha', 0, _enhsp(planner='sat-hmrph'),
|
|
40
|
+
{'verdict': 'unknown', 'verdict_reason': 'incomplete'}),
|
|
41
|
+
# and from a cost bound below the plan's (sat.pddl is solvable)
|
|
42
|
+
('enhsp-dl', 0, _enhsp('-dl', '1'),
|
|
43
|
+
{'verdict': 'unknown', 'verdict_reason': 'bounded'}),
|
|
44
|
+
('enhsp-syntax', 255, _enhsp(),
|
|
45
|
+
{'verdict': 'error', 'verdict_reason': 'input-error'}),
|
|
46
|
+
# exit 0 and `Unsolvable Problem`, after a FileNotFoundException on stderr
|
|
47
|
+
('enhsp-nofile', 0, _enhsp(),
|
|
48
|
+
{'verdict': 'error', 'verdict_reason': 'crash'}),
|
|
49
|
+
('enhsp-oom', 1, _enhsp(),
|
|
50
|
+
{'verdict': 'unknown', 'verdict_reason': 'memory'}),
|
|
51
|
+
])
|
|
52
|
+
def test_real_logs(fixture, code, command, expected):
|
|
53
|
+
assert _verdict(fixture, code, command) == expected
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_the_issue_6_hazard_is_not_a_proof():
|
|
57
|
+
"""A missing domain file exits 0 with an `unsolvable` line on stdout. That
|
|
58
|
+
must never read as unsat, whatever the stderr check decides."""
|
|
59
|
+
assert _verdict('enhsp-nofile', 0, _enhsp())['verdict'] != 'unsat'
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_refused_without_an_exception_proves_nothing():
|
|
63
|
+
"""`Unsolvable Problem` alone: grounding returned null, which may be a sound
|
|
64
|
+
reachability refutation, but the log does not say which."""
|
|
65
|
+
stdout = 'Domain parsed\nProblem parsed\nGrounding..\nUnsolvable Problem\n'
|
|
66
|
+
assert SolverStatsExtractor.enhsp_verdict(0, stdout, '', _enhsp()) == {
|
|
67
|
+
'verdict': 'unknown', 'verdict_reason': 'grounding'}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_a_swallowed_search_exception_is_a_crash():
|
|
71
|
+
"""ENHSP.planning() logs the exception at SEVERE and returns: no verdict
|
|
72
|
+
line, exit 0. No input here makes it throw, so this stderr is modelled on
|
|
73
|
+
enhsp-nofile.stderr."""
|
|
74
|
+
stdout = _read('enhsp-exhaust', 'stdout').split('Problem unsolvable')[0]
|
|
75
|
+
stderr = ('Oct 22, 2026 11:00:26 PM planners.ENHSP planning\n'
|
|
76
|
+
'SEVERE: null\njava.lang.NullPointerException\n')
|
|
77
|
+
assert SolverStatsExtractor.enhsp_verdict(0, stdout, stderr, _enhsp()) == {
|
|
78
|
+
'verdict': 'error', 'verdict_reason': 'crash'}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.parametrize('extra, planner, expected', [
|
|
82
|
+
(('-ha', 'true'), None, 'incomplete'),
|
|
83
|
+
(('-ht', 'true'), None, 'incomplete'),
|
|
84
|
+
(('-ha', 'false'), None, None),
|
|
85
|
+
# a preset overrides -ha (ENHSP.setPlanner resets it)
|
|
86
|
+
(('-ha', 'true'), 'opt-blind', None),
|
|
87
|
+
(('-anytime',), 'sat-hmrp', 'anytime'),
|
|
88
|
+
(('-autoanytime',), None, 'anytime'),
|
|
89
|
+
])
|
|
90
|
+
def test_configuration_flags(extra, planner, expected):
|
|
91
|
+
command = ['enhsp', '-o', 'd.pddl', '-f', 'p.pddl', *extra]
|
|
92
|
+
if planner:
|
|
93
|
+
command += ['-planner', planner]
|
|
94
|
+
stats = _verdict('enhsp-exhaust', 0, command)
|
|
95
|
+
if expected is None:
|
|
96
|
+
assert stats == {'verdict': 'unsat', 'verdict_reason': 'exhausted'}
|
|
97
|
+
else:
|
|
98
|
+
assert stats == {'verdict': 'unknown', 'verdict_reason': expected}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.parametrize('flag', ['timed_out', 'interrupted'])
|
|
102
|
+
def test_killed(flag):
|
|
103
|
+
assert _verdict('enhsp-exhaust', -15, _enhsp(), **{flag: True}) == {
|
|
104
|
+
'verdict': 'killed'}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_unexplained_exits():
|
|
108
|
+
assert SolverStatsExtractor.enhsp_verdict(0, '', '', _enhsp()) == {
|
|
109
|
+
'verdict': 'error', 'verdict_reason': 'unexplained'}
|
|
110
|
+
assert SolverStatsExtractor.enhsp_verdict(-9, '', '', _enhsp()) == {
|
|
111
|
+
'verdict': 'error', 'verdict_reason': 'crash'}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_vocabulary_is_shared_with_savilerow():
|
|
115
|
+
names = ['enhsp-sat', 'enhsp-exhaust', 'enhsp-ha', 'enhsp-dl',
|
|
116
|
+
'enhsp-syntax', 'enhsp-nofile', 'enhsp-oom']
|
|
117
|
+
for name in names:
|
|
118
|
+
assert _verdict(name, 0, _enhsp())['verdict'] in SAVILEROW_VERDICTS
|
|
@@ -7,6 +7,7 @@ from a search that gave up. The 12 cases therefore run against REAL planner
|
|
|
7
7
|
logs (tests/fixtures/fd, see its README), not hand-written ones.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
import re
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
|
|
12
13
|
import pytest
|
|
@@ -26,6 +27,14 @@ def _fd(search):
|
|
|
26
27
|
'--search', search]
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
_EXHAUSTED = '[t=0.01s, 1 KB] Completely explored state space -- no solution!'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _swap_search(log, search):
|
|
34
|
+
"""A real log with its logged search swapped, everything else kept."""
|
|
35
|
+
return re.sub(r"--search '[^']*'", lambda _: f"--search '{search}'", log)
|
|
36
|
+
|
|
37
|
+
|
|
29
38
|
def _verdict(returncode, stdout='', command=None, **kwargs):
|
|
30
39
|
return SolverStatsExtractor.fd_verdict(returncode, stdout, command, **kwargs)
|
|
31
40
|
|
|
@@ -57,11 +66,19 @@ class TestUnsolvedExit12:
|
|
|
57
66
|
def test_real_logs(self, fixture, search, expected):
|
|
58
67
|
assert _verdict(12, _log(fixture), _fd(search)) == expected
|
|
59
68
|
|
|
60
|
-
def
|
|
61
|
-
"""The
|
|
62
|
-
|
|
69
|
+
def test_the_logged_search_outranks_the_argv(self):
|
|
70
|
+
"""The driver logs the search it actually ran, so a bound is seen even
|
|
71
|
+
when the caller's argv does not show one (an alias, say)."""
|
|
63
72
|
assert _verdict(12, _log('fd-bound'), _fd('astar(blind())')) == {
|
|
73
|
+
'verdict': 'unknown', 'verdict_reason': 'bounded'}
|
|
74
|
+
|
|
75
|
+
def test_bound_is_read_from_the_argv_without_the_log_line(self):
|
|
76
|
+
"""Exhaustion alone reads as a proof; the argv's bound= takes it away."""
|
|
77
|
+
log = _EXHAUSTED + '\n'
|
|
78
|
+
assert _verdict(12, log, _fd('astar(blind())')) == {
|
|
64
79
|
'verdict': 'unsat', 'verdict_reason': 'exhausted'}
|
|
80
|
+
assert _verdict(12, log, _fd('astar(blind(), bound=2)')) == {
|
|
81
|
+
'verdict': 'unknown', 'verdict_reason': 'bounded'}
|
|
65
82
|
|
|
66
83
|
def test_portfolio_12_is_never_unsat(self):
|
|
67
84
|
"""A portfolio's 12 can mask its components' time-outs, and its
|
|
@@ -85,6 +102,46 @@ class TestUnsolvedExit12:
|
|
|
85
102
|
assert _verdict(12, both, _fd('astar(blind())'))['verdict'] == 'unknown'
|
|
86
103
|
|
|
87
104
|
|
|
105
|
+
class TestUnsafePruning:
|
|
106
|
+
"""Issue #6: before 26.6, a search prunes every state an evaluator calls a
|
|
107
|
+
dead end, reliable or not, so running out of states proves nothing if one
|
|
108
|
+
of them was unreliable on this task."""
|
|
109
|
+
|
|
110
|
+
def test_real_ff_on_a_task_with_axioms(self):
|
|
111
|
+
assert _verdict(12, _log('fd-ff-ax'), None) == {
|
|
112
|
+
'verdict': 'unknown', 'verdict_reason': 'unsafe-pruning'}
|
|
113
|
+
|
|
114
|
+
@pytest.mark.parametrize('search', [
|
|
115
|
+
'lazy_greedy([ff()])', 'eager_greedy([add()])', 'astar(hmax())'])
|
|
116
|
+
def test_relaxation_heuristics_are_safe_without_axioms(self, search):
|
|
117
|
+
"""`fd-exhaust` has `Translator axioms: 0`."""
|
|
118
|
+
assert _verdict(12, _swap_search(_log('fd-exhaust'), search), None) == {
|
|
119
|
+
'verdict': 'unsat', 'verdict_reason': 'exhausted'}
|
|
120
|
+
|
|
121
|
+
@pytest.mark.parametrize('search', [
|
|
122
|
+
'lazy_greedy([cg()])', 'lazy_greedy([cea()])', 'astar(hm(m=2))',
|
|
123
|
+
'lazy_greedy([lmcount(lm_rhw())])'])
|
|
124
|
+
def test_always_unreliable_evaluators(self, search):
|
|
125
|
+
assert _verdict(12, _swap_search(_log('fd-exhaust'), search), None) == {
|
|
126
|
+
'verdict': 'unknown', 'verdict_reason': 'unsafe-pruning'}
|
|
127
|
+
|
|
128
|
+
def test_a_predefined_evaluator_counts(self):
|
|
129
|
+
"""`--evaluator h=cg()` is in the logged line, not in the search."""
|
|
130
|
+
log = _log('fd-exhaust').replace(
|
|
131
|
+
"--search 'astar(blind())'",
|
|
132
|
+
"--evaluator 'h=cg()' --search 'lazy_greedy([h])'")
|
|
133
|
+
assert _verdict(12, log, None)['verdict_reason'] == 'unsafe-pruning'
|
|
134
|
+
|
|
135
|
+
def test_blind_on_a_task_with_axioms_is_still_a_proof(self):
|
|
136
|
+
log = _swap_search(_log('fd-ff-ax'), 'astar(blind())')
|
|
137
|
+
assert _verdict(12, log, None) == {
|
|
138
|
+
'verdict': 'unsat', 'verdict_reason': 'exhausted'}
|
|
139
|
+
|
|
140
|
+
def test_no_axiom_count_reads_as_axioms(self):
|
|
141
|
+
assert _verdict(12, _EXHAUSTED, _fd('lazy_greedy([ff()])')) == {
|
|
142
|
+
'verdict': 'unknown', 'verdict_reason': 'unsafe-pruning'}
|
|
143
|
+
|
|
144
|
+
|
|
88
145
|
class TestExitCodes:
|
|
89
146
|
"""Every code but 12 says what happened by itself."""
|
|
90
147
|
|
|
@@ -155,7 +155,8 @@ def test_custom_status_without_a_solution_is_not_success():
|
|
|
155
155
|
|
|
156
156
|
def test_planner_proven_unsat_is_success():
|
|
157
157
|
"""Exits non-zero, finalizes 'failed', and has answered the question."""
|
|
158
|
-
s = _summary(_phase("translation", True),
|
|
158
|
+
s = _summary(_phase("translation", True),
|
|
159
|
+
_phase("solving", False, verdict="unsat"))
|
|
159
160
|
s.finalize("failed", proves_unsat=True)
|
|
160
161
|
assert s.outcome == "unsat"
|
|
161
162
|
assert s.success is True
|
|
@@ -193,12 +194,23 @@ def test_failed_run_is_read_through_its_verdict(verdict, proves, outcome):
|
|
|
193
194
|
assert s.success is (outcome == "unsat")
|
|
194
195
|
|
|
195
196
|
|
|
196
|
-
def
|
|
197
|
-
"""
|
|
198
|
-
|
|
199
|
-
|
|
197
|
+
def test_failed_without_a_verdict_is_never_unsat():
|
|
198
|
+
"""Issue #6: with no account of how the search ended, `failed` is what a
|
|
199
|
+
proof, a rejected input and a crash all look like. A model that could have
|
|
200
|
+
proved gets `unknown`, and only a recorded verdict can make it `unsat`."""
|
|
201
|
+
s = _summary(_phase("solving", False, returncode=1))
|
|
200
202
|
s.finalize("failed", proves_unsat=True)
|
|
201
|
-
assert s.outcome == "
|
|
203
|
+
assert s.outcome == "unknown"
|
|
204
|
+
assert s.success is False
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_issue_6_reproduction():
|
|
208
|
+
"""The issue's own reproduction: an ENHSP run that recorded no verdict."""
|
|
209
|
+
s = RunSummary(instance='i', model='pddl', solver='enhsp')
|
|
210
|
+
s.add_phase(PhaseResult(name='solving', returncode=1,
|
|
211
|
+
command=['enhsp', '-o', 'd.pddl', '-f', 'p.pddl']))
|
|
212
|
+
s.finalize('failed', proves_unsat=True)
|
|
213
|
+
assert (s.outcome, s.success) == ('unknown', False)
|
|
202
214
|
|
|
203
215
|
|
|
204
216
|
def test_horizon_verdicts_never_decide_the_run():
|
|
@@ -609,6 +609,34 @@ class TestEnhspSolver(TempDirTestMixin):
|
|
|
609
609
|
|
|
610
610
|
assert result is None
|
|
611
611
|
|
|
612
|
+
@pytest.mark.parametrize("fixture, expected, logged_as", [
|
|
613
|
+
("enhsp-exhaust", {"verdict": "unsat", "verdict_reason": "exhausted"},
|
|
614
|
+
"info"),
|
|
615
|
+
# issue #6: exit 0 and an `unsolvable` line, after the domain failed to load
|
|
616
|
+
("enhsp-nofile", {"verdict": "error", "verdict_reason": "crash"},
|
|
617
|
+
"error"),
|
|
618
|
+
])
|
|
619
|
+
def test_enhsp_solver_records_its_verdict(self, fixture, expected, logged_as):
|
|
620
|
+
"""The verdict goes on the solving phase, where the summary's outcome
|
|
621
|
+
reads it; only an `error` verdict is logged as a failure."""
|
|
622
|
+
enhsp_dir = Path(__file__).parent / "fixtures" / "enhsp"
|
|
623
|
+
stderr = enhsp_dir / f"{fixture}.stderr"
|
|
624
|
+
self.mock_runner.run_phase.return_value = SolverResult(
|
|
625
|
+
command=["enhsp"], returncode=0,
|
|
626
|
+
stdout=(enhsp_dir / f"{fixture}.stdout").read_text(),
|
|
627
|
+
stderr=stderr.read_text() if stderr.exists() else "", duration=0.5)
|
|
628
|
+
phase = PhaseResult(name="solving")
|
|
629
|
+
self.mock_summary.get_phase.return_value = phase
|
|
630
|
+
|
|
631
|
+
result = self.solver.solve(
|
|
632
|
+
param_file=self.temp_path / "problem.pddl", model_path="domain.pddl")
|
|
633
|
+
|
|
634
|
+
assert result is None
|
|
635
|
+
assert {k: phase.solver_stats[k] for k in expected} == expected
|
|
636
|
+
assert getattr(self.solver.logger, logged_as).called
|
|
637
|
+
if logged_as == "info":
|
|
638
|
+
assert not self.solver.logger.error.called
|
|
639
|
+
|
|
612
640
|
def test_enhsp_solver_timeout(self):
|
|
613
641
|
"""A timed-out run (no plan) raises TimeoutError."""
|
|
614
642
|
self.mock_runner.run_phase.return_value = SolverResult(
|
|
@@ -120,12 +120,32 @@ def test_every_solver_has_a_default_model_that_exists(starter):
|
|
|
120
120
|
|
|
121
121
|
def test_incomplete_tunes_do_not_claim_proofs(starter):
|
|
122
122
|
assert starter.get_proves_unsat('pddl') is True
|
|
123
|
-
|
|
124
|
-
assert starter.get_proves_unsat('pddl', tune='
|
|
123
|
+
# complete; where it prunes unsafely (axioms), the verdict says so
|
|
124
|
+
assert starter.get_proves_unsat('pddl', tune='ff', solver_name='fd') is True
|
|
125
|
+
assert starter.get_proves_unsat('pddl', tune='soup', solver_name='fd') is False
|
|
125
126
|
assert starter.get_proves_unsat('eprime') is False
|
|
126
127
|
assert starter.get_proves_unsat('essence') is False
|
|
127
128
|
|
|
128
129
|
|
|
130
|
+
# The solver types whose backend records a solving-phase verdict, the only
|
|
131
|
+
# thing a run's outcome can conclude `unsat` from (issue #6).
|
|
132
|
+
_VERDICT_SOLVER_TYPES = {'fd', 'enhsp'}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_proofs_are_claimed_only_where_a_verdict_is_recorded(starter):
|
|
136
|
+
"""For every solver that can run a model claiming proves-unsat, the backend
|
|
137
|
+
records a verdict, so the claim is never made off a bare `failed`."""
|
|
138
|
+
for model in starter.config['models']:
|
|
139
|
+
for solver in starter.list_solvers():
|
|
140
|
+
if not starter.is_solver_compatible_with_model(solver, model):
|
|
141
|
+
continue
|
|
142
|
+
tunes = [None, *starter.get_solver_config(solver).get('tune', {})]
|
|
143
|
+
if any(starter.get_proves_unsat(model, tune=t, solver_name=solver)
|
|
144
|
+
for t in tunes):
|
|
145
|
+
assert starter.get_solver_type(solver) in _VERDICT_SOLVER_TYPES, (
|
|
146
|
+
model, solver)
|
|
147
|
+
|
|
148
|
+
|
|
129
149
|
def test_config_path_may_be_a_str(tmp_path):
|
|
130
150
|
"""The starter's own header wires it with a str. A str has no is_file(), and
|
|
131
151
|
used to be silently skipped, so the registry raised as if nothing at all
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|