solver-support 0.3.2__tar.gz → 0.4.1__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 (50) hide show
  1. {solver_support-0.3.2/solver_support.egg-info → solver_support-0.4.1}/PKG-INFO +38 -5
  2. {solver_support-0.3.2 → solver_support-0.4.1}/README.md +36 -3
  3. {solver_support-0.3.2 → solver_support-0.4.1}/docs/injection-api.md +6 -0
  4. {solver_support-0.3.2 → solver_support-0.4.1}/docs/solver-runner.md +26 -3
  5. {solver_support-0.3.2 → solver_support-0.4.1}/docs/timing-model.md +113 -1
  6. {solver_support-0.3.2 → solver_support-0.4.1}/pyproject.toml +7 -2
  7. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/__init__.py +17 -3
  8. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/command_builders.py +29 -0
  9. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/horizon_utils.py +35 -10
  10. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/model_registry.py +133 -19
  11. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/run_summary.py +55 -13
  12. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/solver_fd.py +65 -31
  13. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/solver_horizon.py +110 -22
  14. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/solver_runner.py +45 -13
  15. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/solver_stats.py +136 -0
  16. solver_support-0.4.1/solver_support/starter/models.yaml +189 -0
  17. {solver_support-0.3.2 → solver_support-0.4.1/solver_support.egg-info}/PKG-INFO +38 -5
  18. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support.egg-info/SOURCES.txt +5 -0
  19. {solver_support-0.3.2 → solver_support-0.4.1}/tests/fixtures/models.yaml +70 -0
  20. {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_command_builders.py +21 -0
  21. solver_support-0.4.1/tests/test_fd_verdict.py +148 -0
  22. solver_support-0.4.1/tests/test_horizon_params.py +139 -0
  23. solver_support-0.4.1/tests/test_proves_unsat.py +50 -0
  24. {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_run_summary_success.py +58 -0
  25. {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_solver_runner.py +53 -0
  26. {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_solvers.py +86 -6
  27. solver_support-0.4.1/tests/test_starter_config.py +160 -0
  28. {solver_support-0.3.2 → solver_support-0.4.1}/LICENSE +0 -0
  29. {solver_support-0.3.2 → solver_support-0.4.1}/MANIFEST.in +0 -0
  30. {solver_support-0.3.2 → solver_support-0.4.1}/setup.cfg +0 -0
  31. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/file_management.py +0 -0
  32. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/global_state.py +0 -0
  33. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/objective.py +0 -0
  34. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/solver_base.py +0 -0
  35. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/solver_enhsp.py +0 -0
  36. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/solver_upfplan.py +0 -0
  37. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support/utilities.py +0 -0
  38. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support.egg-info/dependency_links.txt +0 -0
  39. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support.egg-info/not-zip-safe +0 -0
  40. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support.egg-info/requires.txt +0 -0
  41. {solver_support-0.3.2 → solver_support-0.4.1}/solver_support.egg-info/top_level.txt +0 -0
  42. {solver_support-0.3.2 → solver_support-0.4.1}/tests/conftest.py +0 -0
  43. {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_helpers.py +0 -0
  44. {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_horizon_utils.py +0 -0
  45. {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_import.py +0 -0
  46. {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_registry_dump.py +0 -0
  47. {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_run_summary.py +0 -0
  48. {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_savilerow_info_recording.py +0 -0
  49. {solver_support-0.3.2 → solver_support-0.4.1}/tests/test_solution_naming.py +0 -0
  50. {solver_support-0.3.2 → solver_support-0.4.1}/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.1
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
+ (Conjure, Savile Row, Fast Downward, SymK and ENHSP) 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
+ (Conjure, Savile Row, Fast Downward, SymK and ENHSP) 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
@@ -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
@@ -238,6 +258,73 @@ N+1. Only the registry knows which models decide the problem itself
238
258
  - **Timeout**: the final, cut-off layer is bounded by `end_hint = duration -
239
259
  translate_time` (the search-relative kill time), so the blown-up deepest bound
240
260
  does not collapse to ~0.
261
+ - **Its own limit, and the backstop above it**: `--search-time-limit T` is a CPU
262
+ rlimit on the search alone, while the runner's watchdog counts wall clock over
263
+ translation plus search. A watchdog at `T` therefore always fires first, and
264
+ the run loses the exit 23 and the final statistics block it would have
265
+ written. Size the watchdog with `fd_backstop(T, translate_allowance)`, which
266
+ is `T`, plus translation, plus `FD_EXIT_GRACE`, and pass it as
267
+ `FastDownwardSolver.solve(..., watchdog=...)`. The runner's own timeout stays
268
+ the in-band limit. Then an external kill once again means the planner ignored
269
+ its limit. As with Savile Row, this is a
270
+ backstop and not a budget: to hold a run to a wall-clock `T`, shrink the search
271
+ limit.
272
+
273
+ #### Verdicts: proof, limit, or rejection
274
+
275
+ Fast Downward answers through its **exit code** (`driver/returncodes.py`), so
276
+ `run_phase` is handed `FD_ANSWER_RETURNCODES` and a proof is not logged as a
277
+ failed command. `SolverStatsExtractor.fd_verdict` reads the code, plus the log
278
+ where the code is ambiguous, into the same vocabulary as the Savile Row verdict.
279
+ `FastDownwardSolver` records it on the `solving` phase:
280
+
281
+ | exit | verdict | `verdict_reason` |
282
+ |---|---|---|
283
+ | 0–3 (plan; 1–3 then hit a limit) | `sat` | |
284
+ | 10, 11 | `unsat` | (the planner's own claim) |
285
+ | 12, see below | `unsat` / `unknown` | `exhausted`, or `search-limit` / `bounded` / `portfolio` / `incomplete` |
286
+ | 13 (26.6+) | `unknown` | `bounded` |
287
+ | 20 / 21 | `unknown` | `translate-memory` / `translate-limit` |
288
+ | 22, 24 / 23 | `unknown` | `memory` / `search-limit` |
289
+ | 34, 37 | `error` | `unsupported` (`unknown`/`portfolio` for an anytime portfolio) |
290
+ | 31, 33, 36 | `error` | `input-error` |
291
+ | 30, 32, 35, a signal | `error` | `crash` |
292
+ | killed by the runner | `killed` | |
293
+
294
+ **Exit 12 is ambiguous by construction.** Before 26.6 the planner exits
295
+ `found_solution() ? 0 : 12` however the search ended, so the log decides:
296
+
297
+ - `Time limit reached. Abort search.` (the search's `max_time=`) gives
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
302
+ `Generating unsolvable task` lands here too, since the search then exhausts a
303
+ trivially false goal.
304
+ - SymK prints no exhaustion line. Its last `BOUND: L < U` line, with `L ≥ U`, is
305
+ `exhausted` when `U` is infinity (`2147483647`) and `bounded` otherwise.
306
+ - A portfolio's 12 is always `portfolio`. It can mask its components'
307
+ time-outs, and its exhaustion line may come from a component that pruned
308
+ unsafely.
309
+ - Anything else is `incomplete`: EHC giving up, or a 26.6 search that the
310
+ planner itself judged incomplete (`Search terminated without finding a
311
+ plan!`).
312
+
313
+ **`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.
322
+
323
+ **The planner's own time limit is a timeout.** `search-limit` and
324
+ `translate-limit` make `solve()` raise `TimeoutError`, exactly as a watchdog
325
+ kill does, and so does an anytime portfolio that ends without a plan. Every other
326
+ no-plan ending returns `None`, and the run's `outcome` is derived from the
327
+ verdict.
241
328
 
242
329
  ### UPF (via the consumer's UPF CLI; PDDL or constructed models)
243
330
 
@@ -319,16 +406,41 @@ run is ever concluded unsat, which understates rather than overstates what was
319
406
  proved. A status that stopped early (`timeout`, `hlim`, `stopped`) is never
320
407
  `unsat` however complete the model.
321
408
 
409
+ For a planner, being able to prove depends on the **search** as well as the model.
410
+ `get_proves_unsat(model, tune=..., solver_name=...)` is the model's
411
+ `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
414
+ a model that cannot prove. `finalize` keeps the value on the summary as
415
+ `proves_unsat`, and a later `finalize` that passes None reuses it. So a
416
+ re-finalize that folds in a late phase, as `horizon_utils` does, cannot drop it.
417
+
418
+ **A `solving` phase's verdict decides a completed run.** `failed` is the status
419
+ of both a planner's proof and its crash, so `proves_unsat` alone would promote
420
+ 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:
422
+
423
+ | verdict | `proves_unsat` | outcome |
424
+ |---|---|---|
425
+ | `unsat` | True | `unsat` |
426
+ | `unsat` | False | `unknown`: the search ran out, but cannot prove |
427
+ | `unknown` / `killed` | either | `unknown` |
428
+ | `error` | either | `error` |
429
+
430
+ A run with no verdict is read as before. Per-horizon verdicts
431
+ (`solving-horizon-N`) are about one bound each and never decide the run.
432
+
322
433
  ## what the core owns
323
434
 
324
435
  | concern | location |
325
436
  |---------|----------|
326
437
  | per-solver stat parsers, cost-layer assembly, dispatch | `solver_stats.py` |
327
438
  | per-run Savile Row verdict (refuted vs gave up) | `solver_stats.py` (`savilerow_verdict`) |
439
+ | per-run Fast Downward / SymK verdict (exit code + log) | `solver_stats.py` (`fd_verdict`) |
328
440
  | Savile Row `.info` split recording (per horizon) | `solver_horizon.py` (`_record_savilerow_times`) |
329
441
  | stats attached to a phase after each solve | `solver_runner.py`, `solver_horizon.py` |
330
442
  | 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`) |
443
+ | relating the in-band limits to the external kill | `command_builders.py` (`savilerow_backstop`, `fd_backstop`) |
332
444
 
333
445
  Turning these per-phase stats into a unified per-horizon curve for display, a
334
446
  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.1"
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
 
@@ -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: