solver-support 0.1.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 (42) hide show
  1. solver_support-0.1.1/LICENSE +29 -0
  2. solver_support-0.1.1/MANIFEST.in +18 -0
  3. solver_support-0.1.1/PKG-INFO +120 -0
  4. solver_support-0.1.1/README.md +92 -0
  5. solver_support-0.1.1/docs/injection-api.md +137 -0
  6. solver_support-0.1.1/docs/solver-runner.md +134 -0
  7. solver_support-0.1.1/docs/timing-model.md +164 -0
  8. solver_support-0.1.1/pyproject.toml +88 -0
  9. solver_support-0.1.1/setup.cfg +4 -0
  10. solver_support-0.1.1/solver_support/__init__.py +132 -0
  11. solver_support-0.1.1/solver_support/command_builders.py +334 -0
  12. solver_support-0.1.1/solver_support/file_management.py +243 -0
  13. solver_support-0.1.1/solver_support/global_state.py +62 -0
  14. solver_support-0.1.1/solver_support/horizon_utils.py +350 -0
  15. solver_support-0.1.1/solver_support/model_registry.py +792 -0
  16. solver_support-0.1.1/solver_support/objective.py +92 -0
  17. solver_support-0.1.1/solver_support/run_summary.py +338 -0
  18. solver_support-0.1.1/solver_support/solver_base.py +140 -0
  19. solver_support-0.1.1/solver_support/solver_enhsp.py +101 -0
  20. solver_support-0.1.1/solver_support/solver_fd.py +261 -0
  21. solver_support-0.1.1/solver_support/solver_horizon.py +634 -0
  22. solver_support-0.1.1/solver_support/solver_runner.py +702 -0
  23. solver_support-0.1.1/solver_support/solver_stats.py +724 -0
  24. solver_support-0.1.1/solver_support/solver_upfplan.py +168 -0
  25. solver_support-0.1.1/solver_support/utilities.py +146 -0
  26. solver_support-0.1.1/solver_support.egg-info/PKG-INFO +120 -0
  27. solver_support-0.1.1/solver_support.egg-info/SOURCES.txt +40 -0
  28. solver_support-0.1.1/solver_support.egg-info/dependency_links.txt +1 -0
  29. solver_support-0.1.1/solver_support.egg-info/not-zip-safe +1 -0
  30. solver_support-0.1.1/solver_support.egg-info/requires.txt +2 -0
  31. solver_support-0.1.1/solver_support.egg-info/top_level.txt +1 -0
  32. solver_support-0.1.1/tests/conftest.py +109 -0
  33. solver_support-0.1.1/tests/fixtures/models.yaml +57 -0
  34. solver_support-0.1.1/tests/test_helpers.py +27 -0
  35. solver_support-0.1.1/tests/test_horizon_utils.py +360 -0
  36. solver_support-0.1.1/tests/test_import.py +103 -0
  37. solver_support-0.1.1/tests/test_run_summary.py +655 -0
  38. solver_support-0.1.1/tests/test_run_summary_success.py +129 -0
  39. solver_support-0.1.1/tests/test_solution_naming.py +117 -0
  40. solver_support-0.1.1/tests/test_solver_runner.py +870 -0
  41. solver_support-0.1.1/tests/test_solver_stats.py +998 -0
  42. solver_support-0.1.1/tests/test_solvers.py +548 -0
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, András Salamon
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,18 @@
1
+ # What lands in the SOURCE distribution (`python -m build --sdist`).
2
+ #
3
+ # This does not affect the wheel -- the artefact `pip install solver-support`
4
+ # actually fetches -- which carries only the solver_support package and its
5
+ # metadata, and is already minimal. It governs the source release, which should
6
+ # be self-contained: buildable, and testable by anyone repackaging it (a distro
7
+ # or conda-forge recipe typically runs the suite at build time).
8
+ #
9
+ # setuptools' defaults already pick up README.md, LICENSE, pyproject.toml and
10
+ # tests/test_*.py -- but NOT tests/conftest.py, whose name does not match
11
+ # `test*`, nor the YAML fixture the registry tests load. The sdist therefore
12
+ # shipped a test suite that could not run at all: every test needing a fixture
13
+ # errored on a missing one. These two lines are what make it coherent.
14
+ recursive-include tests *.py *.yaml
15
+
16
+ # The docs the README points at, so the source release does not dangle links to
17
+ # files it does not carry.
18
+ recursive-include docs *.md
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: solver-support
3
+ Version: 0.1.1
4
+ Summary: Generic Conjure / Savile Row / Fast Downward / SymK / ENHSP / UPF solver pipeline
5
+ Author: András Salamon
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Homepage, https://github.com/ott2/solver-support
8
+ Project-URL: Repository, https://github.com/ott2/solver-support
9
+ Project-URL: Issues, https://github.com/ott2/solver-support/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: psutil>=5.8.0
26
+ Requires-Dist: PyYAML>=5.4
27
+ Dynamic: license-file
28
+
29
+ # solver-support
30
+
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.
38
+
39
+ It carries **no** notion of any particular problem domain or objective. A
40
+ consumer supplies:
41
+
42
+ - its own **model registry** (a `models.yaml`) via a config path — the loader is
43
+ generic, the model content is the consumer's;
44
+ - its **scoring / objective strategy** via injection — the core reports the raw
45
+ objective a solver emits and lets the consumer interpret it (is-this-an-
46
+ optimisation-model, how to read the objective, any reporting multiplier).
47
+
48
+ ## Status
49
+
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.
53
+
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.
58
+
59
+ ## Installation
60
+
61
+ ```bash
62
+ pip install solver-support
63
+ ```
64
+
65
+ 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`.
67
+
68
+ ## Usage
69
+
70
+ See [`docs/injection-api.md`](https://github.com/ott2/solver-support/blob/main/docs/injection-api.md)
71
+ for the four injection points a consumer wires — model-registry paths, objective
72
+ extraction, warning logger, UPF CLI — with a worked example.
73
+
74
+ ## Docs
75
+
76
+ - [`injection-api.md`](https://github.com/ott2/solver-support/blob/main/docs/injection-api.md)
77
+ — the four seams a consumer wires.
78
+ - [`solver-runner.md`](https://github.com/ott2/solver-support/blob/main/docs/solver-runner.md)
79
+ — running a solver as a subprocess: `SolverResult`, timeout vs duration, phases.
80
+ - [`timing-model.md`](https://github.com/ott2/solver-support/blob/main/docs/timing-model.md)
81
+ — how solver timing is recorded, how the backends map onto one shape, and the
82
+ invariants parsers rely on.
83
+
84
+ ## Development
85
+
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:
89
+
90
+ ```bash
91
+ git clone https://github.com/ott2/solver-support
92
+ pip install -e solver-support
93
+ ```
94
+
95
+ ## Tests
96
+
97
+ `pytest tests/` — the core's own suite, and the home for solver-level
98
+ development. It runs against `solver_support` alone: no consumer need be
99
+ installed, and none is imported.
100
+
101
+ Since the core ships unwired, a test that needs a seam wired wires a stand-in
102
+ (`tests/conftest.py`):
103
+
104
+ | fixture | supplies |
105
+ |---------|----------|
106
+ | `fixture_registry` | a `ModelRegistry` over `tests/fixtures/models.yaml`, this suite's own synthetic config — never a consumer's |
107
+ | `wired_stdout_score` | a consumer-style `Score: N` extractor, so a score reaches `solver_stats` |
108
+ | `wired_timeout_score` | a consumer-style extractor for a salvaged timeout solution |
109
+
110
+ Wiring a fixture config rather than mocking the registry keeps the real loader
111
+ and accessors in the path. Anything a test needs from the registry goes in
112
+ `tests/fixtures/models.yaml`, keeping its shape faithful to a real consumer
113
+ config — that shape is the contract the loader implements.
114
+
115
+ ## Licence
116
+
117
+ BSD-3-Clause — see
118
+ [`LICENSE`](https://github.com/ott2/solver-support/blob/main/LICENSE).
119
+
120
+ By András Salamon, with Claude Opus 4.8 and 5.
@@ -0,0 +1,92 @@
1
+ # solver-support
2
+
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.
10
+
11
+ It carries **no** notion of any particular problem domain or objective. A
12
+ consumer supplies:
13
+
14
+ - its own **model registry** (a `models.yaml`) via a config path — the loader is
15
+ generic, the model content is the consumer's;
16
+ - its **scoring / objective strategy** via injection — the core reports the raw
17
+ objective a solver emits and lets the consumer interpret it (is-this-an-
18
+ optimisation-model, how to read the objective, any reporting multiplier).
19
+
20
+ ## Status
21
+
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.
25
+
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.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install solver-support
35
+ ```
36
+
37
+ 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`.
39
+
40
+ ## Usage
41
+
42
+ See [`docs/injection-api.md`](https://github.com/ott2/solver-support/blob/main/docs/injection-api.md)
43
+ for the four injection points a consumer wires — model-registry paths, objective
44
+ extraction, warning logger, UPF CLI — with a worked example.
45
+
46
+ ## Docs
47
+
48
+ - [`injection-api.md`](https://github.com/ott2/solver-support/blob/main/docs/injection-api.md)
49
+ — the four seams a consumer wires.
50
+ - [`solver-runner.md`](https://github.com/ott2/solver-support/blob/main/docs/solver-runner.md)
51
+ — running a solver as a subprocess: `SolverResult`, timeout vs duration, phases.
52
+ - [`timing-model.md`](https://github.com/ott2/solver-support/blob/main/docs/timing-model.md)
53
+ — how solver timing is recorded, how the backends map onto one shape, and the
54
+ invariants parsers rely on.
55
+
56
+ ## Development
57
+
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:
61
+
62
+ ```bash
63
+ git clone https://github.com/ott2/solver-support
64
+ pip install -e solver-support
65
+ ```
66
+
67
+ ## Tests
68
+
69
+ `pytest tests/` — the core's own suite, and the home for solver-level
70
+ development. It runs against `solver_support` alone: no consumer need be
71
+ installed, and none is imported.
72
+
73
+ Since the core ships unwired, a test that needs a seam wired wires a stand-in
74
+ (`tests/conftest.py`):
75
+
76
+ | fixture | supplies |
77
+ |---------|----------|
78
+ | `fixture_registry` | a `ModelRegistry` over `tests/fixtures/models.yaml`, this suite's own synthetic config — never a consumer's |
79
+ | `wired_stdout_score` | a consumer-style `Score: N` extractor, so a score reaches `solver_stats` |
80
+ | `wired_timeout_score` | a consumer-style extractor for a salvaged timeout solution |
81
+
82
+ Wiring a fixture config rather than mocking the registry keeps the real loader
83
+ and accessors in the path. Anything a test needs from the registry goes in
84
+ `tests/fixtures/models.yaml`, keeping its shape faithful to a real consumer
85
+ config — that shape is the contract the loader implements.
86
+
87
+ ## Licence
88
+
89
+ BSD-3-Clause — see
90
+ [`LICENSE`](https://github.com/ott2/solver-support/blob/main/LICENSE).
91
+
92
+ By András Salamon, with Claude Opus 4.8 and 5.
@@ -0,0 +1,137 @@
1
+ # injection API
2
+
3
+ `solver_support` ships **unwired**: a bare `import solver_support` gives a working
4
+ pipeline that knows nothing about any problem domain. It records whatever
5
+ objective a solver emits but cannot interpret it, resolves no model registry, and
6
+ routes warnings to its own package logger. A **consumer** supplies the missing
7
+ domain policy once, at its own import time, through the injection points below.
8
+
9
+ Every seam follows the same shape: a module-level default, a `set_*` setter, and
10
+ a stable wrapper (or constructor argument) that reads the current value **at call
11
+ time**. So wiring may happen any time before first use, and the core never imports
12
+ the consumer — the dependency arrow points one way, consumer → core.
13
+
14
+ ## the four seams
15
+
16
+ ### 1. model registry paths (`solver_support.model_registry`)
17
+
18
+ The loader is generic; the model content and its packaging are the consumer's.
19
+
20
+ | setter | supplies |
21
+ |--------|----------|
22
+ | `set_default_config_path(path)` | the consumer's `models.yaml` (a `Path` / str) |
23
+ | `set_resource_root(root)` | root that model-file paths in the config resolve against |
24
+ | `set_user_config_finder(finder)` | a **callable** returning an optional per-invocation user-config `Path` |
25
+
26
+ `set_user_config_finder` takes a *finder*, not a fixed path, so cwd-relative
27
+ candidates bind when the registry is constructed rather than at import. Until a
28
+ default config path is registered, constructing `ModelRegistry()` raises
29
+ `ModelRegistryError` rather than guessing a consumer's config.
30
+
31
+ ### 2. objective extraction (`solver_support.objective`)
32
+
33
+ The core asks for a solution's objective at a few points but does not know how a
34
+ domain encodes it. Register an extractor for each route the consumer uses; an
35
+ unregistered route yields `None` (the standalone core simply records no score).
36
+
37
+ | setter | extractor signature | used for |
38
+ |--------|--------------------|----------|
39
+ | `set_reported_score_from_solution(fn)` | `fn(solution_file) -> int \| None` | reported score read from a solution file |
40
+ | `set_reported_score_from_stdout(fn)` | `fn(stdout) -> int \| None` | reported score printed on solver stdout |
41
+ | `set_reported_score_from_timeout_solution(fn)` | `fn(solution_file) -> int \| None` | a salvaged, possibly-truncated timeout solution |
42
+ | `set_raw_objective_from_solution(fn)` | `fn(solution_file) -> int \| None` | the **raw** (unscaled) objective the horizon scan compares between horizons |
43
+
44
+ Keep the raw objective in model units — it drives horizon comparison — while the
45
+ reported extractors may apply any display scaling the consumer wants.
46
+
47
+ ### 3. warning logger name (`solver_support.utilities`)
48
+
49
+ `warn()` routes through a logger named `_warn_logger_name` (default
50
+ `'solver_support'`). A consumer that configures its own named logger (with file /
51
+ console handlers) points `warn()` at it so these warnings share those handlers:
52
+
53
+ ```python
54
+ from solver_support import set_warn_logger_name
55
+ set_warn_logger_name('my_runner') # matches setup_logger('my_runner', ...)
56
+ ```
57
+
58
+ ### 4. UPF planner CLI (`solver_support.solver_upfplan.UpfPlanSolver`)
59
+
60
+ The generic UPF driver shells out to a CLI that performs the UPF
61
+ PDDLReader/construction and solve. There is **no** built-in default (the CLI is
62
+ consumer-provided). Inject it per instance, or override the class attribute:
63
+
64
+ ```python
65
+ UpfPlanSolver(runner, solver_name='upffd', build_mode='upf',
66
+ upfplan_command='my-upfplan') # per instance (preferred)
67
+ # or, once:
68
+ UpfPlanSolver.UPFPLAN_COMMAND = 'my-upfplan' # class-attribute override
69
+ ```
70
+
71
+ `solve()` refuses with a clear `ValueError` if no command is configured, rather
72
+ than shelling out to a `None`-prefixed argv.
73
+
74
+ ## worked example
75
+
76
+ Wire everything once, at the consumer's own import time — in its
77
+ `__init__.py`, so that every entry point and every test that imports the package
78
+ gets an identically configured core, and nothing has to remember to wire it:
79
+
80
+ ```python
81
+ # in mypuzzle_support/__init__.py
82
+
83
+ # 1. scoring: register the objective extractors this domain uses. Grouping them
84
+ # behind one wire_into_core() keeps the registration next to the parsing.
85
+ from . import mypuzzle_scoring
86
+ mypuzzle_scoring.wire_into_core()
87
+ ```
88
+
89
+ ```python
90
+ # in mypuzzle_support/mypuzzle_scoring.py
91
+ import re
92
+ from solver_support import (
93
+ set_reported_score_from_stdout, set_raw_objective_from_solution,
94
+ )
95
+
96
+ def _score_from_stdout(stdout):
97
+ m = re.search(r'^Score:\s*(-?\d+)', stdout, re.MULTILINE)
98
+ return int(m.group(1)) if m else None
99
+
100
+ def wire_into_core():
101
+ set_reported_score_from_stdout(_score_from_stdout)
102
+ set_raw_objective_from_solution(_objective_from_solution) # model units
103
+ ```
104
+
105
+ ```python
106
+ # back in __init__.py
107
+
108
+ # 2. registry packaging: where this consumer's models.yaml and model files live.
109
+ # A finder (not a fixed path) for the user override, so cwd-relative
110
+ # candidates bind when the registry is built rather than at import.
111
+ from pathlib import Path
112
+ from solver_support import (
113
+ set_default_config_path, set_resource_root, set_user_config_finder,
114
+ )
115
+
116
+ _here = Path(__file__).parent
117
+ set_default_config_path(_here / 'config' / 'models.yaml')
118
+ set_resource_root(_here)
119
+ set_user_config_finder(lambda: next(
120
+ (p for p in (Path.cwd() / 'models.yaml',) if p.is_file()), None))
121
+
122
+ # 3. warnings: funnel the core's warn() into this consumer's configured logger,
123
+ # so they reach the same file and console handlers as its own messages.
124
+ from solver_support import set_warn_logger_name
125
+ set_warn_logger_name('mypuzzle_runner')
126
+ ```
127
+
128
+ and inject the UPF CLI where the solver is constructed:
129
+
130
+ ```python
131
+ UpfPlanSolver(runner, solver_name=solver, build_mode=build_mode,
132
+ upfplan_command='mypuzzle-upfplan')
133
+ ```
134
+
135
+ Each consumer registers its own scoring, its own `models.yaml`, its own logger
136
+ name and its own UPF CLI. Nothing above is built into the core, and the core
137
+ never imports the consumer.
@@ -0,0 +1,134 @@
1
+ # solver runner
2
+
3
+ `solver_runner.py` runs a solver as a subprocess and reports what happened. It
4
+ exists because a bare `subprocess.run` cannot answer the question the pipeline
5
+ actually asks — *did this solver fail, or did it run out of time, or did the user
6
+ stop it?* — and because a solver that is killed must not leave a process tree
7
+ behind. It handles:
8
+
9
+ - timeouts, with the whole process tree torn down (via `psutil`);
10
+ - signal handling, so an interrupted run shuts down cleanly rather than orphaning
11
+ a solver;
12
+ - separate stdout/stderr capture (the plan stays clean on stdout while an engine
13
+ logs its search to stderr);
14
+ - structured logging to console and file;
15
+ - phase tracking, feeding the `RunSummary` (see [`timing-model.md`](timing-model.md)).
16
+
17
+ ## SolverResult
18
+
19
+ The complete result of one execution — the raw data plus the status properties
20
+ the pipeline branches on.
21
+
22
+ ```python
23
+ SolverResult(command, returncode, stdout, stderr, duration,
24
+ timed_out=False, interrupted=False)
25
+ ```
26
+
27
+ | field | meaning |
28
+ |-------|---------|
29
+ | `command: List[str]` | the command and arguments that ran |
30
+ | `returncode: int` | process return code (0 for success) |
31
+ | `stdout: str` / `stderr: str` | captured output, kept separate |
32
+ | `duration: float` | **actual** seconds the command ran |
33
+ | `timed_out: bool` | it was killed for exceeding the timeout |
34
+ | `interrupted: bool` | it was stopped by a signal |
35
+
36
+ ### timeout vs duration
37
+
38
+ Three easily-confused numbers:
39
+
40
+ - `default_timeout` (a `SolverRunner` parameter) — the *limit*;
41
+ - `duration` (a `SolverResult` field) — the *actual* time it ran, whether it
42
+ finished or was killed;
43
+ - `timed_out` (a `SolverResult` field) — whether that limit was hit.
44
+
45
+ ```python
46
+ runner = SolverRunner(default_timeout=10.0)
47
+
48
+ runner.run(['sleep', '3']) # duration ~3.0, timed_out False, returncode 0
49
+ runner.run(['sleep', '15']) # duration ~10.0, timed_out True, returncode -1
50
+ ```
51
+
52
+ `duration` is ground truth for the phase, and every finer-grained solver time is
53
+ a component of it — see the invariants in [`timing-model.md`](timing-model.md).
54
+
55
+ ### properties
56
+
57
+ - `success: bool` — completed with return code 0, no timeout, no interruption.
58
+ - `failed: bool` — the inverse; useful as a direct `if` on a run.
59
+
60
+ Note that `failed` covers three *different* outcomes, and the pipeline usually
61
+ cares which: check `timed_out` and `interrupted` before treating a run as a
62
+ genuine solver failure. A timed-out anytime solver may still have banked a
63
+ solution worth keeping.
64
+
65
+ The runner applies that rule to its own logging: a timeout or an interrupt is
66
+ recorded at INFO (`Command did not complete`), and only a solver that ran and
67
+ exited non-zero is logged as `Command failed` at ERROR. A launch failure logs its
68
+ own error before returning. So a consumer whose timed-out run *is* the result -
69
+ a benchmark baseline, say - gets no spurious error rows.
70
+
71
+ `run_phase` carries the same distinction into the persisted summary: the phase
72
+ records `returncode`, `timed_out` and `launch_failed` alongside `success`, so the
73
+ outcome can be reconstructed from a saved run without re-reading the log.
74
+
75
+ - `__str__()` renders `SolverResult({command}) -> {STATUS} (rc={returncode},
76
+ t={duration}s)` with `STATUS` one of `SUCCESS` / `FAILED` / `TIMEOUT` /
77
+ `INTERRUPTED`:
78
+
79
+ ```
80
+ SolverResult(echo hello) -> SUCCESS (rc=0, t=0.01s)
81
+ SolverResult(sleep 10) -> TIMEOUT (rc=-1, t=5.00s)
82
+ SolverResult(ls /nonexistent) -> FAILED (rc=1, t=0.01s)
83
+ ```
84
+
85
+ ## usage
86
+
87
+ ```python
88
+ from solver_support import SolverRunner
89
+
90
+ runner = SolverRunner(default_timeout=30.0)
91
+ result = runner.run(['savilerow', 'model.eprime', 'problem.param'])
92
+
93
+ if result.timed_out:
94
+ print(f"solver timed out after {result.duration:.1f}s")
95
+ elif result.interrupted:
96
+ print("solver was interrupted")
97
+ elif result.failed:
98
+ print(f"solver failed with return code {result.returncode}: {result.stderr}")
99
+ else:
100
+ print(f"solved in {result.duration:.2f}s")
101
+ ```
102
+
103
+ Two variants of the same call:
104
+
105
+ - `run_phase(phase_name, command, expected_outputs=..., horizon_number=...)` —
106
+ what the pipeline uses. It runs the command *and* records a `PhaseResult` on
107
+ the `RunSummary`, with the parsed solver stats attached; use it for anything
108
+ whose timing should appear in the summary.
109
+ - `run_simple(*command_parts)` — the terse variant, returning `result.failed`
110
+ (truthy on failure) for call sites that only branch on success and want no
111
+ phase recorded.
112
+
113
+ Two optional controls, both defaulting to the historical behaviour:
114
+
115
+ - `cwd=` on `run` / `run_phase` — the subprocess's working directory. Omitted, it
116
+ inherits the caller's, which is what the `Solver` classes rely on (they are
117
+ documented to run after a chdir, with paths relative to it). A consumer that
118
+ would rather not chdir a whole process can place each subprocess instead.
119
+ - `log_level=` on the `SolverRunner` constructor — console verbosity for this
120
+ runner. `global_state.log_level()` can only ever *raise* verbosity (it floors
121
+ at WARNING), so this is the way to make a runner quieter. The file handler
122
+ captures everything regardless.
123
+
124
+ ## tests
125
+
126
+ `tests/test_solver_runner.py` — execution outcomes (success / failure / timeout /
127
+ interruption), process-tree and child-process cleanup, signal handling, logging
128
+ setup, environment and working-directory handling, output edge cases (long
129
+ output, special characters, empty commands), and integration tests that run real
130
+ system commands.
131
+
132
+ Signals are *not* handled here: `SolverRunner` runs a command and reports, while
133
+ installing the handler and deciding what an interrupt means for the run as a
134
+ whole belongs to the consumer's top-level runner.