solver-support 0.2.2__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {solver_support-0.2.2/solver_support.egg-info → solver_support-0.3.2}/PKG-INFO +26 -18
  2. {solver_support-0.2.2 → solver_support-0.3.2}/README.md +25 -17
  3. {solver_support-0.2.2 → solver_support-0.3.2}/docs/solver-runner.md +3 -3
  4. {solver_support-0.2.2 → solver_support-0.3.2}/docs/timing-model.md +111 -1
  5. {solver_support-0.2.2 → solver_support-0.3.2}/pyproject.toml +10 -7
  6. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support/__init__.py +25 -1
  7. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support/command_builders.py +66 -3
  8. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support/run_summary.py +98 -30
  9. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support/solver_horizon.py +21 -6
  10. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support/solver_stats.py +125 -0
  11. {solver_support-0.2.2 → solver_support-0.3.2/solver_support.egg-info}/PKG-INFO +26 -18
  12. {solver_support-0.2.2 → solver_support-0.3.2}/tests/test_command_builders.py +56 -0
  13. {solver_support-0.2.2 → solver_support-0.3.2}/tests/test_import.py +20 -0
  14. {solver_support-0.2.2 → solver_support-0.3.2}/tests/test_run_summary.py +5 -4
  15. {solver_support-0.2.2 → solver_support-0.3.2}/tests/test_run_summary_success.py +58 -6
  16. {solver_support-0.2.2 → solver_support-0.3.2}/tests/test_savilerow_info_recording.py +47 -1
  17. {solver_support-0.2.2 → solver_support-0.3.2}/tests/test_solver_runner.py +3 -2
  18. {solver_support-0.2.2 → solver_support-0.3.2}/tests/test_solver_stats.py +154 -0
  19. {solver_support-0.2.2 → solver_support-0.3.2}/LICENSE +0 -0
  20. {solver_support-0.2.2 → solver_support-0.3.2}/MANIFEST.in +0 -0
  21. {solver_support-0.2.2 → solver_support-0.3.2}/docs/injection-api.md +0 -0
  22. {solver_support-0.2.2 → solver_support-0.3.2}/setup.cfg +0 -0
  23. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support/file_management.py +0 -0
  24. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support/global_state.py +0 -0
  25. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support/horizon_utils.py +0 -0
  26. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support/model_registry.py +0 -0
  27. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support/objective.py +0 -0
  28. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support/solver_base.py +0 -0
  29. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support/solver_enhsp.py +0 -0
  30. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support/solver_fd.py +0 -0
  31. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support/solver_runner.py +0 -0
  32. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support/solver_upfplan.py +0 -0
  33. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support/utilities.py +0 -0
  34. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support.egg-info/SOURCES.txt +0 -0
  35. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support.egg-info/dependency_links.txt +0 -0
  36. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support.egg-info/not-zip-safe +0 -0
  37. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support.egg-info/requires.txt +0 -0
  38. {solver_support-0.2.2 → solver_support-0.3.2}/solver_support.egg-info/top_level.txt +0 -0
  39. {solver_support-0.2.2 → solver_support-0.3.2}/tests/conftest.py +0 -0
  40. {solver_support-0.2.2 → solver_support-0.3.2}/tests/fixtures/models.yaml +0 -0
  41. {solver_support-0.2.2 → solver_support-0.3.2}/tests/test_helpers.py +0 -0
  42. {solver_support-0.2.2 → solver_support-0.3.2}/tests/test_horizon_utils.py +0 -0
  43. {solver_support-0.2.2 → solver_support-0.3.2}/tests/test_registry_dump.py +0 -0
  44. {solver_support-0.2.2 → solver_support-0.3.2}/tests/test_solution_naming.py +0 -0
  45. {solver_support-0.2.2 → solver_support-0.3.2}/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.2
4
4
  Summary: Generic Conjure / Savile Row / Fast Downward / SymK / ENHSP / UPF solver pipeline
5
5
  Author: András Salamon
6
6
  License-Expression: BSD-3-Clause
