solver-support 0.2.2__tar.gz → 0.3.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 (45) hide show
  1. {solver_support-0.2.2/solver_support.egg-info → solver_support-0.3.1}/PKG-INFO +26 -18
  2. {solver_support-0.2.2 → solver_support-0.3.1}/README.md +25 -17
  3. {solver_support-0.2.2 → solver_support-0.3.1}/docs/solver-runner.md +3 -3
  4. {solver_support-0.2.2 → solver_support-0.3.1}/docs/timing-model.md +94 -1
  5. {solver_support-0.2.2 → solver_support-0.3.1}/pyproject.toml +10 -7
  6. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/__init__.py +25 -1
  7. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/command_builders.py +66 -3
  8. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/run_summary.py +98 -30
  9. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/solver_horizon.py +21 -6
  10. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/solver_stats.py +104 -0
  11. {solver_support-0.2.2 → solver_support-0.3.1/solver_support.egg-info}/PKG-INFO +26 -18
  12. {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_command_builders.py +56 -0
  13. {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_import.py +20 -0
  14. {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_run_summary.py +5 -4
  15. {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_run_summary_success.py +58 -6
  16. {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_savilerow_info_recording.py +47 -1
  17. {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_solver_runner.py +3 -2
  18. {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_solver_stats.py +113 -0
  19. {solver_support-0.2.2 → solver_support-0.3.1}/LICENSE +0 -0
  20. {solver_support-0.2.2 → solver_support-0.3.1}/MANIFEST.in +0 -0
  21. {solver_support-0.2.2 → solver_support-0.3.1}/docs/injection-api.md +0 -0
  22. {solver_support-0.2.2 → solver_support-0.3.1}/setup.cfg +0 -0
  23. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/file_management.py +0 -0
  24. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/global_state.py +0 -0
  25. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/horizon_utils.py +0 -0
  26. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/model_registry.py +0 -0
  27. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/objective.py +0 -0
  28. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/solver_base.py +0 -0
  29. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/solver_enhsp.py +0 -0
  30. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/solver_fd.py +0 -0
  31. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/solver_runner.py +0 -0
  32. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/solver_upfplan.py +0 -0
  33. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support/utilities.py +0 -0
  34. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support.egg-info/SOURCES.txt +0 -0
  35. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support.egg-info/dependency_links.txt +0 -0
  36. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support.egg-info/not-zip-safe +0 -0
  37. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support.egg-info/requires.txt +0 -0
  38. {solver_support-0.2.2 → solver_support-0.3.1}/solver_support.egg-info/top_level.txt +0 -0
  39. {solver_support-0.2.2 → solver_support-0.3.1}/tests/conftest.py +0 -0
  40. {solver_support-0.2.2 → solver_support-0.3.1}/tests/fixtures/models.yaml +0 -0
  41. {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_helpers.py +0 -0
  42. {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_horizon_utils.py +0 -0
  43. {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_registry_dump.py +0 -0
  44. {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_solution_naming.py +0 -0
  45. {solver_support-0.2.2 → solver_support-0.3.1}/tests/test_solvers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solver-support
3
- Version: 0.2.2
3
+ Version: 0.3.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
@@ -29,12 +29,13 @@ Dynamic: license-file
29
29
  # solver-support
30
30
 
31
31
  A generic, domain-agnostic pipeline for running constraint-modelling and
32
- planning solvers: **Conjure** / **Savile Row** (+ SAT/CP backends), **Fast
33
- Downward**, **SymK**, **ENHSP**, and **UPF**-constructed models. It handles the
34
- mechanics that every such run needs — building solver command lines from a model
35
- registry, spawning subprocesses with timeouts and clean teardown, naming the
36
- per-run files, parsing solver output into a uniform timing / cost-layer model,
37
- and driving the horizon (increasing-difficulty) loop.
32
+ planning solvers: **Conjure** / **Savile Row** (driving a SAT solver, **Minion**,
33
+ **OR-Tools CP-SAT**, **Gecode** or **Chuffed**), **Fast Downward**, **SymK**,
34
+ **ENHSP**, and **UPF**-constructed models. It handles the mechanics that every
35
+ such run needs — building solver command lines from a model registry, spawning
36
+ subprocesses with timeouts and clean teardown, naming the per-run files, parsing
37
+ solver output into a uniform timing / cost-layer model, and driving the horizon
38
+ (increasing-difficulty) loop.
38
39
 
39
40
  It carries **no** notion of any particular problem domain or objective. A
40
41
  consumer supplies:
@@ -47,14 +48,17 @@ consumer supplies:
47
48
 
48
49
  ## Status
49
50
 
50
- **Alpha.** The pipeline is in day-to-day use by two consumers and is covered by
51
- its own test suite, but this is the first public release and the API may still
52
- shift between 0.x versions.
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
53
+ release's changes are listed on the
54
+ [releases page](https://github.com/ott2/solver-support/releases).
53
55
 
54
- The package ships **unwired**: a bare `import solver_support` gives a pipeline
55
- that resolves no model registry and interprets no objective, because it has no
56
- way to guess either. A consumer registers its scoring, registry paths, warning
57
- logger and UPF CLI once, before first use.
56
+ The package ships **unwired**, by design and on every release: a bare
57
+ `import solver_support` gives a pipeline that resolves no model registry and
58
+ interprets no objective, because it has no way to guess either. Constructing a
59
+ `ModelRegistry` *raises* rather than falling back on a guess. A consumer
60
+ registers its scoring, registry paths, warning logger and UPF CLI once, before
61
+ first use — see [Usage](#usage).
58
62
 
59
63
  ## Installation
60
64
 
@@ -63,7 +67,8 @@ pip install solver-support
63
67
  ```
64
68
 
65
69
  Requires Python 3.9 or newer (3.9.6 is the system Python on macOS, and is
66
- supported deliberately). The only runtime dependencies are `psutil` and `PyYAML`.
70
+ supported deliberately — each release is built and tested against it). The only
71
+ runtime dependencies are `psutil` and `PyYAML`.
67
72
 
68
73
  ## Usage
69
74
 
@@ -83,15 +88,18 @@ extraction, warning logger, UPF CLI — with a worked example.
83
88
 
84
89
  ## Development
85
90
 
86
- Clone and install editable. A consumer under development alongside it is
87
- normally installed into the same virtualenv, so both are importable and edits to
88
- either take effect immediately:
91
+ To work on this package, clone and install it editable:
89
92
 
90
93
  ```bash
91
94
  git clone https://github.com/ott2/solver-support
92
95
  pip install -e solver-support
93
96
  ```
94
97
 
98
+ Consumers pin a **released** version from PyPI rather than the working tree, so a
99
+ change here reaches them only when it is released. To try an unreleased change
100
+ against a consumer, install this editable into that consumer's environment
101
+ deliberately — it is not the default arrangement.
102
+
95
103
  ## Tests
96
104
 
97
105
  `pytest tests/` — the core's own suite, and the home for solver-level
@@ -1,12 +1,13 @@
1
1
  # solver-support
2
2
 
3
3
  A generic, domain-agnostic pipeline for running constraint-modelling and
4
- planning solvers: **Conjure** / **Savile Row** (+ SAT/CP backends), **Fast
5
- Downward**, **SymK**, **ENHSP**, and **UPF**-constructed models. It handles the
6
- mechanics that every such run needs — building solver command lines from a model
7
- registry, spawning subprocesses with timeouts and clean teardown, naming the
8
- per-run files, parsing solver output into a uniform timing / cost-layer model,
9
- and driving the horizon (increasing-difficulty) loop.
4
+ planning solvers: **Conjure** / **Savile Row** (driving a SAT solver, **Minion**,
5
+ **OR-Tools CP-SAT**, **Gecode** or **Chuffed**), **Fast Downward**, **SymK**,
6
+ **ENHSP**, and **UPF**-constructed models. It handles the mechanics that every
7
+ such run needs — building solver command lines from a model registry, spawning
8
+ subprocesses with timeouts and clean teardown, naming the per-run files, parsing
9
+ solver output into a uniform timing / cost-layer model, and driving the horizon
10
+ (increasing-difficulty) loop.
10
11
 
11
12
  It carries **no** notion of any particular problem domain or objective. A
12
13
  consumer supplies:
@@ -19,14 +20,17 @@ consumer supplies:
19
20
 
20
21
  ## Status
21
22
 
22
- **Alpha.** The pipeline is in day-to-day use by two consumers and is covered by
23
- its own test suite, but this is the first public release and the API may still
24
- shift between 0.x versions.
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
25
+ release's changes are listed on the
26
+ [releases page](https://github.com/ott2/solver-support/releases).
25
27
 
26
- The package ships **unwired**: a bare `import solver_support` gives a pipeline
27
- that resolves no model registry and interprets no objective, because it has no
28
- way to guess either. A consumer registers its scoring, registry paths, warning
29
- logger and UPF CLI once, before first use.
28
+ The package ships **unwired**, by design and on every release: a bare
29
+ `import solver_support` gives a pipeline that resolves no model registry and
30
+ interprets no objective, because it has no way to guess either. Constructing a
31
+ `ModelRegistry` *raises* rather than falling back on a guess. A consumer
32
+ registers its scoring, registry paths, warning logger and UPF CLI once, before
33
+ first use — see [Usage](#usage).
30
34
 
31
35
  ## Installation
32
36
 
@@ -35,7 +39,8 @@ pip install solver-support
35
39
  ```
36
40
 
37
41
  Requires Python 3.9 or newer (3.9.6 is the system Python on macOS, and is
38
- supported deliberately). The only runtime dependencies are `psutil` and `PyYAML`.
42
+ supported deliberately — each release is built and tested against it). The only
43
+ runtime dependencies are `psutil` and `PyYAML`.
39
44
 
40
45
  ## Usage
41
46
 
@@ -55,15 +60,18 @@ extraction, warning logger, UPF CLI — with a worked example.
55
60
 
56
61
  ## Development
57
62
 
58
- Clone and install editable. A consumer under development alongside it is
59
- normally installed into the same virtualenv, so both are importable and edits to
60
- either take effect immediately:
63
+ To work on this package, clone and install it editable:
61
64
 
62
65
  ```bash
63
66
  git clone https://github.com/ott2/solver-support
64
67
  pip install -e solver-support
65
68
  ```
66
69
 
70
+ Consumers pin a **released** version from PyPI rather than the working tree, so a
71
+ change here reaches them only when it is released. To try an unreleased change
72
+ against a consumer, install this editable into that consumer's environment
73
+ deliberately — it is not the default arrangement.
74
+
67
75
  ## Tests
68
76
 
69
77
  `pytest tests/` — the core's own suite, and the home for solver-level
@@ -37,13 +37,13 @@ SolverResult(command, returncode, stdout, stderr, duration,
37
37
 
38
38
  Three easily-confused numbers:
39
39
 
40
- - `default_timeout` (a `SolverRunner` parameter) — the *limit*;
40
+ - `timeout` (a `SolverRunner` parameter) — the *limit*;
41
41
  - `duration` (a `SolverResult` field) — the *actual* time it ran, whether it
42
42
  finished or was killed;
43
43
  - `timed_out` (a `SolverResult` field) — whether that limit was hit.
44
44
 
45
45
  ```python
46
- runner = SolverRunner(default_timeout=10.0)
46
+ runner = SolverRunner(timeout=10.0)
47
47
 
48
48
  runner.run(['sleep', '3']) # duration ~3.0, timed_out False, returncode 0
49
49
  runner.run(['sleep', '15']) # duration ~10.0, timed_out True, returncode -1
@@ -87,7 +87,7 @@ SolverResult(ls /nonexistent) -> FAILED (rc=1, t=0.01s)
87
87
  ```python
88
88
  from solver_support import SolverRunner
89
89
 
90
- runner = SolverRunner(default_timeout=30.0)
90
+ runner = SolverRunner(timeout=30.0)
91
91
  result = runner.run(['savilerow', 'model.eprime', 'problem.param'])
92
92
 
93
93
  if result.timed_out:
@@ -146,6 +146,68 @@ wires only the objective-file route will find continue-scan stops at the first
146
146
  solving horizon on those backends instead of improving — wire
147
147
  `set_raw_objective_from_solution` to use them for optimisation models.
148
148
 
149
+ #### The three time limits, and how they relate
150
+
151
+ A Savile Row run is bounded three times over, and the three are **not**
152
+ interchangeable. Getting the relationship wrong is silent, so it is worth
153
+ stating once:
154
+
155
+ | limit | set by | what it actually bounds |
156
+ |---|---|---|
157
+ | `-timelimit T` | `build_savilerow_command(timeout=T)` | tailoring. Nominally translation *plus* solving, but Savile Row stops binding it once a solver is running |
158
+ | the solver's own (`--time=S`, …) | `build_savilerow_command(solver_timeout=S)` | search |
159
+ | the external kill | `SolverRunner(timeout=…)` | the whole process tree |
160
+
161
+ **The first two are consecutive, so they add.** Savile Row tailors, and only
162
+ then launches the solver, so the wall clock the pair permits is `T + S` — not
163
+ `max(T, S)`. Left unsplit (`S` defaulting to `T`) that is `2T`, which is why an
164
+ external kill set at `T` can fire while the toolchain is still inside the limits
165
+ it was given.
166
+
167
+ **An early kill costs every statistic, not just the tail.** Savile Row
168
+ accumulates `SATVars`, `SATClauses`, `SolverNodes` and the
169
+ `SavileRowTotalTime` / `SolverTotalTime` split in memory and writes the `.info`
170
+ once, after the solver returns. Kill the tree and the output directory holds an
171
+ `.eprime` and perhaps an `.eprime-minion`, never an `.eprime-info`, so
172
+ `extract_savilerow_info` has nothing to read and the run cannot say even whether
173
+ it reached the solver.
174
+
175
+ So size the external kill with `savilerow_backstop(T, S)` — `T + S` plus grace
176
+ for the flush — and it comes to mean "the toolchain ignored its limits", a
177
+ genuine fault rather than an ordinary and lossy way for a run to end.
178
+
179
+ **To hold a run to a wall-clock budget, shrink the pair, not the backstop.**
180
+ Give the solver `S = T - (what tailoring will cost)`. The core picks no reserve
181
+ policy: a horizon scan can estimate from the previous horizon's
182
+ `SavileRowTotalTime`, a single-shot run has nothing to estimate from, and only
183
+ the caller knows which it is.
184
+
185
+ #### Verdicts: refuted vs gave up
186
+
187
+ `No solution found.` is what Savile Row prints **both** when the solver proved
188
+ there is none and when it stopped at its own limit without deciding, and
189
+ `solution_found` is False for both. The first is a result; the second
190
+ establishes nothing. `SolverStatsExtractor.savilerow_verdict` separates them and
191
+ `_record_savilerow_times` records the answer on the phase as `verdict`:
192
+
193
+ | verdict | evidence | meaning |
194
+ |---|---|---|
195
+ | `sat` / `unsat` | `SolverSatisfiable` present | decided (`proven_optimal` too, on optimisation models) |
196
+ | `unknown` | absent, with `verdict_reason` | `solver-limit`, `tailoring-limit`, `memory`, `encoding-limit`, or `unexplained` |
197
+ | `error` | `SavileRowUncaughtException` | Savile Row itself failed |
198
+ | `killed` | no `.info` at all | recorded by the caller, from the phase's `timed_out` / `launch_failed` |
199
+
200
+ Decidedness keys on `SolverSatisfiable` being **present**, deliberately not on
201
+ the solver having run: Savile Row can settle an instance during tailoring and
202
+ never invoke a solver, leaving `SolverTotalTime: 0` beside a perfectly good
203
+ verdict. Testing "did the solver run?" reports every one of those as undecided.
204
+
205
+ **A horizon's `unsat` is about the bound, not the problem.** It means "not at
206
+ this horizon", never "not at all" — refuting horizons 1..N says nothing about
207
+ N+1. Only the registry knows which models decide the problem itself
208
+ (`get_proves_unsat`), which is why the run-level `outcome` is derived in
209
+ `RunSummary.finalize` and never aggregated from these.
210
+
149
211
  ### Fast Downward / SymK (PDDL models)
150
212
 
151
213
  - **Shape**: not a horizon scan — one `solving` phase whose A\* (blind) / symbolic
@@ -210,15 +272,46 @@ solving horizon on those backends instead of improving — wire
210
272
  - **Split is Savile-Row-only and best-effort.** `savilerow_time` / `solver_time`
211
273
  are `None` for planners and for any Savile Row horizon whose `.info` was
212
274
  missing.
275
+ - **In-band limits add; the external kill must sit above their sum.** See "the
276
+ three time limits" above. `savilerow_backstop` derives it from the same
277
+ numbers the builder gets.
278
+ - **A clean exit is not an answer.** A solver given its own limit stops *itself*
279
+ and exits 0, so every phase of a fruitless run can succeed. Run success is
280
+ decided by `outcome`, never by exit codes.
281
+
282
+ ## outcomes
283
+
284
+ `final_status` answers two questions at once — what was learned, and why the run
285
+ stopped — so `RunSummary.finalize` records them separately. `final_status` keeps
286
+ its meaning; these sit alongside it.
287
+
288
+ | field | values |
289
+ |---|---|
290
+ | `outcome` | `solved` / `unsat` / `unknown` / `error` |
291
+ | `stop_reason` | `complete` / `time` / `horizons` / `interrupt` / `error` |
292
+
293
+ `success` is `outcome in {solved, unsat}`: **a run succeeded if it produced a
294
+ definitive answer in the budget it was given.** A proof that no solution exists
295
+ answers the question as surely as a solution does, even though the planner that
296
+ produced it exits non-zero.
297
+
298
+ `unsat` requires the caller to pass `finalize(..., proves_unsat=True)`, normally
299
+ from the registry's `get_proves_unsat`. The core cannot tell from the output — a
300
+ bounded CP model and a complete planner print the same thing — and left False no
301
+ run is ever concluded unsat, which understates rather than overstates what was
302
+ proved. A status that stopped early (`timeout`, `hlim`, `stopped`) is never
303
+ `unsat` however complete the model.
213
304
 
214
305
  ## what the core owns
215
306
 
216
307
  | concern | location |
217
308
  |---------|----------|
218
309
  | per-solver stat parsers, cost-layer assembly, dispatch | `solver_stats.py` |
310
+ | per-run Savile Row verdict (refuted vs gave up) | `solver_stats.py` (`savilerow_verdict`) |
219
311
  | Savile Row `.info` split recording (per horizon) | `solver_horizon.py` (`_record_savilerow_times`) |
220
312
  | stats attached to a phase after each solve | `solver_runner.py`, `solver_horizon.py` |
221
- | the phase model (`RunSummary` / `PhaseResult`) | `run_summary.py` |
313
+ | the phase model (`RunSummary` / `PhaseResult`), outcome and success | `run_summary.py` |
314
+ | relating the in-band limits to the external kill | `command_builders.py` (`savilerow_backstop`) |
222
315
 
223
316
  Turning these per-phase stats into a unified per-horizon curve for display, a
224
317
  single "preprocess" baseline, and plots is the **consumer's** job — it reads the
@@ -11,10 +11,16 @@ build-backend = "setuptools.build_meta"
11
11
 
12
12
  [project]
13
13
  name = "solver-support"
14
- # Single-sourced from solver_support.__version__, so a release bump touches one
15
- # line and the installed metadata can never disagree with what the package
16
- # reports at runtime.
17
- dynamic = ["version"]
14
+ # The single source of the version. `solver_support.__version__` reads it back
15
+ # from the installed distribution metadata, so there is no literal in the
16
+ # package to drift from this one.
17
+ #
18
+ # Static rather than `dynamic = ["version"]` with a `{attr = ...}` pointing into
19
+ # the package: PEP 621 treats `version` as a static field, and `dynamic` is the
20
+ # escape hatch for a version that genuinely cannot be known without building.
21
+ # Ours can. Carrying a `.devN` suffix between releases keeps the working tree
22
+ # from claiming a version that is already tagged and published.
23
+ version = "0.3.1"
18
24
  description = "Generic Conjure / Savile Row / Fast Downward / SymK / ENHSP / UPF solver pipeline"
19
25
  readme = "README.md"
20
26
  requires-python = ">=3.9"
@@ -72,9 +78,6 @@ dev = [
72
78
  [tool.setuptools]
73
79
  zip-safe = false
74
80
 
75
- [tool.setuptools.dynamic]
76
- version = {attr = "solver_support.__version__"}
77
-
78
81
  [tool.setuptools.packages.find]
79
82
  include = ["solver_support*"]
80
83
 
@@ -13,7 +13,28 @@ unconfigured pipeline. A consumer registers its scoring (see :mod:`objective`)
13
13
  and its packaging paths (see :mod:`model_registry`) once at its own import time.
14
14
  """
15
15
 
16
- __version__ = "0.2.2"
16
+ # Read back from the installed distribution metadata; the version itself is
17
+ # declared once, in pyproject.toml. There is deliberately no literal here to
18
+ # drift from it - the failure being avoided is a release where the metadata says
19
+ # one thing and `__version__` another, each individually plausible and nothing
20
+ # catching the disagreement.
21
+ #
22
+ # Resolved eagerly rather than behind a PEP 562 `__getattr__`: the lookup scans
23
+ # sys.path for a matching .dist-info, which costs a fraction of a millisecond,
24
+ # and this module already imports psutil, PyYAML and every solver backend below.
25
+ # Deferring it would save nothing measurable against that.
26
+ #
27
+ # The fallback fires only in a source tree that was never installed. Note an
28
+ # *editable* install pins its .dist-info at install time, so a checkout whose
29
+ # version has moved on still reports the older one here until it is reinstalled
30
+ # - the price of single-sourcing from metadata rather than from a literal.
31
+ try:
32
+ from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
33
+ from importlib.metadata import version as _version
34
+
35
+ __version__ = _version("solver-support")
36
+ except _PackageNotFoundError: # a source tree, never installed
37
+ __version__ = "0+unknown"
17
38
 
18
39
  # --- model registry (loader + command builders + packaging injection) ---
19
40
  from .model_registry import (
@@ -25,6 +46,7 @@ from .model_registry import (
25
46
  set_resource_root,
26
47
  set_user_config_finder,
27
48
  )
49
+ from .command_builders import savilerow_backstop, SAVILEROW_FLUSH_GRACE
28
50
 
29
51
  # --- run configuration singleton ---
30
52
  from .global_state import global_state, GlobalState
@@ -85,6 +107,8 @@ __all__ = [
85
107
  "set_default_config_path",
86
108
  "set_resource_root",
87
109
  "set_user_config_finder",
110
+ "savilerow_backstop",
111
+ "SAVILEROW_FLUSH_GRACE",
88
112
  # run config
89
113
  "global_state",
90
114
  "GlobalState",
@@ -21,6 +21,41 @@ from typing import List, Optional
21
21
  from .model_registry import ModelRegistryError
22
22
 
23
23
 
24
+ #: Seconds of slack between the in-band limits and the external kill. Savile Row
25
+ #: needs a moment *after* its own limit fires to write the ``.info`` file (it
26
+ #: accumulates every statistic in memory and flushes once), and Conjure to
27
+ #: translate a solution back. A kill set exactly at the in-band limit races that
28
+ #: flush and generally wins, so the run that was about to report its statistics
29
+ #: reports nothing at all - the failure is worst precisely for the instances
30
+ #: where all the work was tailoring, which are the ones the limit bounded.
31
+ SAVILEROW_FLUSH_GRACE = 30
32
+
33
+
34
+ def savilerow_backstop(timeout: int, solver_timeout: Optional[int] = None,
35
+ grace: int = SAVILEROW_FLUSH_GRACE) -> int:
36
+ """The external timeout that cannot preempt Savile Row's own limits.
37
+
38
+ Savile Row's ``-timelimit`` and the backend solver's own limit are budgets
39
+ spent *one after the other* - it tailors, then runs the solver - so the wall
40
+ clock they jointly permit is their SUM, not the larger of the two. An
41
+ external kill below that sum will sometimes fire while the toolchain is
42
+ still inside the limits it was given, and because Savile Row writes its
43
+ ``.info`` only at the very end, firing early discards every statistic the
44
+ run had gathered.
45
+
46
+ Sized from the same numbers that reach :meth:`build_savilerow_command`, so
47
+ the two cannot drift apart. Passing this to the runner makes an external
48
+ kill mean "the toolchain ignored its limits", which is a genuine fault,
49
+ rather than an ordinary and silently lossy way for a run to end.
50
+
51
+ This is a *backstop*, not a wall-clock budget. To hold a run to ``T``
52
+ seconds of wall clock, do not shrink this - shrink the in-band pair so that
53
+ their sum is ``T`` (give the solver ``T`` minus what tailoring will cost)
54
+ and let the backstop sit above it, unreached.
55
+ """
56
+ return timeout + (timeout if solver_timeout is None else solver_timeout) + grace
57
+
58
+
24
59
  class CommandBuilderMixin:
25
60
  """The ``build_*_command`` methods mixed into :class:`ModelRegistry`."""
26
61
 
@@ -108,7 +143,8 @@ class CommandBuilderMixin:
108
143
  objective_file_path: Optional[str] = None,
109
144
  extra_options: Optional[List[str]] = None,
110
145
  solver_name: str = 'kissat',
111
- tune: Optional[str] = None) -> List[str]:
146
+ tune: Optional[str] = None,
147
+ solver_timeout: Optional[int] = None) -> List[str]:
112
148
  """
113
149
  Build a Savile Row command with proper flags for horizon scanning.
114
150
 
@@ -120,7 +156,28 @@ class CommandBuilderMixin:
120
156
  model_path: path to the model file
121
157
  param_path: path to the parameter file
122
158
  use_srjava: use Java savilerow instead of savilerow-native
123
- timeout: override timeout in seconds (uses default_timeout if None)
159
+ timeout: Savile Row's own ``-timelimit``, in seconds (uses
160
+ ``default_timeout`` if None). Covers tailoring *plus* solving,
161
+ but stops binding once a solver is running - so in practice it
162
+ bounds tailoring.
163
+ solver_timeout: the backend solver's own limit, if it should differ
164
+ from ``timeout``. Defaults to ``timeout``, which is what every
165
+ caller got before this existed.
166
+
167
+ These are two budgets spent one after the other, not one budget
168
+ named twice: Savile Row tailors, and only then runs the solver.
169
+ Passing the same number to both grants up to ``2 x timeout`` of
170
+ wall clock, which is why an external kill set at ``timeout``
171
+ fires mid-search and takes the statistics with it - Savile Row
172
+ accumulates them in memory and writes the ``.info`` once, after
173
+ the solver it launched returns.
174
+
175
+ The split is what lets a caller keep a real wall-clock budget:
176
+ reserve what tailoring will cost and give the solver the rest.
177
+ The core picks no reserve policy - a horizon scan can measure
178
+ the previous horizon's ``SavileRowTotalTime``, a single-shot run
179
+ cannot - so the number is the caller's. See
180
+ :func:`savilerow_backstop` for sizing the external kill.
124
181
  objective_file_path: path for intermediate objective values file (default: obj.txt)
125
182
  extra_options: additional command line options to pass to Savile Row
126
183
  solver_name: name of the solver configuration to use (default: 'kissat').
@@ -226,12 +283,18 @@ class CommandBuilderMixin:
226
283
  # The solver's own limit, in whatever flag and unit that solver
227
284
  # speaks. A config may override the dialect (or set it to null to
228
285
  # suppress it) without the core having to learn a new backend.
286
+ #
287
+ # Defaults to `timeout`, so a caller that says nothing gets exactly
288
+ # the argv it always got. Note the two limits are consecutive
289
+ # budgets - Savile Row tailors, then runs the solver - so leaving
290
+ # them equal permits roughly twice `timeout` of wall clock.
229
291
  option = solver_config.get('solver_timeout_option', spec['timeout_option'])
230
292
  if option:
231
293
  scale = spec['timeout_scale']
294
+ budget = timeout if solver_timeout is None else solver_timeout
232
295
  # Left untouched when the solver already speaks seconds, so those
233
296
  # argvs keep their exact previous form (--time=45, never 45.0).
234
- value = timeout if scale == 1 else int(timeout * scale)
297
+ value = budget if scale == 1 else int(budget * scale)
235
298
  args.extend(["-solver-options", option.format(value=value)])
236
299
  # Savile Row's own limit covers translation plus solving and is always
237
300
  # in seconds. Always added, so a backend with no limit of its own is
@@ -105,17 +105,49 @@ _NON_SOLVING_PHASES = frozenset({"translation", "modelling", "visualisation"})
105
105
  # visualiser or .sol export would silently flip a solved run to success=False.)
106
106
  _OPTIONAL_PHASES = frozenset({"visualisation", "export"})
107
107
 
108
- # final_status values the orchestrator uses to mean "this run did not produce a
109
- # solution / could not run". On these, overall success is False regardless of
110
- # whether each individual subprocess exited 0 — a solver that runs cleanly but
111
- # yields no solution (all horizons UNSAT, or a planner that returns no plan) is
112
- # not a success. `final_status` is the orchestrator's verdict; `all(phase.success)`
113
- # only knows that subprocesses didn't crash, so the verdict wins. See
114
- # [[upf-noplan-success-reporting]] and docs/server-harness.md.
115
- # 'hlim' (horizon scan exhausted its budget without deciding) is a no-solution
116
- # outcome too, so it also forces success False; it is kept as a distinct status
117
- # string so consumers can tell "ran out of horizons" from a genuine failure.
118
- _FAILURE_STATUSES = frozenset({"failed", "launch-failed", "hlim"})
108
+ # --- what a run concluded, and why it stopped ---
109
+ #
110
+ # `final_status` alone cannot answer "did this run learn anything?", because it
111
+ # conflates two independent questions. 'timeout' covers both a run that found a
112
+ # solution before the budget ran out and one that found nothing; 'failed' covers
113
+ # both a planner proving no plan exists and a crash. So the two questions are
114
+ # recorded separately:
115
+ #
116
+ # outcome what was learned: solved / unsat / unknown / error
117
+ # stop_reason why it ended: complete / time / horizons / interrupt / error
118
+ #
119
+ # `final_status` keeps its current meaning and is untouched - it is the
120
+ # orchestrator's own label, and consumers key on it.
121
+ OUTCOMES = ('solved', 'unsat', 'unknown', 'error')
122
+ STOP_REASONS = ('complete', 'time', 'horizons', 'interrupt', 'error')
123
+
124
+ # A run succeeded if it produced a DEFINITIVE ANSWER in the budget it was given
125
+ # - a solution, or a sound proof that none exists. Both answer the question the
126
+ # run was asked; 'unknown' and 'error' do not. Note this is decided on the
127
+ # outcome and not on exit codes: a planner proving unsatisfiability exits
128
+ # non-zero and its phase records success=False, yet the run succeeded.
129
+ _SUCCESS_OUTCOMES = frozenset({'solved', 'unsat'})
130
+
131
+ # final_status values where the search RAN TO COMPLETION, so "no solution" can
132
+ # be a verdict rather than an interruption. On any other status the search
133
+ # stopped early and the outcome can only be 'unknown', however complete the
134
+ # model - which is what stops 'hlim' being read as a proof. ('failed' is here
135
+ # because that is the status a planner's proven-unsat arrives under: it exits
136
+ # non-zero, having answered the question.)
137
+ _COMPLETED_STATUSES = frozenset({"done", "success", "failed"})
138
+
139
+ # final_status -> why the run stopped. Statuses absent here leave stop_reason
140
+ # empty rather than guessing: an unrecognised label is a consumer's own, and
141
+ # inventing a reason for it would put words in its mouth.
142
+ _STOP_REASONS_BY_STATUS = {
143
+ "done": "complete",
144
+ "success": "complete",
145
+ "failed": "complete",
146
+ "launch-failed": "error",
147
+ "timeout": "time",
148
+ "hlim": "horizons",
149
+ "stopped": "interrupt",
150
+ }
119
151
 
120
152
 
121
153
  @dataclass
@@ -136,6 +168,12 @@ class RunSummary:
136
168
  success: bool = False # Overall success
137
169
  solution_found: bool = False # Whether solution was found
138
170
  final_status: str = "" # Final status message
171
+ # What the run concluded and why it stopped - the two questions final_status
172
+ # conflates (see OUTCOMES / STOP_REASONS above). Derived by finalize();
173
+ # empty on a summary that was never finalized, and on one loaded from a file
174
+ # written before these existed.
175
+ outcome: str = "" # solved / unsat / unknown / error
176
+ stop_reason: str = "" # complete / time / horizons / interrupt / error
139
177
  score: Optional[int] = None # Total score the model reported (scored/upf models only)
140
178
  startat: Optional[int] = None # Starting horizon for Savile Row models (if specified)
141
179
  stopat: Optional[int] = None # Highest horizon tried, if the default cap (100) was overridden
@@ -177,8 +215,42 @@ class RunSummary:
177
215
  """
178
216
  self.success = all(p.success for p in self.required_phases)
179
217
 
180
- def finalize(self, final_status: str = "done") -> None:
181
- """Finalize the summary with end time and overall success status."""
218
+ def _derive_outcome(self, final_status: str, proves_unsat: bool,
219
+ has_timeout_solution: bool) -> str:
220
+ """What this run learned: solved / unsat / unknown / error."""
221
+ # A solution in hand wins over any status label: a run that solved the
222
+ # instance and was then interrupted, or that banked an anytime solution
223
+ # before its budget expired, still solved it.
224
+ if self.solution_found or has_timeout_solution:
225
+ return 'solved'
226
+
227
+ if final_status == "launch-failed":
228
+ return 'error'
229
+
230
+ 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'
236
+
237
+ # 'timeout', 'hlim', 'stopped', or a label this core does not know: the
238
+ # search stopped early. Nothing was decided, and no completeness of the
239
+ # model can change that -- refuting horizons 1..N says nothing about
240
+ # N+1, which is exactly why 'hlim' must never become 'unsat'.
241
+ return 'unknown'
242
+
243
+ def finalize(self, final_status: str = "done",
244
+ proves_unsat: bool = False) -> None:
245
+ """Finalize the summary: end time, outcome, stop reason, success.
246
+
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.
253
+ """
182
254
  self.end_time = datetime.now()
183
255
  self.final_status = final_status
184
256
 
@@ -191,23 +263,19 @@ class RunSummary:
191
263
  for phase in self.phases
192
264
  )
193
265
 
194
- if final_status in _FAILURE_STATUSES:
195
- # The orchestrator already judged this run a failure (no solution
196
- # produced, or the tool could not be launched). Don't let cleanly
197
- # exiting subprocesses (e.g. all-UNSAT horizons) override that — a
198
- # run that found no solution is not a success.
199
- self.success = False
200
- elif self.phases:
201
- # Overall success is decided by the REQUIRED phases only: best-effort
202
- # post-processing (visualisation/.sol export) is non-fatal and must
203
- # not fail an otherwise-solved run. A valid timeout solution also
204
- # counts (the final solving horizon itself shows as timed-out).
205
- required = self.required_phases
206
- self.success = (bool(required)
207
- and all(p.success for p in required)) or has_timeout_solution
208
- else:
209
- self.success = False
210
-
266
+ self.outcome = self._derive_outcome(
267
+ final_status, proves_unsat, has_timeout_solution)
268
+ self.stop_reason = _STOP_REASONS_BY_STATUS.get(final_status, "")
269
+ # Decided on the answer, not on exit codes. The previous rule -- "no
270
+ # declared failure status AND every required phase exited 0" -- called a
271
+ # run successful whenever its subprocesses were tidy, so a horizon scan
272
+ # that ran out of time having found nothing reported success=True: the
273
+ # solvers had each stopped themselves cleanly at the limit they were
274
+ # given. Under this rule that run is 'unknown', and a planner's
275
+ # proven-unsat (which exits non-zero) is a success.
276
+ self.success = self.outcome in _SUCCESS_OUTCOMES
277
+
278
+
211
279
  # Extract the highest score from any phase that has one
212
280
  scores = []
213
281
  for phase in self.phases: