solver-support 0.3.2__tar.gz → 0.4.0__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 (49) hide show
  1. {solver_support-0.3.2/solver_support.egg-info → solver_support-0.4.0}/PKG-INFO +38 -5
  2. {solver_support-0.3.2 → solver_support-0.4.0}/README.md +36 -3
  3. {solver_support-0.3.2 → solver_support-0.4.0}/docs/injection-api.md +6 -0
  4. {solver_support-0.3.2 → solver_support-0.4.0}/docs/solver-runner.md +26 -3
  5. {solver_support-0.3.2 → solver_support-0.4.0}/docs/timing-model.md +93 -1
  6. {solver_support-0.3.2 → solver_support-0.4.0}/pyproject.toml +7 -2
  7. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/__init__.py +17 -3
  8. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/command_builders.py +29 -0
  9. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/model_registry.py +68 -10
  10. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/run_summary.py +55 -13
  11. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/solver_fd.py +65 -31
  12. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/solver_runner.py +45 -13
  13. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/solver_stats.py +136 -0
  14. solver_support-0.4.0/solver_support/starter/models.yaml +144 -0
  15. {solver_support-0.3.2 → solver_support-0.4.0/solver_support.egg-info}/PKG-INFO +38 -5
  16. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support.egg-info/SOURCES.txt +4 -0
  17. {solver_support-0.3.2 → solver_support-0.4.0}/tests/fixtures/models.yaml +44 -0
  18. {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_command_builders.py +21 -0
  19. solver_support-0.4.0/tests/test_fd_verdict.py +148 -0
  20. solver_support-0.4.0/tests/test_proves_unsat.py +50 -0
  21. {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_run_summary_success.py +58 -0
  22. {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_solver_runner.py +53 -0
  23. {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_solvers.py +86 -6
  24. solver_support-0.4.0/tests/test_starter_config.py +85 -0
  25. {solver_support-0.3.2 → solver_support-0.4.0}/LICENSE +0 -0
  26. {solver_support-0.3.2 → solver_support-0.4.0}/MANIFEST.in +0 -0
  27. {solver_support-0.3.2 → solver_support-0.4.0}/setup.cfg +0 -0
  28. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/file_management.py +0 -0
  29. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/global_state.py +0 -0
  30. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/horizon_utils.py +0 -0
  31. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/objective.py +0 -0
  32. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/solver_base.py +0 -0
  33. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/solver_enhsp.py +0 -0
  34. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/solver_horizon.py +0 -0
  35. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/solver_upfplan.py +0 -0
  36. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support/utilities.py +0 -0
  37. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support.egg-info/dependency_links.txt +0 -0
  38. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support.egg-info/not-zip-safe +0 -0
  39. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support.egg-info/requires.txt +0 -0
  40. {solver_support-0.3.2 → solver_support-0.4.0}/solver_support.egg-info/top_level.txt +0 -0
  41. {solver_support-0.3.2 → solver_support-0.4.0}/tests/conftest.py +0 -0
  42. {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_helpers.py +0 -0
  43. {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_horizon_utils.py +0 -0
  44. {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_import.py +0 -0
  45. {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_registry_dump.py +0 -0
  46. {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_run_summary.py +0 -0
  47. {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_savilerow_info_recording.py +0 -0
  48. {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_solution_naming.py +0 -0
  49. {solver_support-0.3.2 → solver_support-0.4.0}/tests/test_solver_stats.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solver-support
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Generic Conjure / Savile Row / Fast Downward / SymK / ENHSP / UPF solver pipeline
5
5
  Author: András Salamon
6
6
  License-Expression: BSD-3-Clause
7
7
  Project-URL: Homepage, https://github.com/ott2/solver-support
8
8
  Project-URL: Repository, https://github.com/ott2/solver-support
9
9
  Project-URL: Issues, https://github.com/ott2/solver-support/issues
10
- Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Development Status :: 4 - Beta
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: Intended Audience :: Science/Research
13
13
  Classifier: Operating System :: OS Independent
@@ -48,8 +48,10 @@ consumer supplies:
48
48
 
49
49
  ## Status
50
50
 
51
- **Alpha.** The pipeline is in day-to-day use by several consumers and is covered
52
- by its own test suite, but the API may still shift between 0.x releases. Each
51
+ **Beta.** The pipeline is in day-to-day use by several consumers and is covered
52
+ by its own test suite. The injection seams and the run-summary vocabulary
53
+ (`outcome`, `stop_reason`, `verdict`) are stable: later releases add to them
54
+ rather than rename them. Other API may still shift between 0.x releases. Each
53
55
  release's changes are listed on the
54
56
  [releases page](https://github.com/ott2/solver-support/releases).
55
57
 
@@ -70,6 +72,37 @@ Requires Python 3.9 or newer (3.9.6 is the system Python on macOS, and is
70
72
  supported deliberately — each release is built and tested against it). The only
71
73
  runtime dependencies are `psutil` and `PyYAML`.
72
74
 
75
+ ## Quick start
76
+
77
+ The package ships no model registry, but it carries a commented starter one
78
+ (Fast Downward, SymK, ENHSP and Savile Row) to copy and edit. Writing it out
79
+ does not wire it; you point the registry at it:
80
+
81
+ ```python
82
+ from pathlib import Path
83
+ from solver_support import (
84
+ write_starter_config, set_default_config_path, set_resource_root,
85
+ global_state, SolverRunner, FastDownwardSolver, fd_backstop,
86
+ )
87
+
88
+ config = write_starter_config("config/models.yaml") # once; then edit it
89
+ set_default_config_path(config)
90
+ set_resource_root(".") # where the models' `path:` entries resolve
91
+
92
+ registry = global_state.model_registry
93
+ runner = SolverRunner(instance="p01", model="pddl", solver="fd", timeout=60,
94
+ timestamp="run1", summary_file=Path("p01.json"))
95
+ try:
96
+ plan = FastDownwardSolver(runner).solve(
97
+ Path("p01.pddl"), model_path=str(registry.get_model_path("pddl")),
98
+ watchdog=fd_backstop(60)) # let the planner's own limit fire
99
+ status = "done" if plan else "failed"
100
+ except TimeoutError:
101
+ status = "timeout"
102
+ runner.finalize_summary(status, proves_unsat=registry.get_proves_unsat("pddl"))
103
+ print(runner.summary.outcome) # solved / unsat / unknown / error
104
+ ```
105
+
73
106
  ## Usage
74
107
 
75
108
  See [`docs/injection-api.md`](https://github.com/ott2/solver-support/blob/main/docs/injection-api.md)
@@ -125,4 +158,4 @@ config — that shape is the contract the loader implements.
125
158
  BSD-3-Clause — see
126
159
  [`LICENSE`](https://github.com/ott2/solver-support/blob/main/LICENSE).
127
160
 
128
- By András Salamon, with Claude Opus 4.8 and 5.
161
+ By András Salamon, with Claude Opus 4.8, 5 and 5.5.
@@ -20,8 +20,10 @@ consumer supplies:
20
20
 
21
21
  ## Status
22
22
 
23
- **Alpha.** The pipeline is in day-to-day use by several consumers and is covered
24
- by its own test suite, but the API may still shift between 0.x releases. Each
23
+ **Beta.** The pipeline is in day-to-day use by several consumers and is covered
24
+ by its own test suite. The injection seams and the run-summary vocabulary
25
+ (`outcome`, `stop_reason`, `verdict`) are stable: later releases add to them
26
+ rather than rename them. Other API may still shift between 0.x releases. Each
25
27
  release's changes are listed on the
26
28
  [releases page](https://github.com/ott2/solver-support/releases).
27
29
 
@@ -42,6 +44,37 @@ Requires Python 3.9 or newer (3.9.6 is the system Python on macOS, and is
42
44
  supported deliberately — each release is built and tested against it). The only
43
45
  runtime dependencies are `psutil` and `PyYAML`.
44
46
 
47
+ ## Quick start
48
+
49
+ The package ships no model registry, but it carries a commented starter one
50
+ (Fast Downward, SymK, ENHSP and Savile Row) to copy and edit. Writing it out
51
+ does not wire it; you point the registry at it:
52
+
53
+ ```python
54
+ from pathlib import Path
55
+ from solver_support import (
56
+ write_starter_config, set_default_config_path, set_resource_root,
57
+ global_state, SolverRunner, FastDownwardSolver, fd_backstop,
58
+ )
59
+
60
+ config = write_starter_config("config/models.yaml") # once; then edit it
61
+ set_default_config_path(config)
62
+ set_resource_root(".") # where the models' `path:` entries resolve
63
+
64
+ registry = global_state.model_registry
65
+ runner = SolverRunner(instance="p01", model="pddl", solver="fd", timeout=60,
66
+ timestamp="run1", summary_file=Path("p01.json"))
67
+ try:
68
+ plan = FastDownwardSolver(runner).solve(
69
+ Path("p01.pddl"), model_path=str(registry.get_model_path("pddl")),
70
+ watchdog=fd_backstop(60)) # let the planner's own limit fire
71
+ status = "done" if plan else "failed"
72
+ except TimeoutError:
73
+ status = "timeout"
74
+ runner.finalize_summary(status, proves_unsat=registry.get_proves_unsat("pddl"))
75
+ print(runner.summary.outcome) # solved / unsat / unknown / error
76
+ ```
77
+
45
78
  ## Usage
46
79
 
47
80
  See [`docs/injection-api.md`](https://github.com/ott2/solver-support/blob/main/docs/injection-api.md)
@@ -97,4 +130,4 @@ config — that shape is the contract the loader implements.
97
130
  BSD-3-Clause — see
98
131
  [`LICENSE`](https://github.com/ott2/solver-support/blob/main/LICENSE).
99
132
 
100
- By András Salamon, with Claude Opus 4.8 and 5.
133
+ By András Salamon, with Claude Opus 4.8, 5 and 5.5.
@@ -28,6 +28,12 @@ candidates bind when the registry is constructed rather than at import. Until a
28
28
  default config path is registered, constructing `ModelRegistry()` raises
29
29
  `ModelRegistryError` rather than guessing a consumer's config.
30
30
 
31
+ To start a config from scratch, `write_starter_config(path)` writes out the
32
+ commented starter registry that ships as package data (`starter_config_text()`
33
+ returns its text). Writing it out wires nothing. The registry still raises until
34
+ `set_default_config_path` names it, so a starter file on disk can never change
35
+ what an unwired core does.
36
+
31
37
  ### 2. objective extraction (`solver_support.objective`)
32
38
 
33
39
  The core asks for a solution's objective at a few points but does not know how a
@@ -21,13 +21,15 @@ the pipeline branches on.
21
21
 
22
22
  ```python
23
23
  SolverResult(command, returncode, stdout, stderr, duration,
24
- timed_out=False, interrupted=False)
24
+ timed_out=False, interrupted=False, launch_failed=False,
25
+ ok_returncodes=None)
25
26
  ```
26
27
 
27
28
  | field | meaning |
28
29
  |-------|---------|
29
30
  | `command: List[str]` | the command and arguments that ran |
30
- | `returncode: int` | process return code (0 for success) |
31
+ | `returncode: int` | process return code |
32
+ | `ok_returncodes` | the codes that count as success; `{0}` unless the caller says otherwise |
31
33
  | `stdout: str` / `stderr: str` | captured output, kept separate |
32
34
  | `duration: float` | **actual** seconds the command ran |
33
35
  | `timed_out: bool` | it was killed for exceeding the timeout |
@@ -54,9 +56,30 @@ a component of it — see the invariants in [`timing-model.md`](timing-model.md)
54
56
 
55
57
  ### properties
56
58
 
57
- - `success: bool` — completed with return code 0, no timeout, no interruption.
59
+ - `success: bool` — exited with one of its `ok_returncodes`, no timeout, no
60
+ interruption, no launch failure.
58
61
  - `failed: bool` — the inverse; useful as a direct `if` on a run.
59
62
 
63
+ ### solvers that answer through the exit code
64
+
65
+ Some solvers put the *answer* in the exit code, so for them a non-zero exit is
66
+ the solver doing its job. Every DIMACS SAT solver exits 10 for SATISFIABLE and
67
+ 20 for UNSATISFIABLE; read against `{0}`, a refutation would be logged as
68
+ `Command failed` and recorded as `success=False`. Pass the solver's set to `run`
69
+ or `run_phase`:
70
+
71
+ ```python
72
+ from solver_support.solver_runner import DIMACS_SAT_RETURNCODES
73
+
74
+ result = runner.run(['kissat', 'model.cnf'], ok_returncodes=DIMACS_SAT_RETURNCODES)
75
+ result.success # True for 0 (unknown), 10 (sat) and 20 (unsat)
76
+ result.returncode # which of them
77
+ ```
78
+
79
+ `success` then means "the process ran and answered", not what the answer was:
80
+ read `returncode` (or the parsed stats) for that. The runner never picks the set
81
+ by detecting the solver from the command, so detection cannot decide success.
82
+
60
83
  Note that `failed` covers three *different* outcomes, and the pipeline usually
61
84
  cares which: check `timed_out` and `interrupted` before treating a run as a
62
85
  genuine solver failure. A timed-out anytime solver may still have banked a
@@ -238,6 +238,73 @@ N+1. Only the registry knows which models decide the problem itself
238
238
  - **Timeout**: the final, cut-off layer is bounded by `end_hint = duration -
239
239
  translate_time` (the search-relative kill time), so the blown-up deepest bound
240
240
  does not collapse to ~0.
241
+ - **Its own limit, and the backstop above it**: `--search-time-limit T` is a CPU
242
+ rlimit on the search alone, while the runner's watchdog counts wall clock over
243
+ translation plus search. A watchdog at `T` therefore always fires first, and
244
+ the run loses the exit 23 and the final statistics block it would have
245
+ written. Size the watchdog with `fd_backstop(T, translate_allowance)`, which
246
+ is `T`, plus translation, plus `FD_EXIT_GRACE`, and pass it as
247
+ `FastDownwardSolver.solve(..., watchdog=...)`. The runner's own timeout stays
248
+ the in-band limit. Then an external kill once again means the planner ignored
249
+ its limit. As with Savile Row, this is a
250
+ backstop and not a budget: to hold a run to a wall-clock `T`, shrink the search
251
+ limit.
252
+
253
+ #### Verdicts: proof, limit, or rejection
254
+
255
+ Fast Downward answers through its **exit code** (`driver/returncodes.py`), so
256
+ `run_phase` is handed `FD_ANSWER_RETURNCODES` and a proof is not logged as a
257
+ failed command. `SolverStatsExtractor.fd_verdict` reads the code, plus the log
258
+ where the code is ambiguous, into the same vocabulary as the Savile Row verdict.
259
+ `FastDownwardSolver` records it on the `solving` phase:
260
+
261
+ | exit | verdict | `verdict_reason` |
262
+ |---|---|---|
263
+ | 0–3 (plan; 1–3 then hit a limit) | `sat` | |
264
+ | 10, 11 | `unsat` | (the planner's own claim) |
265
+ | 12, see below | `unsat` / `unknown` | `exhausted`, or `search-limit` / `bounded` / `portfolio` / `incomplete` |
266
+ | 13 (26.6+) | `unknown` | `bounded` |
267
+ | 20 / 21 | `unknown` | `translate-memory` / `translate-limit` |
268
+ | 22, 24 / 23 | `unknown` | `memory` / `search-limit` |
269
+ | 34, 37 | `error` | `unsupported` (`unknown`/`portfolio` for an anytime portfolio) |
270
+ | 31, 33, 36 | `error` | `input-error` |
271
+ | 30, 32, 35, a signal | `error` | `crash` |
272
+ | killed by the runner | `killed` | |
273
+
274
+ **Exit 12 is ambiguous by construction.** Before 26.6 the planner exits
275
+ `found_solution() ? 0 : 12` however the search ended, so the log decides:
276
+
277
+ - `Time limit reached. Abort search.` (the search's `max_time=`) gives
278
+ `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
282
+ `Generating unsolvable task` lands here too, since the search then exhausts a
283
+ trivially false goal.
284
+ - SymK prints no exhaustion line. Its last `BOUND: L < U` line, with `L ≥ U`, is
285
+ `exhausted` when `U` is infinity (`2147483647`) and `bounded` otherwise.
286
+ - A portfolio's 12 is always `portfolio`. It can mask its components'
287
+ time-outs, and its exhaustion line may come from a component that pruned
288
+ unsafely.
289
+ - Anything else is `incomplete`: EHC giving up, or a 26.6 search that the
290
+ planner itself judged incomplete (`Search terminated without finding a
291
+ plan!`).
292
+
293
+ **`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.
302
+
303
+ **The planner's own time limit is a timeout.** `search-limit` and
304
+ `translate-limit` make `solve()` raise `TimeoutError`, exactly as a watchdog
305
+ kill does, and so does an anytime portfolio that ends without a plan. Every other
306
+ no-plan ending returns `None`, and the run's `outcome` is derived from the
307
+ verdict.
241
308
 
242
309
  ### UPF (via the consumer's UPF CLI; PDDL or constructed models)
243
310
 
@@ -319,16 +386,41 @@ run is ever concluded unsat, which understates rather than overstates what was
319
386
  proved. A status that stopped early (`timeout`, `hlim`, `stopped`) is never
320
387
  `unsat` however complete the model.
321
388
 
389
+ For a planner, being able to prove depends on the **search** as well as the model.
390
+ `get_proves_unsat(model, tune=..., solver_name=...)` is the model's
391
+ `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
394
+ a model that cannot prove. `finalize` keeps the value on the summary as
395
+ `proves_unsat`, and a later `finalize` that passes None reuses it. So a
396
+ re-finalize that folds in a late phase, as `horizon_utils` does, cannot drop it.
397
+
398
+ **A `solving` phase's verdict decides a completed run.** `failed` is the status
399
+ of both a planner's proof and its crash, so `proves_unsat` alone would promote
400
+ 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:
402
+
403
+ | verdict | `proves_unsat` | outcome |
404
+ |---|---|---|
405
+ | `unsat` | True | `unsat` |
406
+ | `unsat` | False | `unknown`: the search ran out, but cannot prove |
407
+ | `unknown` / `killed` | either | `unknown` |
408
+ | `error` | either | `error` |
409
+
410
+ A run with no verdict is read as before. Per-horizon verdicts
411
+ (`solving-horizon-N`) are about one bound each and never decide the run.
412
+
322
413
  ## what the core owns
323
414
 
324
415
  | concern | location |
325
416
  |---------|----------|
326
417
  | per-solver stat parsers, cost-layer assembly, dispatch | `solver_stats.py` |
327
418
  | per-run Savile Row verdict (refuted vs gave up) | `solver_stats.py` (`savilerow_verdict`) |
419
+ | per-run Fast Downward / SymK verdict (exit code + log) | `solver_stats.py` (`fd_verdict`) |
328
420
  | Savile Row `.info` split recording (per horizon) | `solver_horizon.py` (`_record_savilerow_times`) |
329
421
  | stats attached to a phase after each solve | `solver_runner.py`, `solver_horizon.py` |
330
422
  | the phase model (`RunSummary` / `PhaseResult`), outcome and success | `run_summary.py` |
331
- | relating the in-band limits to the external kill | `command_builders.py` (`savilerow_backstop`) |
423
+ | relating the in-band limits to the external kill | `command_builders.py` (`savilerow_backstop`, `fd_backstop`) |
332
424
 
333
425
  Turning these per-phase stats into a unified per-horizon curve for display, a
334
426
  single "preprocess" baseline, and plots is the **consumer's** job — it reads the
@@ -20,7 +20,7 @@ name = "solver-support"
20
20
  # escape hatch for a version that genuinely cannot be known without building.
21
21
  # Ours can. Carrying a `.devN` suffix between releases keeps the working tree
22
22
  # from claiming a version that is already tagged and published.
23
- version = "0.3.2"
23
+ version = "0.4.0"
24
24
  description = "Generic Conjure / Savile Row / Fast Downward / SymK / ENHSP / UPF solver pipeline"
25
25
  readme = "README.md"
26
26
  requires-python = ">=3.9"
@@ -29,7 +29,7 @@ authors = [
29
29
  {name = "András Salamon"},
30
30
  ]
31
31
  classifiers = [
32
- "Development Status :: 3 - Alpha",
32
+ "Development Status :: 4 - Beta",
33
33
  "Intended Audience :: Developers",
34
34
  "Intended Audience :: Science/Research",
35
35
  "Operating System :: OS Independent",
@@ -81,6 +81,11 @@ zip-safe = false
81
81
  [tool.setuptools.packages.find]
82
82
  include = ["solver_support*"]
83
83
 
84
+ # The starter registry `write_starter_config` copies out. Package data, not a
85
+ # default: nothing loads it unless a user writes it out and wires it.
86
+ [tool.setuptools.package-data]
87
+ solver_support = ["starter/*.yaml"]
88
+
84
89
  [tool.pytest.ini_options]
85
90
  testpaths = ["tests"]
86
91
  python_files = ["test_*.py"]
@@ -45,8 +45,12 @@ from .model_registry import (
45
45
  set_default_config_path,
46
46
  set_resource_root,
47
47
  set_user_config_finder,
48
+ starter_config_text,
49
+ write_starter_config,
50
+ )
51
+ from .command_builders import (
52
+ savilerow_backstop, SAVILEROW_FLUSH_GRACE, fd_backstop, FD_EXIT_GRACE,
48
53
  )
49
- from .command_builders import savilerow_backstop, SAVILEROW_FLUSH_GRACE
50
54
 
51
55
  # --- run configuration singleton ---
52
56
  from .global_state import global_state, GlobalState
@@ -68,8 +72,12 @@ from .solver_upfplan import UpfPlanSolver
68
72
  from .solver_horizon import HorizonBasedSolver, SavileRowSolver, ConjureSolver
69
73
 
70
74
  # --- execution, stats, run model ---
71
- from .solver_runner import SolverRunner, SolverResult, SolverExecutableError
72
- from .solver_stats import SolverStatsExtractor, detect_solver_from_command
75
+ from .solver_runner import (
76
+ SolverRunner, SolverResult, SolverExecutableError, DIMACS_SAT_RETURNCODES,
77
+ )
78
+ from .solver_stats import (
79
+ SolverStatsExtractor, detect_solver_from_command, FD_ANSWER_RETURNCODES,
80
+ )
73
81
  from .run_summary import RunSummary, PhaseResult, create_run_summary
74
82
 
75
83
  # --- file naming and horizon loop ---
@@ -107,8 +115,12 @@ __all__ = [
107
115
  "set_default_config_path",
108
116
  "set_resource_root",
109
117
  "set_user_config_finder",
118
+ "starter_config_text",
119
+ "write_starter_config",
110
120
  "savilerow_backstop",
111
121
  "SAVILEROW_FLUSH_GRACE",
122
+ "fd_backstop",
123
+ "FD_EXIT_GRACE",
112
124
  # run config
113
125
  "global_state",
114
126
  "GlobalState",
@@ -130,6 +142,8 @@ __all__ = [
130
142
  "SolverRunner",
131
143
  "SolverResult",
132
144
  "SolverExecutableError",
145
+ "DIMACS_SAT_RETURNCODES",
146
+ "FD_ANSWER_RETURNCODES",
133
147
  "SolverStatsExtractor",
134
148
  "detect_solver_from_command",
135
149
  "RunSummary",
@@ -56,6 +56,35 @@ def savilerow_backstop(timeout: int, solver_timeout: Optional[int] = None,
56
56
  return timeout + (timeout if solver_timeout is None else solver_timeout) + grace
57
57
 
58
58
 
59
+ #: Seconds of slack above Fast Downward's in-band search limit. The driver turns
60
+ #: SIGXCPU into exit 23 and the search then prints its final statistics block
61
+ #: ("Actual search time", expansions); the grace lets that land. It is also what
62
+ #: absorbs the gap between the CPU seconds the limit counts and the wall-clock
63
+ #: seconds the watchdog counts, which a loaded machine widens.
64
+ FD_EXIT_GRACE = 30
65
+
66
+
67
+ def fd_backstop(timeout: int, translate_allowance: Optional[int] = None,
68
+ grace: int = FD_EXIT_GRACE) -> int:
69
+ """The external timeout that cannot preempt Fast Downward's own limit.
70
+
71
+ :meth:`build_fd_command` passes ``--search-time-limit T``, a CPU rlimit
72
+ on the *search* alone, while the runner's watchdog kills at ``T`` seconds of
73
+ wall clock over translation *plus* search. CPU time never exceeds the wall
74
+ time since search began, so a watchdog at ``T`` always fires first and the
75
+ in-band limit never gets to: the run is SIGTERMed instead of exiting 23, its
76
+ statistics are lost, and its verdict can only say ``killed``.
77
+
78
+ Sized like :func:`savilerow_backstop`: the search limit, plus what
79
+ translation may take before it (``translate_allowance``, defaulting to the
80
+ search limit, since translation is unbounded in band and only the caller
81
+ knows its cost), plus grace. The same caveat applies: this is a backstop,
82
+ not a wall-clock budget. To hold a run to ``T``, shrink the search limit.
83
+ """
84
+ allowance = timeout if translate_allowance is None else translate_allowance
85
+ return timeout + allowance + grace
86
+
87
+
59
88
  class CommandBuilderMixin:
60
89
  """The ``build_*_command`` methods mixed into :class:`ModelRegistry`."""
61
90
 
@@ -581,26 +581,51 @@ class ModelRegistry(CommandBuilderMixin):
581
581
  return False
582
582
  return model_config.get('objective-in-solution', False)
583
583
 
584
- def get_proves_unsat(self, model_type: str) -> bool:
585
- """Whether a model can soundly conclude unsatisfiability.
586
-
587
- True only for complete decision procedures (the pddl* planning models
588
- and the upf/upflocal viewpoints, flagged ``proves-unsat: true``) whose
589
- 'no solution' run is a genuine unsat proof. Bounded CP models carry no
590
- such field and so default to False - their 'failed'/'no solution' is an
591
- error or an unfinished horizon search, never an unsat verdict.
584
+ def get_proves_unsat(self, model_type: str, tune: Optional[str] = None,
585
+ solver_name: Optional[str] = None) -> bool:
586
+ """Whether a run of this model can soundly conclude unsatisfiability.
587
+
588
+ True only for complete decision procedures (planning models, flagged
589
+ ``proves-unsat: true``) whose 'no solution' run is a genuine unsat
590
+ proof. Bounded CP models carry no such field and so default to False -
591
+ their 'failed'/'no solution' is an error or an unfinished horizon
592
+ search, never an unsat verdict.
593
+
594
+ For a planner, completeness also depends on the *search*: the same model
595
+ proves unsat under blind A* and not under a relaxation heuristic that
596
+ prunes unsafely on a task with axioms. So a tune preset may carry
597
+ ``proves-unsat: false``, and the answer is the model's flag AND the
598
+ effective tune's. A tune can only take the claim away, never grant it to
599
+ a model that cannot prove.
592
600
 
593
601
  Args:
594
602
  model_type: The model type.
603
+ tune: The explicit ``--tune``, if any. Otherwise the model's pinned
604
+ ``tune:``, then the solver's ``default_tune`` - the resolution
605
+ the solver used to build its command.
606
+ solver_name: The solver that ran, if not the model's own ``solver:``.
595
607
 
596
608
  Returns:
597
- True if the model declares ``proves-unsat: true``, else False.
609
+ True if the model declares ``proves-unsat: true`` and the effective
610
+ tune does not declare ``proves-unsat: false``, else False.
598
611
  """
599
612
  try:
600
613
  _, model_config = self.resolve_model_variant(model_type)
601
614
  except (ValueError, ModelRegistryError):
602
615
  return False
603
- return model_config.get('proves-unsat', False)
616
+ if not model_config.get('proves-unsat', False):
617
+ return False
618
+ solver_name = solver_name or model_config.get('solver')
619
+ if not solver_name:
620
+ return True
621
+ try:
622
+ solver_config = self.get_solver_config(self.get_tune_backend(solver_name))
623
+ except ModelRegistryError:
624
+ return True
625
+ tunes = solver_config.get('tune', {})
626
+ tune = tune or model_config.get('tune')
627
+ name = tune if tune in tunes else solver_config.get('default_tune')
628
+ return bool(tunes.get(name, {}).get('proves-unsat', True))
604
629
 
605
630
  def get_model_status(self, model_type: str) -> Tuple[Optional[str], Optional[str]]:
606
631
  """Return a model's health status and an optional detail string.
@@ -800,6 +825,39 @@ class ModelRegistry(CommandBuilderMixin):
800
825
  return self.get_tune_regime(solver_name, tune) == 'anytime'
801
826
 
802
827
 
828
+ def starter_config_text() -> str:
829
+ """The starter ``models.yaml`` shipped with the package, as text.
830
+
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
833
+ still ships unwired, and :class:`ModelRegistry` still raises until a config
834
+ path is set. Unlike :meth:`ModelRegistry.dump_config` it keeps its comments,
835
+ which is what makes it fit to start from.
836
+ """
837
+ from importlib.resources import files
838
+ return files('solver_support').joinpath('starter', 'models.yaml').read_text(
839
+ encoding='utf-8')
840
+
841
+
842
+ def write_starter_config(path, overwrite: bool = False) -> Path:
843
+ """Write the starter ``models.yaml`` to ``path`` and return the path.
844
+
845
+ The quick start for a new user, who then wires it with
846
+ :func:`set_default_config_path`. Writing it out does *not* wire it: having
847
+ a starter file on disk must never change what an unwired registry does.
848
+
849
+ Raises:
850
+ FileExistsError: if ``path`` exists and ``overwrite`` is False - an edited
851
+ registry is the user's work, and a starter is not worth losing it for.
852
+ """
853
+ path = Path(path)
854
+ if path.exists() and not overwrite:
855
+ raise FileExistsError(f"{path} already exists (pass overwrite=True to replace it)")
856
+ path.parent.mkdir(parents=True, exist_ok=True)
857
+ path.write_text(starter_config_text(), encoding='utf-8')
858
+ return path
859
+
860
+
803
861
  def get_default_registry() -> ModelRegistry:
804
862
  """
805
863
  Get a default model registry instance.
@@ -133,9 +133,20 @@ _SUCCESS_OUTCOMES = frozenset({'solved', 'unsat'})
133
133
  # stopped early and the outcome can only be 'unknown', however complete the
134
134
  # model - which is what stops 'hlim' being read as a proof. ('failed' is here
135
135
  # because that is the status a planner's proven-unsat arrives under: it exits
136
- # non-zero, having answered the question.)
136
+ # non-zero, having answered the question. It is also the status of a crash, which
137
+ # is why a solving phase's `verdict`, where there is one, decides between them.)
137
138
  _COMPLETED_STATUSES = frozenset({"done", "success", "failed"})
138
139
 
140
+ # A completed run with no solution, read through its solving phase's verdict
141
+ # (see SolverStatsExtractor.fd_verdict). `unsat` is handled separately: it needs
142
+ # the model's proves_unsat as well.
143
+ _OUTCOME_BY_VERDICT = {
144
+ 'unknown': 'unknown', # stopped at a limit, or a search that cannot refute
145
+ 'killed': 'unknown',
146
+ 'error': 'error',
147
+ 'sat': 'error', # claimed a plan, yet none reached the summary
148
+ }
149
+
139
150
  # final_status -> why the run stopped. Statuses absent here leave stop_reason
140
151
  # empty rather than guessing: an unrecognised label is a consumer's own, and
141
152
  # inventing a reason for it would put words in its mouth.
@@ -174,6 +185,10 @@ class RunSummary:
174
185
  # written before these existed.
175
186
  outcome: str = "" # solved / unsat / unknown / error
176
187
  stop_reason: str = "" # complete / time / horizons / interrupt / error
188
+ # Whether this run's model AND search can prove unsatisfiability (the
189
+ # registry's get_proves_unsat), as last given to finalize(). Kept so that a
190
+ # later re-finalize - after appending a phase, say - cannot drop it.
191
+ proves_unsat: bool = False
177
192
  score: Optional[int] = None # Total score the model reported (scored/upf models only)
178
193
  startat: Optional[int] = None # Starting horizon for Savile Row models (if specified)
179
194
  stopat: Optional[int] = None # Highest horizon tried, if the default cap (100) was overridden
@@ -228,11 +243,22 @@ class RunSummary:
228
243
  return 'error'
229
244
 
230
245
  if final_status in _COMPLETED_STATUSES:
231
- # The search finished and produced nothing. From a model that
232
- # decides the problem that IS the answer; from any other model it
233
- # means something went wrong, because a bounded model that finishes
234
- # unsolved has not concluded anything -- it has merely stopped.
235
- return 'unsat' if proves_unsat else 'error'
246
+ verdict = self._solving_verdict()
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'
255
+ # The solver said how its search ended. Only a refutation from a
256
+ # search that can prove one is a proof; an exhausted search that
257
+ # cannot (an unsafe heuristic, a tune switched off) concluded
258
+ # nothing, but nothing went wrong either.
259
+ if verdict == 'unsat':
260
+ return 'unsat' if proves_unsat else 'unknown'
261
+ return _OUTCOME_BY_VERDICT.get(verdict, 'error')
236
262
 
237
263
  # 'timeout', 'hlim', 'stopped', or a label this core does not know: the
238
264
  # search stopped early. Nothing was decided, and no completeness of the
@@ -240,19 +266,35 @@ class RunSummary:
240
266
  # N+1, which is exactly why 'hlim' must never become 'unsat'.
241
267
  return 'unknown'
242
268
 
269
+ def _solving_verdict(self) -> Optional[str]:
270
+ """The ``verdict`` the ``solving`` phase recorded, if it recorded one.
271
+
272
+ Only the single-phase solvers' ``solving`` phase: a horizon scan's
273
+ per-horizon verdicts are about one bound each, and never decide the run.
274
+ """
275
+ phase = self.get_phase("solving")
276
+ if phase is None:
277
+ return None
278
+ return phase.solver_stats.get('verdict')
279
+
243
280
  def finalize(self, final_status: str = "done",
244
- proves_unsat: bool = False) -> None:
281
+ proves_unsat: Optional[bool] = None) -> None:
245
282
  """Finalize the summary: end time, outcome, stop reason, success.
246
283
 
247
- ``proves_unsat`` says whether this run's model is a complete decision
248
- procedure, i.e. whether its "no solution" is a sound proof. The core
249
- cannot know that from the output - a bounded CP model and a planner print
250
- the same thing - so the caller supplies it, normally from the registry's
251
- ``get_proves_unsat``. Left False, a no-solution run is never concluded
252
- unsat, which is the safe direction: understating what was proved.
284
+ ``proves_unsat`` says whether this run's model, under the search it
285
+ ran, is a complete decision procedure, i.e. whether its "no solution" is
286
+ a sound proof. The core cannot know that from the output - a bounded CP
287
+ model and a planner print the same thing - so the caller supplies it,
288
+ normally from the registry's ``get_proves_unsat``. Left False, a
289
+ no-solution run is never concluded unsat, which is the safe direction:
290
+ understating what was proved. None keeps the value from the previous
291
+ finalize, so re-finalizing to fold in a late phase cannot drop it.
253
292
  """
254
293
  self.end_time = datetime.now()
255
294
  self.final_status = final_status
295
+ if proves_unsat is not None:
296
+ self.proves_unsat = proves_unsat
297
+ proves_unsat = self.proves_unsat
256
298
 
257
299
  # Check if we have a successful timeout-based solution phase: an anytime
258
300
  # run that found a solution before the budget ran out still succeeded.