solver-support 0.4.0__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {solver_support-0.4.0/solver_support.egg-info → solver_support-0.4.1}/PKG-INFO +2 -2
  2. {solver_support-0.4.0 → solver_support-0.4.1}/README.md +1 -1
  3. {solver_support-0.4.0 → solver_support-0.4.1}/docs/timing-model.md +20 -0
  4. {solver_support-0.4.0 → solver_support-0.4.1}/pyproject.toml +1 -1
  5. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/horizon_utils.py +35 -10
  6. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/model_registry.py +68 -12
  7. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/solver_horizon.py +110 -22
  8. solver_support-0.4.1/solver_support/starter/models.yaml +189 -0
  9. {solver_support-0.4.0 → solver_support-0.4.1/solver_support.egg-info}/PKG-INFO +2 -2
  10. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support.egg-info/SOURCES.txt +1 -0
  11. {solver_support-0.4.0 → solver_support-0.4.1}/tests/fixtures/models.yaml +26 -0
  12. solver_support-0.4.1/tests/test_horizon_params.py +139 -0
  13. solver_support-0.4.1/tests/test_starter_config.py +160 -0
  14. solver_support-0.4.0/solver_support/starter/models.yaml +0 -144
  15. solver_support-0.4.0/tests/test_starter_config.py +0 -85
  16. {solver_support-0.4.0 → solver_support-0.4.1}/LICENSE +0 -0
  17. {solver_support-0.4.0 → solver_support-0.4.1}/MANIFEST.in +0 -0
  18. {solver_support-0.4.0 → solver_support-0.4.1}/docs/injection-api.md +0 -0
  19. {solver_support-0.4.0 → solver_support-0.4.1}/docs/solver-runner.md +0 -0
  20. {solver_support-0.4.0 → solver_support-0.4.1}/setup.cfg +0 -0
  21. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/__init__.py +0 -0
  22. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/command_builders.py +0 -0
  23. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/file_management.py +0 -0
  24. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/global_state.py +0 -0
  25. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/objective.py +0 -0
  26. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/run_summary.py +0 -0
  27. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/solver_base.py +0 -0
  28. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/solver_enhsp.py +0 -0
  29. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/solver_fd.py +0 -0
  30. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/solver_runner.py +0 -0
  31. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/solver_stats.py +0 -0
  32. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/solver_upfplan.py +0 -0
  33. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support/utilities.py +0 -0
  34. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support.egg-info/dependency_links.txt +0 -0
  35. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support.egg-info/not-zip-safe +0 -0
  36. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support.egg-info/requires.txt +0 -0
  37. {solver_support-0.4.0 → solver_support-0.4.1}/solver_support.egg-info/top_level.txt +0 -0
  38. {solver_support-0.4.0 → solver_support-0.4.1}/tests/conftest.py +0 -0
  39. {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_command_builders.py +0 -0
  40. {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_fd_verdict.py +0 -0
  41. {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_helpers.py +0 -0
  42. {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_horizon_utils.py +0 -0
  43. {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_import.py +0 -0
  44. {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_proves_unsat.py +0 -0
  45. {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_registry_dump.py +0 -0
  46. {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_run_summary.py +0 -0
  47. {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_run_summary_success.py +0 -0
  48. {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_savilerow_info_recording.py +0 -0
  49. {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_solution_naming.py +0 -0
  50. {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_solver_runner.py +0 -0
  51. {solver_support-0.4.0 → solver_support-0.4.1}/tests/test_solver_stats.py +0 -0
  52. {solver_support-0.4.0 → solver_support-0.4.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.4.0
3
+ Version: 0.4.1
4
4
  Summary: Generic Conjure / Savile Row / Fast Downward / SymK / ENHSP / UPF solver pipeline
5
5
  Author: András Salamon
6
6
  License-Expression: BSD-3-Clause
@@ -75,7 +75,7 @@ runtime dependencies are `psutil` and `PyYAML`.
75
75
  ## Quick start
76
76
 
77
77
  The package ships no model registry, but it carries a commented starter one
78
- (Fast Downward, SymK, ENHSP and Savile Row) to copy and edit. Writing it out
78
+ (Conjure, Savile Row, Fast Downward, SymK and ENHSP) to copy and edit. Writing it out
79
79
  does not wire it; you point the registry at it:
80
80
 
81
81
  ```python
@@ -47,7 +47,7 @@ runtime dependencies are `psutil` and `PyYAML`.
47
47
  ## Quick start
48
48
 
49
49
  The package ships no model registry, but it carries a commented starter one
50
- (Fast Downward, SymK, ENHSP and Savile Row) to copy and edit. Writing it out
50
+ (Conjure, Savile Row, Fast Downward, SymK and ENHSP) to copy and edit. Writing it out
51
51
  does not wire it; you point the registry at it:
52
52
 
53
53
  ```python
@@ -70,6 +70,18 @@ fields. All times are seconds unless noted.
70
70
  ### Savile Row (Conjure / Essence Prime models)
71
71
 
72
72
  - **Shape**: a horizon scan — one `solving-horizon-N` phase per move budget.
73
+ Each horizon reaches the model as `letting <name> be N`, appended to the
74
+ instance for every name in the model's `horizon-params` (or its solver's).
75
+ These must be exactly the parameters the model declares, since Conjure
76
+ rejects a `letting` for one it lacks. A config without the key gets the
77
+ historical names, `noSteps` (Savile Row) or `maxMoves` + `horizon` (Conjure),
78
+ so models written for those keep working. Only `letting` lines for those names
79
+ are dropped from the instance first, not any line that mentions one.
80
+ - **Which Savile Row entry**: Conjure only refines and translates. Each horizon
81
+ is tailored and solved by the Savile Row entry the `conjure` config names
82
+ (`savilerow: <entry>`), and an Essence run's `--tune` picks among that
83
+ entry's presets. A direct Savile Row scan uses the run's own solver. Without
84
+ either, it is `kissat`, the name older configs use.
73
85
  - **Split source**: Savile Row writes `<param>.info` per horizon;
74
86
  `_record_savilerow_times` (`solver_horizon.py`) reads `SavileRowTotalTime`
75
87
  (tailoring) → `savilerow_time` and `SolverTotalTime` (backend search) →
@@ -176,6 +188,14 @@ So size the external kill with `savilerow_backstop(T, S)` — `T + S` plus grace
176
188
  for the flush — and it comes to mean "the toolchain ignored its limits", a
177
189
  genuine fault rather than an ordinary and lossy way for a run to end.
178
190
 
191
+ The built-in horizon scans do this per horizon when asked:
192
+ `SavileRowSolver.solve(..., backstop=True)` and `ConjureSolver.solve(...,
193
+ backstop=True)` kill each horizon at `savilerow_backstop(R)`, where `R` is the
194
+ budget that horizon's command was built with. Without it, each horizon is
195
+ killed at the runner's own timeout, as before. It is opt-in because a scan
196
+ killed at the backstop can overrun the runner's timeout by up to about that
197
+ timeout again, which would change the run times of an existing consumer.
198
+
179
199
  **To hold a run to a wall-clock budget, shrink the pair, not the backstop.**
180
200
  Give the solver `S = T - (what tailoring will cost)`. The core picks no reserve
181
201
  policy: a horizon scan can estimate from the previous horizon's
@@ -20,7 +20,7 @@ name = "solver-support"
20
20
  # escape hatch for a version that genuinely cannot be known without building.
21
21
  # Ours can. Carrying a `.devN` suffix between releases keeps the working tree
22
22
  # from claiming a version that is already tagged and published.
23
- version = "0.4.0"
23
+ version = "0.4.1"
24
24
  description = "Generic Conjure / Savile Row / Fast Downward / SymK / ENHSP / UPF solver pipeline"
25
25
  readme = "README.md"
26
26
  requires-python = ">=3.9"
@@ -6,10 +6,11 @@ that can be used by different solver types (Savile Row, Conjure, etc.).
6
6
  """
7
7
 
8
8
  import logging
9
+ import re
9
10
  import shutil
10
11
  import time
11
12
  from pathlib import Path
12
- from typing import Optional, Callable, Tuple
13
+ from typing import Optional, Callable, Sequence, Tuple
13
14
 
14
15
  from .file_management import SolutionFileNamer
15
16
  from .global_state import global_state
@@ -76,6 +77,20 @@ def _create_main_solution_from_best(runner: SolverRunner, best_solution_found: P
76
77
  return main_solution
77
78
 
78
79
 
80
+ #: Every name a horizon scan has ever written, for a caller that does not say
81
+ #: which it writes. Only `letting` lines for these are dropped from the instance;
82
+ #: the scan's callback then appends its own for the horizon at hand.
83
+ LEGACY_HORIZON_PARAMS = ('noSteps', 'maxMoves', 'horizon')
84
+
85
+ _LETTING_NAME_RE = re.compile(r'^\s*letting\s+(\w+)\b')
86
+
87
+
88
+ def _is_horizon_letting(line: str, names: Sequence[str]) -> bool:
89
+ """Whether ``line`` sets one of the horizon parameters ``names``."""
90
+ m = _LETTING_NAME_RE.match(line)
91
+ return bool(m) and m.group(1) in names
92
+
93
+
79
94
  def horizon_search(runner: SolverRunner,
80
95
  param_file: Path,
81
96
  startat: int,
@@ -83,7 +98,8 @@ def horizon_search(runner: SolverRunner,
83
98
  logger: logging.Logger,
84
99
  min_horizon: int = 1,
85
100
  stopat: int = 100,
86
- extra_constraints_callback: Optional[Callable[[Path, int], None]] = None) -> Optional[Path]:
101
+ extra_constraints_callback: Optional[Callable[[Path, int], None]] = None,
102
+ horizon_params: Optional[Sequence[str]] = None) -> Optional[Path]:
87
103
  """
88
104
  Generic horizon-extending search loop.
89
105
 
@@ -97,6 +113,9 @@ def horizon_search(runner: SolverRunner,
97
113
  min_horizon: Minimum allowed horizon value
98
114
  stopat: Highest horizon to try (inclusive upper limit of the scan)
99
115
  extra_constraints_callback: Optional function(horizon_param_file, n) to add extra constraints
116
+ horizon_params: The parameter names the callback sets to the horizon, so
117
+ an instance's own value for them is dropped first. None means
118
+ LEGACY_HORIZON_PARAMS.
100
119
 
101
120
  Returns:
102
121
  Path to solution file if found, None otherwise
@@ -137,7 +156,8 @@ def horizon_search(runner: SolverRunner,
137
156
  return None
138
157
 
139
158
  # Create parameter file with horizon constraints
140
- horizon_param_file = create_horizon_param_file(param_file, output_dir, n, logger, runner)
159
+ horizon_param_file = create_horizon_param_file(
160
+ param_file, output_dir, n, logger, runner, horizon_params=horizon_params)
141
161
  if not horizon_param_file:
142
162
  continue
143
163
 
@@ -200,7 +220,8 @@ def horizon_search(runner: SolverRunner,
200
220
  return None
201
221
 
202
222
 
203
- def create_horizon_param_file(param_file: Path, output_dir: Path, n: int, logger: logging.Logger, runner: SolverRunner = None) -> Optional[Path]:
223
+ def create_horizon_param_file(param_file: Path, output_dir: Path, n: int, logger: logging.Logger, runner: SolverRunner = None,
224
+ horizon_params: Optional[Sequence[str]] = None) -> Optional[Path]:
204
225
  """
205
226
  Create parameter file with base content, ready for horizon-specific constraints.
206
227
 
@@ -210,6 +231,9 @@ def create_horizon_param_file(param_file: Path, output_dir: Path, n: int, logger
210
231
  n: Horizon value
211
232
  logger: Logger instance
212
233
  runner: SolverRunner instance for consistent naming (optional for backward compatibility)
234
+ horizon_params: Names whose `letting` lines are dropped from the
235
+ instance, since the caller sets them per horizon. None means
236
+ LEGACY_HORIZON_PARAMS.
213
237
 
214
238
  Returns:
215
239
  Path to created parameter file, or None if creation failed
@@ -233,12 +257,13 @@ def create_horizon_param_file(param_file: Path, output_dir: Path, n: int, logger
233
257
  with open(param_file, 'r') as f:
234
258
  param_content = f.read()
235
259
 
236
- # Remove any existing horizon-related lines
237
- param_lines = []
238
- for line in param_content.splitlines():
239
- # Remove existing horizon constraint lines
240
- if not any(keyword in line for keyword in ['noSteps', 'maxMoves', 'horizon']):
241
- param_lines.append(line)
260
+ # Drop the instance's own value for each horizon parameter: the caller
261
+ # appends one per horizon, and two lettings of a name is an error. Only
262
+ # a `letting` of exactly that name goes - a line that merely mentions
263
+ # it (a comment, `letting horizonLimit`) is the instance's own.
264
+ names = LEGACY_HORIZON_PARAMS if horizon_params is None else tuple(horizon_params)
265
+ param_lines = [line for line in param_content.splitlines()
266
+ if not _is_horizon_letting(line, names)]
242
267
 
243
268
  # Write base parameter file
244
269
  with open(horizon_param_file, 'w') as f:
@@ -8,12 +8,20 @@ from the original bash run script.
8
8
  """
9
9
 
10
10
  from pathlib import Path
11
- from typing import Dict, Any, Optional, List, Tuple, Callable
11
+ from typing import Dict, Any, Optional, List, Tuple, Callable, Sequence
12
12
  import copy
13
+ import os
13
14
  import shutil
14
15
  import yaml
15
16
 
16
17
 
18
+ #: The Savile Row entry a Conjure solver delegates to when its config does not
19
+ #: name one (``savilerow:``), and the entry a Savile Row horizon scan uses when
20
+ #: neither the caller nor the run names one. Kept for configs written before
21
+ #: those could be named; a new config should name its own.
22
+ LEGACY_CONJURE_SAVILEROW = 'kissat'
23
+
24
+
17
25
  class ModelRegistryError(Exception):
18
26
  """Exception raised for model registry errors."""
19
27
  pass
@@ -43,10 +51,13 @@ _user_config_finder: Optional[Callable[[], Optional[Path]]] = None
43
51
  def set_default_config_path(path) -> None:
44
52
  """Register the default ``models.yaml`` the registry loads at startup.
45
53
 
46
- Stored as-is (a path or an ``importlib.resources`` traversable), so the
47
- loader can ``open`` it directly regardless of how the consumer resolved it.
54
+ A ``str`` or other path-like becomes a :class:`~pathlib.Path`; anything
55
+ else (an ``importlib.resources`` traversable) is stored as-is, so the loader
56
+ can ``open`` it directly regardless of how the consumer resolved it.
48
57
  """
49
58
  global _default_config_path
59
+ if isinstance(path, (str, os.PathLike)):
60
+ path = Path(path)
50
61
  _default_config_path = path
51
62
 
52
63
 
@@ -89,9 +100,15 @@ class ModelRegistry(CommandBuilderMixin):
89
100
  # First, load the default YAML config from package
90
101
  default_yaml = self._find_default_yaml_config()
91
102
  if not default_yaml:
103
+ if _default_config_path is not None:
104
+ raise ModelRegistryError(
105
+ f"The registered models.yaml ({_default_config_path}) is not "
106
+ "a file."
107
+ )
92
108
  raise ModelRegistryError(
93
- "Default models.yaml configuration file not found. "
94
- "Please ensure the package is properly installed."
109
+ "No models.yaml is registered: solver-support ships without one. "
110
+ "Call set_default_config_path() before first use; "
111
+ "write_starter_config() writes a starter to begin from."
95
112
  )
96
113
 
97
114
  self.config = {'models': {}, 'solvers': {}}
@@ -642,6 +659,33 @@ class ModelRegistry(CommandBuilderMixin):
642
659
  return None, None
643
660
  return model_config.get('status'), model_config.get('status-detail')
644
661
 
662
+ def get_horizon_params(self, model_type: str,
663
+ default: Sequence[str] = ()) -> List[str]:
664
+ """The parameter names a horizon scan sets to the horizon for a model.
665
+
666
+ Each horizon N reaches the model as ``letting <name> be N`` appended to
667
+ the instance, so these must be exactly the names the model declares:
668
+ Conjure rejects a ``letting`` for a parameter the model does not have.
669
+ Read from the model's ``horizon-params``, then its solver's, then
670
+ ``default`` - the route's own historical names, which configs written
671
+ before the key existed rely on. A single name may be given as a string.
672
+ Resolves aliases and never raises (an unknown model gets ``default``).
673
+ """
674
+ try:
675
+ _, model_config = self.resolve_model_variant(model_type)
676
+ except (ValueError, ModelRegistryError):
677
+ return list(default)
678
+ names = model_config.get('horizon-params')
679
+ if names is None:
680
+ try:
681
+ names = self.get_solver_config(
682
+ model_config.get('solver', '')).get('horizon-params')
683
+ except ModelRegistryError:
684
+ names = None
685
+ if names is None:
686
+ return list(default)
687
+ return [names] if isinstance(names, str) else list(names)
688
+
645
689
  def get_model_default_tune(self, model_type: str) -> Optional[str]:
646
690
  """Return a model's preferred ``--tune`` preset, or None.
647
691
 
@@ -656,17 +700,29 @@ class ModelRegistry(CommandBuilderMixin):
656
700
  return None
657
701
  return model_config.get('tune')
658
702
 
703
+ def get_conjure_savilerow(self, solver_name: str = 'conjure') -> str:
704
+ """The Savile Row entry a Conjure solver hands each horizon's solve to.
705
+
706
+ Conjure only refines and translates. Savile Row then tailors the
707
+ instance and runs its backend, under the solver entry the ``conjure``
708
+ config names with ``savilerow:``. A config without the key gets
709
+ ``kissat``, the name configs written before the key existed use.
710
+ """
711
+ return self.get_solver_config(solver_name).get(
712
+ 'savilerow', LEGACY_CONJURE_SAVILEROW)
713
+
659
714
  def get_tune_backend(self, solver_name: str) -> str:
660
715
  """Return the backend whose tune presets govern ``--tune`` for a solver.
661
716
 
662
717
  Almost always the solver itself. The exception is the Conjure/Essence
663
- path: its per-horizon inner solve is delegated to kissat (see
664
- ConjureSolver), so an Essence run's ``--tune`` presets are kissat's, not
665
- the tune-less ``conjure`` entry's. Keeping this mapping here means the
666
- CLI validity check and ``build_*_command`` agree on what a tune means.
718
+ path: its per-horizon inner solve is delegated to a Savile Row entry
719
+ (:meth:`get_conjure_savilerow`), so an Essence run's ``--tune`` presets
720
+ are that entry's, not the tune-less ``conjure`` entry's. Keeping this
721
+ mapping here means the CLI validity check and ``build_*_command`` agree
722
+ on what a tune means.
667
723
  """
668
724
  if self.get_solver_type(solver_name) == 'conjure':
669
- return 'kissat'
725
+ return self.get_conjure_savilerow(solver_name)
670
726
  return solver_name
671
727
 
672
728
  def get_valid_tunes(self, solver_name: str) -> List[str]:
@@ -828,8 +884,8 @@ class ModelRegistry(CommandBuilderMixin):
828
884
  def starter_config_text() -> str:
829
885
  """The starter ``models.yaml`` shipped with the package, as text.
830
886
 
831
- A commented example registry - Fast Downward, SymK, ENHSP and Savile Row -
832
- for a new user to copy and edit. It is never loaded implicitly: the core
887
+ A commented example registry - Conjure, Savile Row, Fast Downward, SymK
888
+ and ENHSP - for a new user to copy and edit. It is never loaded implicitly: the core
833
889
  still ships unwired, and :class:`ModelRegistry` still raises until a config
834
890
  path is set. Unlike :meth:`ModelRegistry.dump_config` it keeps its comments,
835
891
  which is what makes it fit to start from.
@@ -10,7 +10,7 @@ import os
10
10
  import shutil
11
11
  import time
12
12
  from pathlib import Path
13
- from typing import Optional, List
13
+ from typing import Optional, List, Tuple
14
14
 
15
15
  from .solver_stats import SolverStatsExtractor
16
16
  from .solver_runner import SolverExecutableError
@@ -18,19 +18,90 @@ from .global_state import global_state
18
18
  from .file_management import SolutionFileNamer, ObjectiveFileManager
19
19
  from .solver_base import Solver
20
20
  from .horizon_utils import horizon_search, calculate_horizon_timeout
21
+ from .command_builders import savilerow_backstop
21
22
  from .objective import raw_objective_from_solution
23
+ from .model_registry import LEGACY_CONJURE_SAVILEROW
22
24
 
23
25
 
24
26
  class HorizonBasedSolver(Solver):
25
27
  """Base class for solvers that use horizon search with optimization support."""
26
28
 
27
- def __init__(self, solver_runner, solver_name: str = 'kissat'):
28
- """Initialize with solver runner and optimization tracking."""
29
+ #: The parameter(s) this route sets to the horizon, for a model whose
30
+ #: registry entry names none (``horizon-params``). Kept for configs written
31
+ #: before the key existed; a new config should name its own.
32
+ DEFAULT_HORIZON_PARAMS: Tuple[str, ...] = ()
33
+
34
+ def __init__(self, solver_runner, solver_name: Optional[str] = None):
35
+ """Initialize with solver runner and optimization tracking.
36
+
37
+ ``solver_name`` is the Savile Row entry that solves each horizon. None
38
+ resolves it from the registry when first needed (see
39
+ :meth:`_default_solver_name`).
40
+ """
29
41
  super().__init__(solver_runner)
30
42
  self._best_objective = None
31
43
  self._has_timeout = False # Track if we're running with a timeout
32
44
  self._stop_at_first = False # regime: first stops the scan at the first solving horizon
33
- self.solver_name = solver_name # the backend config (kissat/minion/...)
45
+ self._solver_name = solver_name
46
+ self._backstop = False # size each horizon's kill above its in-band limits
47
+
48
+ @property
49
+ def solver_name(self) -> str:
50
+ """The Savile Row entry that solves each horizon."""
51
+ if self._solver_name is None:
52
+ return self._default_solver_name()
53
+ return self._solver_name
54
+
55
+ @solver_name.setter
56
+ def solver_name(self, value: Optional[str]) -> None:
57
+ self._solver_name = value
58
+
59
+ def _default_solver_name(self) -> str:
60
+ """The entry to use when the caller named none: the run's own solver
61
+ if it is a Savile Row entry, else the legacy name."""
62
+ try:
63
+ run_solver = self.runner.summary.solver
64
+ if global_state.model_registry.get_solver_type(run_solver) == 'savilerow':
65
+ return run_solver
66
+ except Exception:
67
+ pass
68
+ return LEGACY_CONJURE_SAVILEROW
69
+
70
+ def _horizon_params(self) -> List[str]:
71
+ """The parameter names this run sets to the horizon (see
72
+ :meth:`ModelRegistry.get_horizon_params`)."""
73
+ try:
74
+ return global_state.model_registry.get_horizon_params(
75
+ self.runner.summary.model, self.DEFAULT_HORIZON_PARAMS)
76
+ except Exception:
77
+ return list(self.DEFAULT_HORIZON_PARAMS)
78
+
79
+ def _horizon_watchdog(self, horizon_timeout: Optional[int]) -> Optional[int]:
80
+ """The kill for one horizon's Savile Row run, or None for the runner's.
81
+
82
+ With ``backstop=True`` it is :func:`savilerow_backstop` over the same
83
+ budget the command was built with: Savile Row's ``-timelimit`` and the
84
+ solver's own limit each get ``horizon_timeout`` and run one after the
85
+ other, so a kill below their sum can land before Savile Row writes its
86
+ ``.info`` and loses every statistic. Off by default, because a scan
87
+ killed at the backstop can overrun the runner's timeout by up to about
88
+ that timeout again.
89
+ """
90
+ if self._backstop and horizon_timeout is not None:
91
+ return savilerow_backstop(horizon_timeout)
92
+ return None
93
+
94
+ def _write_horizon(self, horizon_param_file: Path, n: int,
95
+ names: List[str]) -> bool:
96
+ """Append ``letting <name> be n`` for each horizon parameter."""
97
+ try:
98
+ with open(horizon_param_file, 'a') as f:
99
+ for name in names:
100
+ f.write(f"letting {name} be {n}\n")
101
+ return True
102
+ except Exception as e:
103
+ self.logger.error(f"Failed to add horizon constraints: {e}")
104
+ return False
34
105
 
35
106
  def _generate_objective_file_path(self, n: int) -> Optional[Path]:
36
107
  """Generate the objective file path for a given horizon, or None.
@@ -339,7 +410,9 @@ class HorizonBasedSolver(Solver):
339
410
  class SavileRowSolver(HorizonBasedSolver):
340
411
  """Solver implementation for Savile Row with horizon search."""
341
412
 
342
- def __init__(self, solver_runner, solver_name: str = 'kissat'):
413
+ DEFAULT_HORIZON_PARAMS = ('noSteps',)
414
+
415
+ def __init__(self, solver_runner, solver_name: Optional[str] = None):
343
416
  """Initialize with solver runner and solver name."""
344
417
  super().__init__(solver_runner, solver_name)
345
418
 
@@ -352,6 +425,10 @@ class SavileRowSolver(HorizonBasedSolver):
352
425
  - model_path: Path to Essence Prime model file (required)
353
426
  - startat: Starting horizon value (default: 1)
354
427
  - stopat: Highest horizon to try (default: 100)
428
+ - backstop: kill each horizon at savilerow_backstop of its
429
+ budget rather than at the runner's timeout, so Savile Row's
430
+ own limits fire first and its statistics survive
431
+ (default: False)
355
432
  - objbound: Objective bound constraint for optimization models
356
433
  Returns:
357
434
  Path to solution file if successful, None otherwise
@@ -363,6 +440,7 @@ class SavileRowSolver(HorizonBasedSolver):
363
440
 
364
441
  startat = kwargs.get('startat', 1)
365
442
  stopat = kwargs.get('stopat', 100)
443
+ self._backstop = bool(kwargs.get('backstop', False))
366
444
  objbound = kwargs.get('objbound')
367
445
 
368
446
  # Setup optimization parameters
@@ -372,6 +450,7 @@ class SavileRowSolver(HorizonBasedSolver):
372
450
  # Track total elapsed time for timeout management
373
451
  total_start_time = time.time()
374
452
  original_timeout = self.runner.timeout
453
+ horizon_params = self._horizon_params()
375
454
 
376
455
  def savilerow_horizon_callback(n: int, horizon_param_file: Path) -> Optional[Path]:
377
456
  """Execute Savile Row for a specific horizon."""
@@ -386,12 +465,7 @@ class SavileRowSolver(HorizonBasedSolver):
386
465
  # Remove any existing objective file before this horizon run
387
466
  self._prepare_objective_file(obj_file)
388
467
 
389
- # Add Savile Row specific constraint
390
- try:
391
- with open(horizon_param_file, 'a') as f:
392
- f.write(f"letting noSteps be {n}\n")
393
- except Exception as e:
394
- self.logger.error(f"Failed to add noSteps constraint: {e}")
468
+ if not self._write_horizon(horizon_param_file, n, horizon_params):
395
469
  return None
396
470
 
397
471
  # Build Savile Row command for this horizon using helper method
@@ -407,7 +481,8 @@ class SavileRowSolver(HorizonBasedSolver):
407
481
  f"solving-horizon-{n}",
408
482
  savilerow_cmd,
409
483
  expected_outputs=[str(expected_solution)],
410
- horizon_number=n
484
+ horizon_number=n,
485
+ timeout=self._horizon_watchdog(horizon_timeout)
411
486
  )
412
487
 
413
488
  # Record Savile Row's tailoring vs solver time split from the .info
@@ -454,13 +529,24 @@ class SavileRowSolver(HorizonBasedSolver):
454
529
  return horizon_search(
455
530
  self.runner, param_file, startat, savilerow_horizon_callback, self.logger,
456
531
  stopat=stopat,
457
- extra_constraints_callback=self._add_objective_constraint if (is_opt_model and continue_scan) else None
532
+ extra_constraints_callback=self._add_objective_constraint if (is_opt_model and continue_scan) else None,
533
+ horizon_params=horizon_params
458
534
  )
459
535
 
460
536
 
461
537
  class ConjureSolver(HorizonBasedSolver):
462
538
  """Solver implementation for Conjure (Essence to EPrime to solution)."""
463
539
 
540
+ DEFAULT_HORIZON_PARAMS = ('maxMoves', 'horizon')
541
+
542
+ def _default_solver_name(self) -> str:
543
+ """The Savile Row entry the ``conjure`` config names, else the legacy
544
+ name (see :meth:`ModelRegistry.get_conjure_savilerow`)."""
545
+ try:
546
+ return global_state.model_registry.get_conjure_savilerow()
547
+ except Exception:
548
+ return LEGACY_CONJURE_SAVILEROW
549
+
464
550
  def solve(self, param_file: Path, **kwargs) -> Optional[Path]:
465
551
  """
466
552
  Execute Conjure solver with horizon search loop for Essence models.
@@ -470,6 +556,10 @@ class ConjureSolver(HorizonBasedSolver):
470
556
  - model_path: Path to Essence model file (required)
471
557
  - startat: Starting horizon value (default: 1)
472
558
  - stopat: Highest horizon to try (default: 100)
559
+ - backstop: kill each horizon at savilerow_backstop of its
560
+ budget rather than at the runner's timeout, so Savile Row's
561
+ own limits fire first and its statistics survive
562
+ (default: False)
473
563
  Returns:
474
564
  Path to solution file if successful, None otherwise
475
565
  """
@@ -480,6 +570,7 @@ class ConjureSolver(HorizonBasedSolver):
480
570
 
481
571
  startat = kwargs.get('startat', 1)
482
572
  stopat = kwargs.get('stopat', 100)
573
+ self._backstop = bool(kwargs.get('backstop', False))
483
574
 
484
575
  # Step 1: Modelling - create EPrime from Essence
485
576
  epm_path = self._do_conjure_modelling(str(model_path))
@@ -493,19 +584,14 @@ class ConjureSolver(HorizonBasedSolver):
493
584
  # Track total elapsed time for timeout management
494
585
  total_start_time = time.time()
495
586
  original_timeout = self.runner.timeout
587
+ horizon_params = self._horizon_params()
496
588
 
497
589
  def conjure_horizon_callback(n: int, horizon_param_file: Path) -> Optional[Path]:
498
590
  """Execute Conjure solve and translate for a specific horizon."""
499
591
  # Calculate remaining timeout for this horizon step
500
592
  horizon_timeout = calculate_horizon_timeout(total_start_time, original_timeout, n, self.logger)
501
593
 
502
- # Add Conjure specific constraints
503
- try:
504
- with open(horizon_param_file, 'a') as f:
505
- f.write(f"letting maxMoves be {n}\n")
506
- f.write(f"letting horizon be {n}\n")
507
- except Exception as e:
508
- self.logger.error(f"Failed to add horizon constraints: {e}")
594
+ if not self._write_horizon(horizon_param_file, n, horizon_params):
509
595
  return None
510
596
 
511
597
  # Execute the solve and translate pipeline
@@ -541,7 +627,8 @@ class ConjureSolver(HorizonBasedSolver):
541
627
  self.runner, param_file, startat, conjure_horizon_callback, self.logger,
542
628
  min_horizon=2,
543
629
  stopat=stopat,
544
- extra_constraints_callback=self._add_objective_constraint if (is_opt_model and continue_scan) else None
630
+ extra_constraints_callback=self._add_objective_constraint if (is_opt_model and continue_scan) else None,
631
+ horizon_params=horizon_params
545
632
  )
546
633
 
547
634
  def _do_conjure_modelling(self, model_path: str) -> Optional[Path]:
@@ -629,7 +716,8 @@ class ConjureSolver(HorizonBasedSolver):
629
716
  f"solving-horizon-{n}",
630
717
  savilerow_cmd,
631
718
  expected_outputs=[str(eps_file)],
632
- horizon_number=n
719
+ horizon_number=n,
720
+ timeout=self._horizon_watchdog(horizon_timeout)
633
721
  )
634
722
 
635
723
  # Record Savile Row's tailoring vs solver time split. Here Savile Row is