solver-support 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {solver_support-0.4.0/solver_support.egg-info → solver_support-0.4.2}/PKG-INFO +20 -2
  2. {solver_support-0.4.0 → solver_support-0.4.2}/README.md +19 -1
  3. {solver_support-0.4.0 → solver_support-0.4.2}/docs/timing-model.md +90 -15
  4. {solver_support-0.4.0 → solver_support-0.4.2}/pyproject.toml +1 -1
  5. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/horizon_utils.py +35 -10
  6. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/model_registry.py +68 -12
  7. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/run_summary.py +8 -7
  8. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/solver_enhsp.py +26 -11
  9. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/solver_horizon.py +110 -22
  10. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/solver_stats.py +129 -9
  11. solver_support-0.4.2/solver_support/starter/models.yaml +194 -0
  12. {solver_support-0.4.0 → solver_support-0.4.2/solver_support.egg-info}/PKG-INFO +20 -2
  13. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support.egg-info/SOURCES.txt +2 -0
  14. {solver_support-0.4.0 → solver_support-0.4.2}/tests/fixtures/models.yaml +26 -0
  15. solver_support-0.4.2/tests/test_enhsp_verdict.py +118 -0
  16. {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_fd_verdict.py +60 -3
  17. solver_support-0.4.2/tests/test_horizon_params.py +139 -0
  18. {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_run_summary_success.py +18 -6
  19. {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_solvers.py +28 -0
  20. solver_support-0.4.2/tests/test_starter_config.py +180 -0
  21. solver_support-0.4.0/solver_support/starter/models.yaml +0 -144
  22. solver_support-0.4.0/tests/test_starter_config.py +0 -85
  23. {solver_support-0.4.0 → solver_support-0.4.2}/LICENSE +0 -0
  24. {solver_support-0.4.0 → solver_support-0.4.2}/MANIFEST.in +0 -0
  25. {solver_support-0.4.0 → solver_support-0.4.2}/docs/injection-api.md +0 -0
  26. {solver_support-0.4.0 → solver_support-0.4.2}/docs/solver-runner.md +0 -0
  27. {solver_support-0.4.0 → solver_support-0.4.2}/setup.cfg +0 -0
  28. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/__init__.py +0 -0
  29. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/command_builders.py +0 -0
  30. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/file_management.py +0 -0
  31. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/global_state.py +0 -0
  32. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/objective.py +0 -0
  33. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/solver_base.py +0 -0
  34. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/solver_fd.py +0 -0
  35. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/solver_runner.py +0 -0
  36. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/solver_upfplan.py +0 -0
  37. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support/utilities.py +0 -0
  38. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support.egg-info/dependency_links.txt +0 -0
  39. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support.egg-info/not-zip-safe +0 -0
  40. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support.egg-info/requires.txt +0 -0
  41. {solver_support-0.4.0 → solver_support-0.4.2}/solver_support.egg-info/top_level.txt +0 -0
  42. {solver_support-0.4.0 → solver_support-0.4.2}/tests/conftest.py +0 -0
  43. {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_command_builders.py +0 -0
  44. {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_helpers.py +0 -0
  45. {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_horizon_utils.py +0 -0
  46. {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_import.py +0 -0
  47. {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_proves_unsat.py +0 -0
  48. {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_registry_dump.py +0 -0
  49. {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_run_summary.py +0 -0
  50. {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_savilerow_info_recording.py +0 -0
  51. {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_solution_naming.py +0 -0
  52. {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_solver_runner.py +0 -0
  53. {solver_support-0.4.0 → solver_support-0.4.2}/tests/test_solver_stats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solver-support
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Generic Conjure / Savile Row / Fast Downward / SymK / ENHSP / UPF solver pipeline
5
5
  Author: András Salamon
6
6
  License-Expression: BSD-3-Clause
@@ -37,6 +37,24 @@ subprocesses with timeouts and clean teardown, naming the per-run files, parsing
37
37
  solver output into a uniform timing / cost-layer model, and driving the horizon
38
38
  (increasing-difficulty) loop.
39
39
 
40
+ Its main value is a **unified semantics for solver behaviour**. Each solver
41
+ reports how a run ended in its own way, and the raw signals are misleading
42
+ taken at face value:
43
+
44
+ - Fast Downward's exit 12 can mean a proof, a time limit or a search that gave up.
45
+ - ENHSP exits 0 after a proof and after failing to read its input alike.
46
+ - Savile Row prints the same "no solution" whether the backend refuted the
47
+ instance or stopped at its own limit.
48
+ - A SAT solver's 10 and 20 are answers, not failures.
49
+
50
+ solver-support reads each solver's exit codes, logs and info files. It reports
51
+ every run in one vocabulary: a `verdict` (`sat` / `unsat` / `unknown` / `error` /
52
+ `killed`, with a reason) for each solve, and an `outcome` and `stop_reason` for
53
+ the run. So `unsat` means a proof, whichever solver produced it. A run that
54
+ merely found nothing, or crashed, never reads as one. The rules for each solver
55
+ are checked against its source and its real output, and are documented in
56
+ [`timing-model.md`](https://github.com/ott2/solver-support/blob/main/docs/timing-model.md).
57
+
40
58
  It carries **no** notion of any particular problem domain or objective. A
41
59
  consumer supplies:
42
60
 
@@ -75,7 +93,7 @@ runtime dependencies are `psutil` and `PyYAML`.
75
93
  ## Quick start
76
94
 
77
95
  The package ships no model registry, but it carries a commented starter one
78
- (Fast Downward, SymK, ENHSP and Savile Row) to copy and edit. Writing it out
96
+ (Conjure, Savile Row, Fast Downward, SymK and ENHSP) to copy and edit. Writing it out
79
97
  does not wire it; you point the registry at it:
80
98
 
81
99
  ```python
@@ -9,6 +9,24 @@ subprocesses with timeouts and clean teardown, naming the per-run files, parsing
9
9
  solver output into a uniform timing / cost-layer model, and driving the horizon
10
10
  (increasing-difficulty) loop.
11
11
 
12
+ Its main value is a **unified semantics for solver behaviour**. Each solver
13
+ reports how a run ended in its own way, and the raw signals are misleading
14
+ taken at face value:
15
+
16
+ - Fast Downward's exit 12 can mean a proof, a time limit or a search that gave up.
17
+ - ENHSP exits 0 after a proof and after failing to read its input alike.
18
+ - Savile Row prints the same "no solution" whether the backend refuted the
19
+ instance or stopped at its own limit.
20
+ - A SAT solver's 10 and 20 are answers, not failures.
21
+
22
+ solver-support reads each solver's exit codes, logs and info files. It reports
23
+ every run in one vocabulary: a `verdict` (`sat` / `unsat` / `unknown` / `error` /
24
+ `killed`, with a reason) for each solve, and an `outcome` and `stop_reason` for
25
+ the run. So `unsat` means a proof, whichever solver produced it. A run that
26
+ merely found nothing, or crashed, never reads as one. The rules for each solver
27
+ are checked against its source and its real output, and are documented in
28
+ [`timing-model.md`](https://github.com/ott2/solver-support/blob/main/docs/timing-model.md).
29
+
12
30
  It carries **no** notion of any particular problem domain or objective. A
13
31
  consumer supplies:
14
32
 
@@ -47,7 +65,7 @@ runtime dependencies are `psutil` and `PyYAML`.
47
65
  ## Quick start
48
66
 
49
67
  The package ships no model registry, but it carries a commented starter one
50
- (Fast Downward, SymK, ENHSP and Savile Row) to copy and edit. Writing it out
68
+ (Conjure, Savile Row, Fast Downward, SymK and ENHSP) to copy and edit. Writing it out
51
69
  does not wire it; you point the registry at it:
52
70
 
53
71
  ```python
@@ -70,6 +70,18 @@ fields. All times are seconds unless noted.
70
70
  ### Savile Row (Conjure / Essence Prime models)
71
71
 
72
72
  - **Shape**: a horizon scan — one `solving-horizon-N` phase per move budget.
73
+ Each horizon reaches the model as `letting <name> be N`, appended to the
74
+ instance for every name in the model's `horizon-params` (or its solver's).
75
+ These must be exactly the parameters the model declares, since Conjure
76
+ rejects a `letting` for one it lacks. A config without the key gets the
77
+ historical names, `noSteps` (Savile Row) or `maxMoves` + `horizon` (Conjure),
78
+ so models written for those keep working. Only `letting` lines for those names
79
+ are dropped from the instance first, not any line that mentions one.
80
+ - **Which Savile Row entry**: Conjure only refines and translates. Each horizon
81
+ is tailored and solved by the Savile Row entry the `conjure` config names
82
+ (`savilerow: <entry>`), and an Essence run's `--tune` picks among that
83
+ entry's presets. A direct Savile Row scan uses the run's own solver. Without
84
+ either, it is `kissat`, the name older configs use.
73
85
  - **Split source**: Savile Row writes `<param>.info` per horizon;
74
86
  `_record_savilerow_times` (`solver_horizon.py`) reads `SavileRowTotalTime`
75
87
  (tailoring) → `savilerow_time` and `SolverTotalTime` (backend search) →
@@ -176,6 +188,14 @@ So size the external kill with `savilerow_backstop(T, S)` — `T + S` plus grace
176
188
  for the flush — and it comes to mean "the toolchain ignored its limits", a
177
189
  genuine fault rather than an ordinary and lossy way for a run to end.
178
190
 
191
+ The built-in horizon scans do this per horizon when asked:
192
+ `SavileRowSolver.solve(..., backstop=True)` and `ConjureSolver.solve(...,
193
+ backstop=True)` kill each horizon at `savilerow_backstop(R)`, where `R` is the
194
+ budget that horizon's command was built with. Without it, each horizon is
195
+ killed at the runner's own timeout, as before. It is opt-in because a scan
196
+ killed at the backstop can overrun the runner's timeout by up to about that
197
+ timeout again, which would change the run times of an existing consumer.
198
+
179
199
  **To hold a run to a wall-clock budget, shrink the pair, not the backstop.**
180
200
  Give the solver `S = T - (what tailoring will cost)`. The core picks no reserve
181
201
  policy: a horizon scan can estimate from the previous horizon's
@@ -276,9 +296,13 @@ where the code is ambiguous, into the same vocabulary as the Savile Row verdict.
276
296
 
277
297
  - `Time limit reached. Abort search.` (the search's `max_time=`) gives
278
298
  `search-limit`.
279
- - `Completely explored state space -- no solution!` gives `unsat`/`exhausted`,
280
- or `bounded` if the `--search` string has a `bound=`. The log looks the same
281
- either way, so the argv is read for it. The translator's
299
+ - `Completely explored state space -- no solution!` gives `unsat`/`exhausted`.
300
+ It gives `bounded` instead if the search has a `bound=`, and
301
+ `unsafe-pruning` if an evaluator's dead ends are unreliable on this task
302
+ (below). The exhaustion line looks the same either way, so the search is read
303
+ from the driver's `search command line string`. That line has aliases
304
+ expanded and `--evaluator` predefinitions included. If the line is missing,
305
+ the search is read from the argv's `--search`. The translator's
282
306
  `Generating unsolvable task` lands here too, since the search then exhausts a
283
307
  trivially false goal.
284
308
  - SymK prints no exhaustion line. Its last `BOUND: L < U` line, with `L ≥ U`, is
@@ -291,14 +315,24 @@ where the code is ambiguous, into the same vocabulary as the Savile Row verdict.
291
315
  plan!`).
292
316
 
293
317
  **`exhausted` is this library's reading, not the planner's.** It is sound only
294
- if the search is complete and prunes safely. A relaxation heuristic prunes
295
- infinite-valued states even on a task with axioms, where infinity is not a
296
- dead end, so `astar(ff())` there "exhausts" without proving anything. The log
297
- cannot reveal this. From 26.6 the planner checks it (upstream issue1201): a
298
- safe exhaustion exits 11 and an unsafe one exits 12 with no exhaustion line, so
299
- `exhausted` only ever comes from older planners and SymK. Whether it becomes a
300
- run-level `unsat` is decided by the registry's `proves-unsat`, which a tune
301
- preset can switch off.
318
+ if the search is complete and prunes safely. Before 26.6, a search prunes
319
+ every state an evaluator calls a dead end, whether or not
320
+ `dead_ends_are_reliable()`. So a relaxation heuristic on a task with axioms,
321
+ where infinity is not a dead end, "exhausts" without proving anything. The log
322
+ does show the pruning half, and `fd_verdict` checks it as 26.6 does:
323
+
324
+ | evaluator in the logged search | dead ends unreliable when |
325
+ |---|---|
326
+ | `ff`, `add`, `hmax` | `Translator axioms:` is non-zero, or absent |
327
+ | `cg`, `cea` | always |
328
+ | `hm`, `lmcount` / `landmark_sum` | always: they are unreliable on tasks with conditional effects, which the log does not show |
329
+
330
+ Any such evaluator gives `unknown`/`unsafe-pruning`. From 26.6 the planner
331
+ checks it itself (upstream issue1201): a safe exhaustion exits 11 and an unsafe
332
+ one exits 12 with no exhaustion line. So `exhausted` only ever comes from older
333
+ planners and SymK. Completeness of the search algorithm itself (EHC, say) is
334
+ left to the registry's `proves-unsat`, which a tune preset can switch off, and
335
+ which decides whether `exhausted` becomes a run-level `unsat`.
302
336
 
303
337
  **The planner's own time limit is a timeout.** `search-limit` and
304
338
  `translate-limit` make `solve()` raise `TimeoutError`, exactly as a watchdog
@@ -335,6 +369,38 @@ verdict.
335
369
  - **Direct vs via-UPF**: the direct route passes `end_hint = duration -
336
370
  grounding_time`; the UPF route reuses the parser but passes no end hint.
337
371
 
372
+ #### Verdicts: ENHSP exits 0 for almost everything
373
+
374
+ ENHSP 0.21.0 exits 0 after a plan, after a proof, and after a run that failed to
375
+ load its input. A missing domain file prints `Domain parsed`, then
376
+ `Unsolvable Problem`, and exits 0. The exception goes to **stderr**. So
377
+ `SolverStatsExtractor.enhsp_verdict` reads stdout and stderr, and
378
+ `EnhspSolver` records the result on the `solving` phase, first match wins:
379
+
380
+ | evidence | verdict | `verdict_reason` |
381
+ |---|---|---|
382
+ | `Problem Solved` | `sat` | |
383
+ | `java.lang.OutOfMemoryError` (exit 1) | `unknown` | `memory` |
384
+ | a logged exception (`SEVERE:`, `Exception in thread`) | `error` | `crash` |
385
+ | `Some Syntax Error` (exit 255) | `error` | `input-error` |
386
+ | any other non-zero exit | `error` | `crash` (signal) / `unexplained` |
387
+ | `Problem unsolvable`, with `-anytime` / `-autoanytime` | `unknown` | `anytime` |
388
+ | `Problem unsolvable`, with `-dl` | `unknown` | `bounded` |
389
+ | `Problem unsolvable`, helpful actions (`sat-hmrph`, `sat-hmrphj`; or `-ha true` / `-ht true` with no `-planner`) | `unknown` | `incomplete` |
390
+ | `Problem unsolvable`, otherwise | `unsat` | `exhausted` |
391
+ | `Unsolvable Problem` (grounding returned nothing) | `unknown` | `grounding` |
392
+ | none of these | `error` | `unexplained` |
393
+
394
+ A `-planner` preset overrides `-ha` and `-ht`, as in ENHSP's own `setPlanner`.
395
+ As with Fast Downward, `exhausted` becomes a run-level `unsat` only through
396
+ `proves-unsat`.
397
+
398
+ **Via UPF there is no verdict.** The UPF statuses are wrong in both directions:
399
+ `up_enhsp` reports any exit 0 without a plan as `UNSOLVABLE_PROVEN`, and
400
+ `up_fast_downward` reports any exit 12 as `UNSOLVABLE_INCOMPLETELY`. The UPF CLI
401
+ is the consumer's, so if a consumer wants its UPF runs to conclude `unsat`, it
402
+ must read the engine's log and record a verdict on the `solving` phase itself.
403
+
338
404
  ## invariants and conventions
339
405
 
340
406
  - **`duration` is ground truth; components are a subset.** Never assume the
@@ -389,8 +455,8 @@ proved. A status that stopped early (`timeout`, `hlim`, `stopped`) is never
389
455
  For a planner, being able to prove depends on the **search** as well as the model.
390
456
  `get_proves_unsat(model, tune=..., solver_name=...)` is the model's
391
457
  `proves-unsat` AND the effective tune's. A tune preset may carry
392
- `proves-unsat: false` (for example a relaxation heuristic that prunes unsafely
393
- 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
394
460
  a model that cannot prove. `finalize` keeps the value on the summary as
395
461
  `proves_unsat`, and a later `finalize` that passes None reuses it. So a
396
462
  re-finalize that folds in a late phase, as `horizon_utils` does, cannot drop it.
@@ -398,7 +464,7 @@ re-finalize that folds in a late phase, as `horizon_utils` does, cannot drop it.
398
464
  **A `solving` phase's verdict decides a completed run.** `failed` is the status
399
465
  of both a planner's proof and its crash, so `proves_unsat` alone would promote
400
466
  every crash of a complete model to `unsat`. Where the `solving` phase recorded a
401
- `verdict` (Fast Downward and SymK do, see above), it decides instead:
467
+ `verdict` (Fast Downward, SymK and ENHSP do, see above), it decides:
402
468
 
403
469
  | verdict | `proves_unsat` | outcome |
404
470
  |---|---|---|
@@ -407,7 +473,15 @@ every crash of a complete model to `unsat`. Where the `solving` phase recorded a
407
473
  | `unknown` / `killed` | either | `unknown` |
408
474
  | `error` | either | `error` |
409
475
 
410
- 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
411
485
  (`solving-horizon-N`) are about one bound each and never decide the run.
412
486
 
413
487
  ## what the core owns
@@ -417,6 +491,7 @@ A run with no verdict is read as before. Per-horizon verdicts
417
491
  | per-solver stat parsers, cost-layer assembly, dispatch | `solver_stats.py` |
418
492
  | per-run Savile Row verdict (refuted vs gave up) | `solver_stats.py` (`savilerow_verdict`) |
419
493
  | per-run Fast Downward / SymK verdict (exit code + log) | `solver_stats.py` (`fd_verdict`) |
494
+ | per-run ENHSP verdict (stdout + stderr) | `solver_stats.py` (`enhsp_verdict`) |
420
495
  | Savile Row `.info` split recording (per horizon) | `solver_horizon.py` (`_record_savilerow_times`) |
421
496
  | stats attached to a phase after each solve | `solver_runner.py`, `solver_horizon.py` |
422
497
  | the phase model (`RunSummary` / `PhaseResult`), outcome and success | `run_summary.py` |
@@ -20,7 +20,7 @@ name = "solver-support"
20
20
  # escape hatch for a version that genuinely cannot be known without building.
21
21
  # Ours can. Carrying a `.devN` suffix between releases keeps the working tree
22
22
  # from claiming a version that is already tagged and published.
23
- version = "0.4.0"
23
+ version = "0.4.2"
24
24
  description = "Generic Conjure / Savile Row / Fast Downward / SymK / ENHSP / UPF solver pipeline"
25
25
  readme = "README.md"
26
26
  requires-python = ">=3.9"
@@ -6,10 +6,11 @@ that can be used by different solver types (Savile Row, Conjure, etc.).
6
6
  """
7
7
 
8
8
  import logging
9
+ import re
9
10
  import shutil
10
11
  import time
11
12
  from pathlib import Path
12
- from typing import Optional, Callable, Tuple
13
+ from typing import Optional, Callable, Sequence, Tuple
13
14
 
14
15
  from .file_management import SolutionFileNamer
15
16
  from .global_state import global_state
@@ -76,6 +77,20 @@ def _create_main_solution_from_best(runner: SolverRunner, best_solution_found: P
76
77
  return main_solution
77
78
 
78
79
 
80
+ #: Every name a horizon scan has ever written, for a caller that does not say
81
+ #: which it writes. Only `letting` lines for these are dropped from the instance;
82
+ #: the scan's callback then appends its own for the horizon at hand.
83
+ LEGACY_HORIZON_PARAMS = ('noSteps', 'maxMoves', 'horizon')
84
+
85
+ _LETTING_NAME_RE = re.compile(r'^\s*letting\s+(\w+)\b')
86
+
87
+
88
+ def _is_horizon_letting(line: str, names: Sequence[str]) -> bool:
89
+ """Whether ``line`` sets one of the horizon parameters ``names``."""
90
+ m = _LETTING_NAME_RE.match(line)
91
+ return bool(m) and m.group(1) in names
92
+
93
+
79
94
  def horizon_search(runner: SolverRunner,
80
95
  param_file: Path,
81
96
  startat: int,
@@ -83,7 +98,8 @@ def horizon_search(runner: SolverRunner,
83
98
  logger: logging.Logger,
84
99
  min_horizon: int = 1,
85
100
  stopat: int = 100,
86
- extra_constraints_callback: Optional[Callable[[Path, int], None]] = None) -> Optional[Path]:
101
+ extra_constraints_callback: Optional[Callable[[Path, int], None]] = None,
102
+ horizon_params: Optional[Sequence[str]] = None) -> Optional[Path]:
87
103
  """
88
104
  Generic horizon-extending search loop.
89
105
 
@@ -97,6 +113,9 @@ def horizon_search(runner: SolverRunner,
97
113
  min_horizon: Minimum allowed horizon value
98
114
  stopat: Highest horizon to try (inclusive upper limit of the scan)
99
115
  extra_constraints_callback: Optional function(horizon_param_file, n) to add extra constraints
116
+ horizon_params: The parameter names the callback sets to the horizon, so
117
+ an instance's own value for them is dropped first. None means
118
+ LEGACY_HORIZON_PARAMS.
100
119
 
101
120
  Returns:
102
121
  Path to solution file if found, None otherwise
@@ -137,7 +156,8 @@ def horizon_search(runner: SolverRunner,
137
156
  return None
138
157
 
139
158
  # Create parameter file with horizon constraints
140
- horizon_param_file = create_horizon_param_file(param_file, output_dir, n, logger, runner)
159
+ horizon_param_file = create_horizon_param_file(
160
+ param_file, output_dir, n, logger, runner, horizon_params=horizon_params)
141
161
  if not horizon_param_file:
142
162
  continue
143
163
 
@@ -200,7 +220,8 @@ def horizon_search(runner: SolverRunner,
200
220
  return None
201
221
 
202
222
 
203
- def create_horizon_param_file(param_file: Path, output_dir: Path, n: int, logger: logging.Logger, runner: SolverRunner = None) -> Optional[Path]:
223
+ def create_horizon_param_file(param_file: Path, output_dir: Path, n: int, logger: logging.Logger, runner: SolverRunner = None,
224
+ horizon_params: Optional[Sequence[str]] = None) -> Optional[Path]:
204
225
  """
205
226
  Create parameter file with base content, ready for horizon-specific constraints.
206
227
 
@@ -210,6 +231,9 @@ def create_horizon_param_file(param_file: Path, output_dir: Path, n: int, logger
210
231
  n: Horizon value
211
232
  logger: Logger instance
212
233
  runner: SolverRunner instance for consistent naming (optional for backward compatibility)
234
+ horizon_params: Names whose `letting` lines are dropped from the
235
+ instance, since the caller sets them per horizon. None means
236
+ LEGACY_HORIZON_PARAMS.
213
237
 
214
238
  Returns:
215
239
  Path to created parameter file, or None if creation failed
@@ -233,12 +257,13 @@ def create_horizon_param_file(param_file: Path, output_dir: Path, n: int, logger
233
257
  with open(param_file, 'r') as f:
234
258
  param_content = f.read()
235
259
 
236
- # Remove any existing horizon-related lines
237
- param_lines = []
238
- for line in param_content.splitlines():
239
- # Remove existing horizon constraint lines
240
- if not any(keyword in line for keyword in ['noSteps', 'maxMoves', 'horizon']):
241
- param_lines.append(line)
260
+ # Drop the instance's own value for each horizon parameter: the caller
261
+ # appends one per horizon, and two lettings of a name is an error. Only
262
+ # a `letting` of exactly that name goes - a line that merely mentions
263
+ # it (a comment, `letting horizonLimit`) is the instance's own.
264
+ names = LEGACY_HORIZON_PARAMS if horizon_params is None else tuple(horizon_params)
265
+ param_lines = [line for line in param_content.splitlines()
266
+ if not _is_horizon_letting(line, names)]
242
267
 
243
268
  # Write base parameter file
244
269
  with open(horizon_param_file, 'w') as f:
@@ -8,12 +8,20 @@ from the original bash run script.
8
8
  """
9
9
 
10
10
  from pathlib import Path
11
- from typing import Dict, Any, Optional, List, Tuple, Callable
11
+ from typing import Dict, Any, Optional, List, Tuple, Callable, Sequence
12
12
  import copy
13
+ import os
13
14
  import shutil
14
15
  import yaml
15
16
 
16
17
 
18
+ #: The Savile Row entry a Conjure solver delegates to when its config does not
19
+ #: name one (``savilerow:``), and the entry a Savile Row horizon scan uses when
20
+ #: neither the caller nor the run names one. Kept for configs written before
21
+ #: those could be named; a new config should name its own.
22
+ LEGACY_CONJURE_SAVILEROW = 'kissat'
23
+
24
+
17
25
  class ModelRegistryError(Exception):
18
26
  """Exception raised for model registry errors."""
19
27
  pass
@@ -43,10 +51,13 @@ _user_config_finder: Optional[Callable[[], Optional[Path]]] = None
43
51
  def set_default_config_path(path) -> None:
44
52
  """Register the default ``models.yaml`` the registry loads at startup.
45
53
 
46
- Stored as-is (a path or an ``importlib.resources`` traversable), so the
47
- loader can ``open`` it directly regardless of how the consumer resolved it.
54
+ A ``str`` or other path-like becomes a :class:`~pathlib.Path`; anything
55
+ else (an ``importlib.resources`` traversable) is stored as-is, so the loader
56
+ can ``open`` it directly regardless of how the consumer resolved it.
48
57
  """
49
58
  global _default_config_path
59
+ if isinstance(path, (str, os.PathLike)):
60
+ path = Path(path)
50
61
  _default_config_path = path
51
62
 
52
63
 
@@ -89,9 +100,15 @@ class ModelRegistry(CommandBuilderMixin):
89
100
  # First, load the default YAML config from package
90
101
  default_yaml = self._find_default_yaml_config()
91
102
  if not default_yaml:
103
+ if _default_config_path is not None:
104
+ raise ModelRegistryError(
105
+ f"The registered models.yaml ({_default_config_path}) is not "
106
+ "a file."
107
+ )
92
108
  raise ModelRegistryError(
93
- "Default models.yaml configuration file not found. "
94
- "Please ensure the package is properly installed."
109
+ "No models.yaml is registered: solver-support ships without one. "
110
+ "Call set_default_config_path() before first use; "
111
+ "write_starter_config() writes a starter to begin from."
95
112
  )
96
113
 
97
114
  self.config = {'models': {}, 'solvers': {}}
@@ -642,6 +659,33 @@ class ModelRegistry(CommandBuilderMixin):
642
659
  return None, None
643
660
  return model_config.get('status'), model_config.get('status-detail')
644
661
 
662
+ def get_horizon_params(self, model_type: str,
663
+ default: Sequence[str] = ()) -> List[str]:
664
+ """The parameter names a horizon scan sets to the horizon for a model.
665
+
666
+ Each horizon N reaches the model as ``letting <name> be N`` appended to
667
+ the instance, so these must be exactly the names the model declares:
668
+ Conjure rejects a ``letting`` for a parameter the model does not have.
669
+ Read from the model's ``horizon-params``, then its solver's, then
670
+ ``default`` - the route's own historical names, which configs written
671
+ before the key existed rely on. A single name may be given as a string.
672
+ Resolves aliases and never raises (an unknown model gets ``default``).
673
+ """
674
+ try:
675
+ _, model_config = self.resolve_model_variant(model_type)
676
+ except (ValueError, ModelRegistryError):
677
+ return list(default)
678
+ names = model_config.get('horizon-params')
679
+ if names is None:
680
+ try:
681
+ names = self.get_solver_config(
682
+ model_config.get('solver', '')).get('horizon-params')
683
+ except ModelRegistryError:
684
+ names = None
685
+ if names is None:
686
+ return list(default)
687
+ return [names] if isinstance(names, str) else list(names)
688
+
645
689
  def get_model_default_tune(self, model_type: str) -> Optional[str]:
646
690
  """Return a model's preferred ``--tune`` preset, or None.
647
691
 
@@ -656,17 +700,29 @@ class ModelRegistry(CommandBuilderMixin):
656
700
  return None
657
701
  return model_config.get('tune')
658
702
 
703
+ def get_conjure_savilerow(self, solver_name: str = 'conjure') -> str:
704
+ """The Savile Row entry a Conjure solver hands each horizon's solve to.
705
+
706
+ Conjure only refines and translates. Savile Row then tailors the
707
+ instance and runs its backend, under the solver entry the ``conjure``
708
+ config names with ``savilerow:``. A config without the key gets
709
+ ``kissat``, the name configs written before the key existed use.
710
+ """
711
+ return self.get_solver_config(solver_name).get(
712
+ 'savilerow', LEGACY_CONJURE_SAVILEROW)
713
+
659
714
  def get_tune_backend(self, solver_name: str) -> str:
660
715
  """Return the backend whose tune presets govern ``--tune`` for a solver.
661
716
 
662
717
  Almost always the solver itself. The exception is the Conjure/Essence
663
- path: its per-horizon inner solve is delegated to kissat (see
664
- ConjureSolver), so an Essence run's ``--tune`` presets are kissat's, not
665
- the tune-less ``conjure`` entry's. Keeping this mapping here means the
666
- CLI validity check and ``build_*_command`` agree on what a tune means.
718
+ path: its per-horizon inner solve is delegated to a Savile Row entry
719
+ (:meth:`get_conjure_savilerow`), so an Essence run's ``--tune`` presets
720
+ are that entry's, not the tune-less ``conjure`` entry's. Keeping this
721
+ mapping here means the CLI validity check and ``build_*_command`` agree
722
+ on what a tune means.
667
723
  """
668
724
  if self.get_solver_type(solver_name) == 'conjure':
669
- return 'kissat'
725
+ return self.get_conjure_savilerow(solver_name)
670
726
  return solver_name
671
727
 
672
728
  def get_valid_tunes(self, solver_name: str) -> List[str]:
@@ -828,8 +884,8 @@ class ModelRegistry(CommandBuilderMixin):
828
884
  def starter_config_text() -> str:
829
885
  """The starter ``models.yaml`` shipped with the package, as text.
830
886
 
831
- A commented example registry - Fast Downward, SymK, ENHSP and Savile Row -
832
- for a new user to copy and edit. It is never loaded implicitly: the core
887
+ A commented example registry - Conjure, Savile Row, Fast Downward, SymK
888
+ and ENHSP - for a new user to copy and edit. It is never loaded implicitly: the core
833
889
  still ships unwired, and :class:`ModelRegistry` still raises until a config
834
890
  path is set. Unlike :meth:`ModelRegistry.dump_config` it keeps its comments,
835
891
  which is what makes it fit to start from.
@@ -245,13 +245,14 @@ class RunSummary:
245
245
  if final_status in _COMPLETED_STATUSES:
246
246
  verdict = self._solving_verdict()
247
247
  if verdict is None:
248
- # No account of how the search ended, so 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."""