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.
Files changed (51) hide show
  1. {solver_support-0.4.1/solver_support.egg-info → solver_support-0.4.2}/PKG-INFO +19 -1
  2. {solver_support-0.4.1 → solver_support-0.4.2}/README.md +18 -0
  3. {solver_support-0.4.1 → solver_support-0.4.2}/docs/timing-model.md +70 -15
  4. {solver_support-0.4.1 → solver_support-0.4.2}/pyproject.toml +1 -1
  5. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/run_summary.py +8 -7
  6. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/solver_enhsp.py +26 -11
  7. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/solver_stats.py +129 -9
  8. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/starter/models.yaml +10 -5
  9. {solver_support-0.4.1 → solver_support-0.4.2/solver_support.egg-info}/PKG-INFO +19 -1
  10. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support.egg-info/SOURCES.txt +1 -0
  11. solver_support-0.4.2/tests/test_enhsp_verdict.py +118 -0
  12. {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_fd_verdict.py +60 -3
  13. {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_run_summary_success.py +18 -6
  14. {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_solvers.py +28 -0
  15. {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_starter_config.py +22 -2
  16. {solver_support-0.4.1 → solver_support-0.4.2}/LICENSE +0 -0
  17. {solver_support-0.4.1 → solver_support-0.4.2}/MANIFEST.in +0 -0
  18. {solver_support-0.4.1 → solver_support-0.4.2}/docs/injection-api.md +0 -0
  19. {solver_support-0.4.1 → solver_support-0.4.2}/docs/solver-runner.md +0 -0
  20. {solver_support-0.4.1 → solver_support-0.4.2}/setup.cfg +0 -0
  21. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/__init__.py +0 -0
  22. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/command_builders.py +0 -0
  23. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/file_management.py +0 -0
  24. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/global_state.py +0 -0
  25. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/horizon_utils.py +0 -0
  26. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/model_registry.py +0 -0
  27. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/objective.py +0 -0
  28. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/solver_base.py +0 -0
  29. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/solver_fd.py +0 -0
  30. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/solver_horizon.py +0 -0
  31. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/solver_runner.py +0 -0
  32. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/solver_upfplan.py +0 -0
  33. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support/utilities.py +0 -0
  34. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support.egg-info/dependency_links.txt +0 -0
  35. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support.egg-info/not-zip-safe +0 -0
  36. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support.egg-info/requires.txt +0 -0
  37. {solver_support-0.4.1 → solver_support-0.4.2}/solver_support.egg-info/top_level.txt +0 -0
  38. {solver_support-0.4.1 → solver_support-0.4.2}/tests/conftest.py +0 -0
  39. {solver_support-0.4.1 → solver_support-0.4.2}/tests/fixtures/models.yaml +0 -0
  40. {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_command_builders.py +0 -0
  41. {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_helpers.py +0 -0
  42. {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_horizon_params.py +0 -0
  43. {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_horizon_utils.py +0 -0
  44. {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_import.py +0 -0
  45. {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_proves_unsat.py +0 -0
  46. {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_registry_dump.py +0 -0
  47. {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_run_summary.py +0 -0
  48. {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_savilerow_info_recording.py +0 -0
  49. {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_solution_naming.py +0 -0
  50. {solver_support-0.4.1 → solver_support-0.4.2}/tests/test_solver_runner.py +0 -0
  51. {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.1
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
- 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
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. 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.
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 a relaxation heuristic that prunes unsafely
413
- on tasks with axioms), which takes the claim away. A tune can never grant it to
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 SymK do, see above), it decides instead:
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
- A run with no verdict is read as before. Per-horizon verdicts
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.1"
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 read the status alone.
249
- # The search finished and produced nothing. From a model that
250
- # decides the problem that IS the answer; from any other model
251
- # it means something went wrong, because a bounded model that
252
- # finishes unsolved has not concluded anything -- it has merely
253
- # stopped.
254
- return 'unsat' if proves_unsat else 'error'
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 result.success:
84
- return self._finalise(solution_file)
85
-
86
- # ENHSP exits non-zero when it cannot parse the model (e.g. the axiom
87
- # `pddl` domain, which it does not support) or fails to plan. A plan may
88
- # still have been written on some paths, so salvage it if present.
89
- salvaged = self._finalise(solution_file)
90
- if salvaged is None:
91
- self.logger.error(f"ENHSP solver failed: {result.stderr}")
92
- return salvaged
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 - sound only if that search is complete
625
- and prunes safely, which the log cannot reveal (``astar(ff())`` on a
626
- task with axioms prunes states it should not). Without a reason it is
627
- the planner's own claim. Either way, turning it into a run-level outcome
628
- is the caller's job, through the registry's ``get_proves_unsat``.
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
- search = ''
671
- if '--search' in command:
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
- return ('unknown', 'bounded') if bounded else ('unsat', 'exhausted')
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 a run that finds no plan has
55
- # *proved* there is none, which holds for a complete search. The tunes that
56
- # are not complete opt out below, so it is safe to leave on here.
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, so a search under it that runs out of states proves nothing.
96
- proves-unsat: false
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.1
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
 
@@ -30,6 +30,7 @@ solver_support.egg-info/top_level.txt
30
30
  solver_support/starter/models.yaml
31
31
  tests/conftest.py
32
32
  tests/test_command_builders.py
33
+ tests/test_enhsp_verdict.py
33
34
  tests/test_fd_verdict.py
34
35
  tests/test_helpers.py
35
36
  tests/test_horizon_params.py
@@ -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 test_bound_is_read_from_the_search_string(self):
61
- """The log alone cannot see a bound: drop it from the argv and the same
62
- output reads as a proof."""
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), _phase("solving", False))
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 test_failed_without_a_verdict_keeps_its_old_reading():
197
- """Solvers that record no verdict (and summaries written before verdicts
198
- existed) mean what they always meant."""
199
- s = _summary(_phase("solving", False))
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 == "unsat"
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
- assert starter.get_proves_unsat('pddl', tune='ff') is False
124
- assert starter.get_proves_unsat('pddl', tune='soup') is False
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