@@ -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,85 @@ 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`, `tailoring-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
+ **Each limit names the process it bound.** `solver-limit` / `tailoring-limit`
206
+ are the two time limits (see above), and `memory` / `tailoring-memory` are the
207
+ same split for the heap: `SolverMemOut` is the backend, `SavileRowOutOfMemory`
208
+ the JVM running Savile Row. The front-end memory-out is `unknown` and not
209
+ `error` for the same reason its timeout is — a resource cap stopped the run
210
+ before it decided; nothing malfunctioned. It is checked **before** the verdict,
211
+ and before `SavileRowUncaughtException`: one heap exhaustion sets both flags,
212
+ because the `OutOfMemoryError` reaches Savile Row's top-level handler, and the
213
+ cause is a more useful report than the symptom. `error` is then what is left
214
+ when Savile Row has no account of what went wrong.
215
+
216
+ You will mostly see this flag if your limits are **in band**. A harness that
217
+ caps memory externally (`runsolver` and friends) kills the JVM before it reaches
218
+ its own `-Xmx`, so Savile Row never writes the flag and you get a killed process
219
+ with no statistics instead — the same trade the time limits make. Bounding a run
220
+ from the inside buys diagnostics that killing it from outside destroys.
221
+
222
+ **A horizon's `unsat` is about the bound, not the problem.** It means "not at
223
+ this horizon", never "not at all" — refuting horizons 1..N says nothing about
224
+ N+1. Only the registry knows which models decide the problem itself
225
+ (`get_proves_unsat`), which is why the run-level `outcome` is derived in
226
+ `RunSummary.finalize` and never aggregated from these.
227
+
149
228
  ### Fast Downward / SymK (PDDL models)
150
229
 
151
230
  - **Shape**: not a horizon scan — one `solving` phase whose A\* (blind) / symbolic
@@ -210,15 +289,46 @@ solving horizon on those backends instead of improving — wire
210
289
  - **Split is Savile-Row-only and best-effort.** `savilerow_time` / `solver_time`
211
290
  are `None` for planners and for any Savile Row horizon whose `.info` was
212
291
  missing.
292
+ - **In-band limits add; the external kill must sit above their sum.** See "the
293
+ three time limits" above. `savilerow_backstop` derives it from the same
294
+ numbers the builder gets.
295
+ - **A clean exit is not an answer.** A solver given its own limit stops *itself*
296
+ and exits 0, so every phase of a fruitless run can succeed. Run success is
297
+ decided by `outcome`, never by exit codes.
298
+
299
+ ## outcomes
300
+
301
+ `final_status` answers two questions at once — what was learned, and why the run
302
+ stopped — so `RunSummary.finalize` records them separately. `final_status` keeps
303
+ its meaning; these sit alongside it.
304
+
305
+ | field | values |
306
+ |---|---|
307
+ | `outcome` | `solved` / `unsat` / `unknown` / `error` |
308
+ | `stop_reason` | `complete` / `time` / `horizons` / `interrupt` / `error` |
309
+
310
+ `success` is `outcome in {solved, unsat}`: **a run succeeded if it produced a
311
+ definitive answer in the budget it was given.** A proof that no solution exists
312
+ answers the question as surely as a solution does, even though the planner that
313
+ produced it exits non-zero.
314
+
315
+ `unsat` requires the caller to pass `finalize(..., proves_unsat=True)`, normally
316
+ from the registry's `get_proves_unsat`. The core cannot tell from the output — a
317
+ bounded CP model and a complete planner print the same thing — and left False no
318
+ run is ever concluded unsat, which understates rather than overstates what was
319
+ proved. A status that stopped early (`timeout`, `hlim`, `stopped`) is never
320
+ `unsat` however complete the model.
213
321
 
214
322
  ## what the core owns
215
323
 
216
324
  | concern | location |
217
325
  |---------|----------|
218
326
  | per-solver stat parsers, cost-layer assembly, dispatch | `solver_stats.py` |
327
+ | per-run Savile Row verdict (refuted vs gave up) | `solver_stats.py` (`savilerow_verdict`) |
219
328
  | Savile Row `.info` split recording (per horizon) | `solver_horizon.py` (`_record_savilerow_times`) |
220
329
  | stats attached to a phase after each solve | `solver_runner.py`, `solver_horizon.py` |
221
- | the phase model (`RunSummary` / `PhaseResult`) | `run_summary.py` |
330
+ | 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`) |
222
332
 
223
333
  Turning these per-phase stats into a unified per-horizon curve for display, a
224
334
  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.2"
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