solver-support 0.1.1__tar.gz → 0.2.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 (44) hide show
  1. {solver_support-0.1.1/solver_support.egg-info → solver_support-0.2.1}/PKG-INFO +1 -1
  2. {solver_support-0.1.1 → solver_support-0.2.1}/docs/timing-model.md +39 -0
  3. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support/__init__.py +1 -1
  4. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support/command_builders.py +149 -35
  5. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support/solver_horizon.py +17 -3
  6. {solver_support-0.1.1 → solver_support-0.2.1/solver_support.egg-info}/PKG-INFO +1 -1
  7. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support.egg-info/SOURCES.txt +1 -0
  8. solver_support-0.2.1/tests/fixtures/models.yaml +205 -0
  9. solver_support-0.2.1/tests/test_command_builders.py +203 -0
  10. solver_support-0.1.1/tests/fixtures/models.yaml +0 -57
  11. {solver_support-0.1.1 → solver_support-0.2.1}/LICENSE +0 -0
  12. {solver_support-0.1.1 → solver_support-0.2.1}/MANIFEST.in +0 -0
  13. {solver_support-0.1.1 → solver_support-0.2.1}/README.md +0 -0
  14. {solver_support-0.1.1 → solver_support-0.2.1}/docs/injection-api.md +0 -0
  15. {solver_support-0.1.1 → solver_support-0.2.1}/docs/solver-runner.md +0 -0
  16. {solver_support-0.1.1 → solver_support-0.2.1}/pyproject.toml +0 -0
  17. {solver_support-0.1.1 → solver_support-0.2.1}/setup.cfg +0 -0
  18. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support/file_management.py +0 -0
  19. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support/global_state.py +0 -0
  20. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support/horizon_utils.py +0 -0
  21. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support/model_registry.py +0 -0
  22. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support/objective.py +0 -0
  23. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support/run_summary.py +0 -0
  24. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support/solver_base.py +0 -0
  25. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support/solver_enhsp.py +0 -0
  26. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support/solver_fd.py +0 -0
  27. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support/solver_runner.py +0 -0
  28. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support/solver_stats.py +0 -0
  29. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support/solver_upfplan.py +0 -0
  30. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support/utilities.py +0 -0
  31. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support.egg-info/dependency_links.txt +0 -0
  32. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support.egg-info/not-zip-safe +0 -0
  33. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support.egg-info/requires.txt +0 -0
  34. {solver_support-0.1.1 → solver_support-0.2.1}/solver_support.egg-info/top_level.txt +0 -0
  35. {solver_support-0.1.1 → solver_support-0.2.1}/tests/conftest.py +0 -0
  36. {solver_support-0.1.1 → solver_support-0.2.1}/tests/test_helpers.py +0 -0
  37. {solver_support-0.1.1 → solver_support-0.2.1}/tests/test_horizon_utils.py +0 -0
  38. {solver_support-0.1.1 → solver_support-0.2.1}/tests/test_import.py +0 -0
  39. {solver_support-0.1.1 → solver_support-0.2.1}/tests/test_run_summary.py +0 -0
  40. {solver_support-0.1.1 → solver_support-0.2.1}/tests/test_run_summary_success.py +0 -0
  41. {solver_support-0.1.1 → solver_support-0.2.1}/tests/test_solution_naming.py +0 -0
  42. {solver_support-0.1.1 → solver_support-0.2.1}/tests/test_solver_runner.py +0 -0
  43. {solver_support-0.1.1 → solver_support-0.2.1}/tests/test_solver_stats.py +0 -0
  44. {solver_support-0.1.1 → solver_support-0.2.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.1.1
3
+ Version: 0.2.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
@@ -85,6 +85,45 @@ fields. All times are seconds unless noted.
85
85
  - **Best-effort**: a hard timeout can kill Savile Row before it writes `.info`,
86
86
  leaving the split absent (`None`).
87
87
 
88
+ #### Backends
89
+
90
+ Savile Row drives five backends, chosen by a solver config's `backend:` key and
91
+ described by one row each in `CommandBuilderMixin.SAVILEROW_BACKENDS`. A config
92
+ with no `backend:` key is the SAT arm, which is what every pre-existing config
93
+ relies on. The row supplies three things that vary and nothing that does not; the
94
+ arg-set key is *derived* as `<backend>_args`, so it needs no row.
95
+
96
+ | `backend:` | arg-set key | solver's own time limit | binary flag |
97
+ |---|---|---|---|
98
+ | *(absent)* / `sat` | `sat_args` | `--time=N` (s) | `-satsolver-bin` |
99
+ | `minion` | `minion_args` | *(none)* | `-minion-bin` |
100
+ | `ortools` | `ortools_args` | `--time_limit=N` (s) | `-fzn-bin` |
101
+ | `gecode` | `gecode_args` | `-time N` (**ms**) | `-gecode-bin` |
102
+ | `chuffed` | `chuffed_args` | `--time-out N` (**ms**) | `-chuffed-bin` |
103
+
104
+ Savile Row's own `-timelimit` is always added, always in seconds, and covers
105
+ translation plus solving. The solver's limit is passed through `-solver-options`
106
+ in addition, so the solver stops itself and reports rather than being killed with
107
+ nothing to show. Repeated `-solver-options` accumulate, so a tune preset may
108
+ carry its own without displacing the timeout.
109
+
110
+ **The units are a trap.** Gecode and Chuffed take milliseconds and neither says
111
+ so in its flag name. An unscaled value does not fail loudly — a 30s budget
112
+ becomes 30ms and the solver gives up instantly, which reads as a hard instance.
113
+ `timeout_scale` in the table is what converts; a config overriding
114
+ `solver_timeout_option` changes the flag's *name* only, since the unit belongs to
115
+ the solver rather than to the spelling.
116
+
117
+ **Only the SAT arm records intermediate objectives.** It is the one configuration
118
+ where Savile Row runs the optimisation loop itself; every other backend optimises
119
+ internally, leaving nothing for `-record-intermediate-objective-values` to see.
120
+ So for a non-SAT backend `_generate_objective_file_path` returns `None` and no
121
+ `.obj` file is named or cleared, and `_check_continue_scan` falls back to the raw
122
+ objective read out of the *solution* by the injected extractor. A consumer that
123
+ wires only the objective-file route will find continue-scan stops at the first
124
+ solving horizon on those backends instead of improving — wire
125
+ `set_raw_objective_from_solution` to use them for optimisation models.
126
+
88
127
  ### Fast Downward / SymK (PDDL models)
89
128
 
90
129
  - **Shape**: not a horizon scan — one `solving` phase whose A\* (blind) / symbolic
@@ -13,7 +13,7 @@ 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.1.1"
16
+ __version__ = "0.2.1"
17
17
 
18
18
  # --- model registry (loader + command builders + packaging injection) ---
19
19
  from .model_registry import (
@@ -24,6 +24,84 @@ from .model_registry import ModelRegistryError
24
24
  class CommandBuilderMixin:
25
25
  """The ``build_*_command`` methods mixed into :class:`ModelRegistry`."""
26
26
 
27
+ #: The Savile Row backends, one row each. Everything the argv assembler needs
28
+ #: to know about a backend lives here, so adding one is a single entry plus
29
+ #: its ``<backend>_args`` presets in the config - not an edit spread across an
30
+ #: arg-set lookup, a binary flag and a timeout dialect. The arg-set key is
31
+ #: *derived* (``<backend>_args``) rather than tabulated, so it needs no row.
32
+ #:
33
+ #: ``sat`` is the implicit default: a config with no ``backend:`` key is the
34
+ #: kissat SAT backend every pre-existing config relies on.
35
+ #:
36
+ #: ``timeout_option`` is the *solver's own* time limit, handed to it through
37
+ #: Savile Row's ``-solver-options``; Savile Row's own ``-timelimit`` (covering
38
+ #: translation plus solving) is added for every backend regardless. Giving the
39
+ #: solver its own budget means it stops itself and reports, rather than being
40
+ #: killed with nothing to show.
41
+ #:
42
+ #: ``timeout_scale`` converts the seconds this API speaks into the unit that
43
+ #: solver expects. Gecode and Chuffed both want milliseconds and neither says
44
+ #: so in its flag name, which makes the mistake silent rather than loud: a 30s
45
+ #: budget passed unscaled becomes 30ms, and the solver gives up instantly
46
+ #: looking for all the world like a hard instance.
47
+ SAVILEROW_BACKENDS = {
48
+ 'sat': {
49
+ 'bin_option': '-satsolver-bin',
50
+ 'timeout_option': '--time={value}',
51
+ 'timeout_scale': 1,
52
+ # Savile Row drives the optimisation loop itself when the backend is a
53
+ # SAT solver, so it can record each improving objective as it goes.
54
+ # Every other backend optimises internally, leaving SR nothing to see.
55
+ 'records_objectives': True,
56
+ },
57
+ 'minion': {
58
+ 'bin_option': '-minion-bin',
59
+ # Minion takes no time limit of its own here: -timelimit is enough.
60
+ 'timeout_option': None,
61
+ 'timeout_scale': 1,
62
+ 'records_objectives': False,
63
+ },
64
+ 'ortools': {
65
+ # SR calls OR-Tools through its "standard FlatZinc" route, so the
66
+ # generic -fzn-bin names the binary; Gecode and Chuffed have their own.
67
+ 'bin_option': '-fzn-bin',
68
+ 'timeout_option': '--time_limit={value}', # an Abseil flag, seconds
69
+ 'timeout_scale': 1,
70
+ 'records_objectives': False,
71
+ },
72
+ 'gecode': {
73
+ 'bin_option': '-gecode-bin',
74
+ 'timeout_option': '-time {value}', # "time (in ms) cutoff"
75
+ 'timeout_scale': 1000,
76
+ 'records_objectives': False,
77
+ },
78
+ 'chuffed': {
79
+ 'bin_option': '-chuffed-bin',
80
+ # Named --time-out but documented "Time out in milliseconds".
81
+ 'timeout_option': '--time-out {value}',
82
+ 'timeout_scale': 1000,
83
+ 'records_objectives': False,
84
+ },
85
+ }
86
+
87
+ def records_intermediate_objectives(self, solver_name: str) -> bool:
88
+ """Whether this solver's backend writes the intermediate-objective file.
89
+
90
+ True only for Savile Row's SAT arm, which is the one configuration where
91
+ Savile Row runs the optimisation loop and can report improving objectives
92
+ as it finds them. Callers use this to avoid naming (and clearing) a file
93
+ that will never be written; with no such file, continue-scan falls back to
94
+ the injected solution extractor. Resolves through
95
+ :meth:`get_tune_backend`, so the Conjure path answers for the kissat
96
+ solve it delegates to, and non-savilerow solvers answer False.
97
+ """
98
+ backend_solver = self.get_tune_backend(solver_name)
99
+ if self.get_solver_type(backend_solver) != 'savilerow':
100
+ return False
101
+ backend = self.get_solver_config(backend_solver).get('backend', 'sat')
102
+ spec = self.SAVILEROW_BACKENDS.get(backend)
103
+ return bool(spec and spec['records_objectives'])
104
+
27
105
  def build_savilerow_command(self, model_path: str, param_path: str,
28
106
  use_srjava: bool = False,
29
107
  timeout: Optional[int] = None,
@@ -46,31 +124,50 @@ class CommandBuilderMixin:
46
124
  objective_file_path: path for intermediate objective values file (default: obj.txt)
47
125
  extra_options: additional command line options to pass to Savile Row
48
126
  solver_name: name of the solver configuration to use (default: 'kissat').
49
- The backend (kissat vs minion) is a property of this config:
50
- a config with ``backend: minion`` uses its tune presets'
51
- minion_args and Minion's timeout dialect, otherwise the kissat
52
- SAT arg-set is used.
127
+ The backend is a property of this config, named by its
128
+ ``backend:`` key and described by ``SAVILEROW_BACKENDS``: it
129
+ selects the arg-set key, the solver's own timeout dialect, and
130
+ the flag that names its binary. No ``backend:`` key means the
131
+ kissat SAT backend, which is what every pre-existing config
132
+ relies on.
53
133
  tune: name of a ``tune:`` preset to apply instead of the config's
54
134
  ``default_tune`` (kissat: 'safe'/'bare'/'srdefault'/'gac';
55
- minion: 'bare'). The preset supplies this backend's arg-set
56
- (sat_args for kissat, minion_args for minion). A name that is
57
- unknown or lacks this backend's arg key falls back to
58
- ``default_tune``. This is the savilerow arm of the same ``--tune``
59
- dial fd uses for its search presets.
135
+ minion: 'bare'). The preset supplies this backend's arg-set,
136
+ under the key ``<backend>_args`` - sat_args, minion_args,
137
+ ortools_args, and so on. A name that is unknown or lacks this
138
+ backend's arg key falls back to ``default_tune``. This is the
139
+ savilerow arm of the same ``--tune`` dial fd uses for its search
140
+ presets.
60
141
 
61
142
  Returns:
62
143
  Complete command as list of strings.
63
144
 
64
145
  Raises:
65
- ModelRegistryError: If savilerow solver not found in config, or the
66
- resolved tune has no arg-set for this backend.
146
+ ModelRegistryError: If savilerow solver not found in config, if the
147
+ config names a backend that is not in ``SAVILEROW_BACKENDS``, or
148
+ if the resolved tune has no arg-set for this backend.
67
149
  """
68
150
  solver_config = self.get_solver_config(solver_name)
69
151
 
70
- # The backend solver (kissat SAT vs Minion CP) is declared by the config,
71
- # not passed in: `--solver minion` selects the minion backend config. This
72
- # replaced the old cross-cutting `--minion` boolean.
73
- use_minion = solver_config.get('backend') == 'minion'
152
+ # The backend solver is declared by the config, not passed in: `--solver
153
+ # minion` selects the minion backend config. This replaced the old
154
+ # cross-cutting `--minion` boolean. Absent means the kissat SAT backend,
155
+ # which is what every pre-existing config relies on.
156
+ #
157
+ # An unrecognised name is an error rather than a fallback. The lenient
158
+ # `_effective_tune` lookup below can afford to shrug at a typo because a
159
+ # wrong tune still runs the right solver; a typo'd backend would silently
160
+ # build the SAT arm's argv - a run that succeeds while comparing the wrong
161
+ # solver, which is worse than no run at all. Same reasoning as
162
+ # `is_valid_tune`, applied one level up.
163
+ backend = solver_config.get('backend', 'sat')
164
+ spec = self.SAVILEROW_BACKENDS.get(backend)
165
+ if spec is None:
166
+ known = ', '.join(sorted(self.SAVILEROW_BACKENDS))
167
+ raise ModelRegistryError(
168
+ f"Solver '{solver_name}' declares unknown backend '{backend}' "
169
+ f"(known backends: {known})"
170
+ )
74
171
 
75
172
  # Choose command variant
76
173
  if use_srjava:
@@ -90,12 +187,14 @@ class CommandBuilderMixin:
90
187
  self._savilerow_native_warning_shown = True
91
188
  command = solver_config['command']
92
189
 
93
- # Choose the argument set from the active tune preset. kissat presets
94
- # carry sat_args, minion presets carry minion_args; a tune that is
95
- # unknown or lacks this backend's key falls back to default_tune (the
96
- # lenient behaviour the fd builder uses too). The model-default tune is
97
- # resolved by the caller, so `tune` here is already the effective name.
98
- arg_key = 'minion_args' if use_minion else 'sat_args'
190
+ # Choose the argument set from the active tune preset. Each backend reads
191
+ # its own key, named after it: sat_args, minion_args, ortools_args, and so
192
+ # on. Deriving the name rather than tabulating it is what lets a new
193
+ # backend arrive without touching this line. A tune that is unknown or
194
+ # lacks this backend's key falls back to default_tune (the lenient
195
+ # behaviour the fd builder uses too). The model-default tune is resolved
196
+ # by the caller, so `tune` here is already the effective name.
197
+ arg_key = f'{backend}_args'
99
198
  tunes = solver_config.get('tune', {})
100
199
  tune_name = self._effective_tune(solver_config, tune, arg_key)
101
200
  preset = tunes.get(tune_name, {})
@@ -104,26 +203,41 @@ class CommandBuilderMixin:
104
203
  f"Solver '{solver_name}' tune '{tune_name}' has no {arg_key}"
105
204
  )
106
205
  args = preset[arg_key].copy()
107
- if not use_minion:
108
- # Add objective recording arguments only when needed
109
- if objective_file_path is not None:
110
- args.extend(["-record-intermediate-objective-values", objective_file_path])
206
+ if spec['records_objectives'] and objective_file_path is not None:
207
+ # Only the SAT arm has intermediate objectives to record, because it
208
+ # is the only one where Savile Row runs the optimisation loop rather
209
+ # than the solver. Asking any other backend for them names a file that
210
+ # is never written.
211
+ args.extend(["-record-intermediate-objective-values", objective_file_path])
212
+
213
+ # Name the backend's binary only when the config pins one. Each backend
214
+ # has its own flag for this (-fzn-bin, -gecode-bin, -minion-bin, ...), and
215
+ # left unset Savile Row's default applies - so the registry never has to
216
+ # bake one machine's build into a shared config.
217
+ solver_bin = solver_config.get('solver_bin')
218
+ if solver_bin:
219
+ args.extend([spec['bin_option'], solver_bin])
111
220
 
112
221
  # Add timeout arguments
113
222
  if timeout is None:
114
223
  timeout = solver_config['default_timeout']
115
224
 
116
- # Different timeout handling for minion vs SAT solver
117
225
  if timeout is not None:
118
- if use_minion:
119
- # For minion, just use -timelimit (no solver-options needed)
120
- args.extend(["-timelimit", str(timeout)])
121
- else:
122
- # For SAT solver (kissat), use both -solver-options and -timelimit
123
- args.extend([
124
- "-solver-options", f"--time={timeout}",
125
- "-timelimit", str(timeout)
126
- ])
226
+ # The solver's own limit, in whatever flag and unit that solver
227
+ # speaks. A config may override the dialect (or set it to null to
228
+ # suppress it) without the core having to learn a new backend.
229
+ option = solver_config.get('solver_timeout_option', spec['timeout_option'])
230
+ if option:
231
+ scale = spec['timeout_scale']
232
+ # Left untouched when the solver already speaks seconds, so those
233
+ # argvs keep their exact previous form (--time=45, never 45.0).
234
+ value = timeout if scale == 1 else int(timeout * scale)
235
+ args.extend(["-solver-options", option.format(value=value)])
236
+ # Savile Row's own limit covers translation plus solving and is always
237
+ # in seconds. Always added, so a backend with no limit of its own is
238
+ # still bounded - and one that has its own stops itself and reports,
239
+ # rather than being killed with nothing to show.
240
+ args.extend(["-timelimit", str(timeout)])
127
241
 
128
242
  # Add extra options if provided
129
243
  if extra_options:
@@ -32,8 +32,20 @@ class HorizonBasedSolver(Solver):
32
32
  self._stop_at_first = False # regime: first stops the scan at the first solving horizon
33
33
  self.solver_name = solver_name # the backend config (kissat/minion/...)
34
34
 
35
- def _generate_objective_file_path(self, n: int) -> Path:
36
- """Generate the objective file path for a given horizon."""
35
+ def _generate_objective_file_path(self, n: int) -> Optional[Path]:
36
+ """Generate the objective file path for a given horizon, or None.
37
+
38
+ None when the active backend does not write one. Only Savile Row's SAT
39
+ arm runs the optimisation loop that emits intermediate objectives, so for
40
+ a Minion or FlatZinc backend the file would be named, cleared, and then
41
+ never appear. Returning None here rather than gating each call site keeps
42
+ the three of them identical, and leaves continue-scan to read the
43
+ objective out of the solution via the injected extractor - which is the
44
+ only route those backends have.
45
+ """
46
+ if not global_state.model_registry.records_intermediate_objectives(
47
+ self.solver_name):
48
+ return None
37
49
  return ObjectiveFileManager.generate_objective_path(
38
50
  self.runner.summary.instance,
39
51
  self.runner.summary.solver,
@@ -43,8 +55,10 @@ class HorizonBasedSolver(Solver):
43
55
  tune=self.runner.summary.tune_slot
44
56
  )
45
57
 
46
- def _prepare_objective_file(self, obj_file: Path) -> None:
58
+ def _prepare_objective_file(self, obj_file: Optional[Path]) -> None:
47
59
  """Prepare the objective file by removing any existing file."""
60
+ if obj_file is None:
61
+ return
48
62
  ObjectiveFileManager.prepare_objective_file(obj_file, self.logger)
49
63
 
50
64
  def _parse_objective_file(self, obj_file: Path) -> Optional[int]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solver-support
3
- Version: 0.1.1
3
+ Version: 0.2.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
@@ -28,6 +28,7 @@ solver_support.egg-info/not-zip-safe
28
28
  solver_support.egg-info/requires.txt
29
29
  solver_support.egg-info/top_level.txt
30
30
  tests/conftest.py
31
+ tests/test_command_builders.py
31
32
  tests/test_helpers.py
32
33
  tests/test_horizon_utils.py
33
34
  tests/test_import.py
@@ -0,0 +1,205 @@
1
+ # Synthetic model registry for the core's own tests.
2
+ #
3
+ # The core ships without a registry -- a consumer injects the path to its own
4
+ # models.yaml (see docs/injection-api.md). Tests that reach the registry wire
5
+ # THIS file instead, so the core is exercised against a config it owns rather
6
+ # than against whichever consumer happens to be installed beside it. Keep it
7
+ # minimal: add only what a test actually reads, and keep the shape faithful to a
8
+ # real consumer config (that shape is the contract the loader and the accessors
9
+ # implement).
10
+
11
+ models:
12
+ # A constructed UPF model: built in Python, solved through the UPF driver.
13
+ upf:
14
+ path: upf.py
15
+ solver: upffd
16
+ detection_type: upf
17
+ format-name: upf
18
+ proves-unsat: true
19
+ description: constructed UPF model, solved by Fast Downward via UPF
20
+
21
+ # An optimisation model whose objective is recorded in the solution file, so
22
+ # the horizon scan compares objectives between horizons.
23
+ scored:
24
+ path: models/Scored.eprime
25
+ solver: kissat
26
+ detection_type: scored
27
+ format-name: compact
28
+ objective-in-solution: true
29
+ description: optimisation model carrying its objective in the solution
30
+
31
+ solvers:
32
+ upffd:
33
+ # Fast Downward through the Unified Planning Framework. The engine and the
34
+ # FD search config are BOTH resolved from the effective --tune, which is
35
+ # what UpfPlanSolver turns into `-s <engine>` and `--fd-search <search>`.
36
+ solver_type: upfplan
37
+ command: upfplan
38
+ extensions:
39
+ - .pddl
40
+ default_timeout: 600
41
+ engine_name: fast-downward
42
+ default_tune: optimal
43
+ tune:
44
+ optimal:
45
+ fd_search: "astar(blind())"
46
+ description: cost-optimal blind A*
47
+ satisficing:
48
+ fd_search: "lazy_greedy([ff()])"
49
+ description: satisficing greedy best-first with FF
50
+
51
+ kissat:
52
+ solver_type: savilerow
53
+ command: savilerow
54
+ extensions:
55
+ - .eprime
56
+ default_timeout: 600
57
+ description: SAT backend driven through Savile Row
58
+
59
+ # The Savile Row backends, with tune presets, for the command builder tests.
60
+ # Separate from `kissat` above so that adding them cannot perturb the tests
61
+ # which rely on that entry having no presets.
62
+ #
63
+ # One entry per backend, plus two that pin a solver binary. Two, because the
64
+ # flag that names the binary differs per backend (-fzn-bin vs -gecode-bin), so
65
+ # a single pinned entry could not tell a per-backend flag apart from a
66
+ # hardcoded one.
67
+ srsat:
68
+ # No `backend:` key at all -- the shape every pre-existing SAT config has.
69
+ solver_type: savilerow
70
+ command: savilerow
71
+ extensions:
72
+ - .eprime
73
+ default_timeout: null
74
+ default_tune: default
75
+ tune:
76
+ default:
77
+ sat_args:
78
+ - "-run-solver"
79
+ - "-sat"
80
+ description: SAT backend
81
+
82
+ srminion:
83
+ solver_type: savilerow
84
+ command: savilerow
85
+ extensions:
86
+ - .eprime
87
+ default_timeout: null
88
+ backend: minion
89
+ default_tune: default
90
+ tune:
91
+ default:
92
+ minion_args:
93
+ - "-run-solver"
94
+ description: Minion backend
95
+
96
+ srortools:
97
+ solver_type: savilerow
98
+ command: savilerow
99
+ extensions:
100
+ - .eprime
101
+ default_timeout: null
102
+ backend: ortools
103
+ default_tune: default
104
+ tune:
105
+ default:
106
+ ortools_args:
107
+ - "-run-solver"
108
+ - "-or-tools"
109
+ description: OR-Tools CP-SAT backend
110
+
111
+ srgecode:
112
+ solver_type: savilerow
113
+ command: savilerow
114
+ extensions:
115
+ - .eprime
116
+ default_timeout: null
117
+ backend: gecode
118
+ default_tune: default
119
+ tune:
120
+ default:
121
+ gecode_args:
122
+ - "-run-solver"
123
+ - "-gecode"
124
+ description: Gecode FlatZinc backend
125
+
126
+ srchuffed:
127
+ solver_type: savilerow
128
+ command: savilerow
129
+ extensions:
130
+ - .eprime
131
+ default_timeout: null
132
+ backend: chuffed
133
+ default_tune: default
134
+ tune:
135
+ default:
136
+ chuffed_args:
137
+ - "-run-solver"
138
+ - "-chuffed"
139
+ description: Chuffed FlatZinc backend
140
+
141
+ srortoolsbin:
142
+ # Same as srortools, but pinning the solver binary by name.
143
+ solver_type: savilerow
144
+ command: savilerow
145
+ extensions:
146
+ - .eprime
147
+ default_timeout: null
148
+ backend: ortools
149
+ solver_bin: fzn-ortools
150
+ default_tune: default
151
+ tune:
152
+ default:
153
+ ortools_args:
154
+ - "-run-solver"
155
+ - "-or-tools"
156
+ description: OR-Tools CP-SAT backend, binary pinned
157
+
158
+ srgecodebin:
159
+ # The same pin on a different backend, which must reach a different flag.
160
+ # Also carries a timeout-dialect override, so one entry covers both config
161
+ # escape hatches: the flag name is the config's to change, while the unit
162
+ # stays the solver's (still milliseconds here).
163
+ solver_type: savilerow
164
+ command: savilerow
165
+ extensions:
166
+ - .eprime
167
+ default_timeout: null
168
+ backend: gecode
169
+ solver_bin: fzn-gecode-6
170
+ solver_timeout_option: "-overall-time {value}"
171
+ default_tune: default
172
+ tune:
173
+ default:
174
+ gecode_args:
175
+ - "-run-solver"
176
+ - "-gecode"
177
+ description: Gecode backend, binary pinned
178
+
179
+ srbadbackend:
180
+ # A wrong backend name: must be rejected, not silently run as SAT.
181
+ #
182
+ # `or-tools` rather than an obviously-mangled string, because this is the
183
+ # mistake someone would actually make: Savile Row's flag is `-or-tools`, so a
184
+ # config author copying the name out of the documentation writes the
185
+ # hyphenated form. A test that only rejects gibberish would not have caught
186
+ # the near-miss that matters.
187
+ solver_type: savilerow
188
+ command: savilerow
189
+ extensions:
190
+ - .eprime
191
+ default_timeout: null
192
+ backend: or-tools
193
+ default_tune: default
194
+ tune:
195
+ default:
196
+ # Carries sat_args too, which is what makes the silent-fallback failure
197
+ # possible at all: without them the mistake would surface as a missing
198
+ # arg-set rather than as a wrong-but-working command line.
199
+ sat_args:
200
+ - "-run-solver"
201
+ - "-sat"
202
+ ortools_args:
203
+ - "-run-solver"
204
+ - "-or-tools"
205
+ description: deliberately misspelled backend
@@ -0,0 +1,203 @@
1
+ """Backend dispatch in ``build_savilerow_command``.
2
+
3
+ Savile Row drives five backends: a SAT solver, Minion, and three FlatZinc solvers
4
+ (OR-Tools CP-SAT, Gecode, Chuffed). Which one a config selects, which tune key
5
+ holds its arguments, how its time limit is spelled and in what unit, and which
6
+ flag names its binary all hang off one ``backend:`` key, so the dispatch is worth
7
+ pinning in one place -- including the two backwards-compatibility properties a
8
+ new backend could quietly break: a config with NO ``backend:`` key must still
9
+ behave as the SAT backend, and Minion's argv must not move.
10
+
11
+ The unit conversions are the reason several of these tests exist. Gecode and
12
+ Chuffed both take their limit in milliseconds and neither says so in the flag
13
+ name, so an unscaled value is not a crash but a solver that gives up in 30ms and
14
+ reports the instance as hard.
15
+ """
16
+
17
+ import pytest
18
+
19
+ from solver_support.model_registry import ModelRegistry, ModelRegistryError
20
+
21
+ MODEL = "model.eprime"
22
+ PARAM = "instance.param"
23
+
24
+ #: Every backend the builder knows, with the arg it is expected to emit.
25
+ ALL_BACKENDS = [
26
+ ("srsat", "-sat"),
27
+ ("srminion", "-run-solver"),
28
+ ("srortools", "-or-tools"),
29
+ ("srgecode", "-gecode"),
30
+ ("srchuffed", "-chuffed"),
31
+ ]
32
+
33
+
34
+ def build(registry, solver, **kwargs):
35
+ return registry.build_savilerow_command(MODEL, PARAM, solver_name=solver, **kwargs)
36
+
37
+
38
+ def solver_options(cmd):
39
+ """The values of every ``-solver-options`` flag in a command."""
40
+ return [cmd[i + 1] for i, a in enumerate(cmd) if a == "-solver-options"]
41
+
42
+
43
+ @pytest.fixture
44
+ def registry(fixture_registry):
45
+ return ModelRegistry()
46
+
47
+
48
+ class TestBackendArgSets:
49
+ """Each backend takes its arguments from its own ``<backend>_args`` key."""
50
+
51
+ @pytest.mark.parametrize("solver,expected", ALL_BACKENDS)
52
+ def test_backend_uses_its_own_arg_set(self, registry, solver, expected):
53
+ assert expected in build(registry, solver)
54
+
55
+ def test_sat_config_without_backend_key_still_uses_sat_args(self, registry):
56
+ # The regression that matters: every pre-existing SAT config omits
57
+ # `backend:` entirely, so the default must remain the SAT arm.
58
+ cmd = build(registry, "srsat")
59
+ assert "-sat" in cmd
60
+ assert "-or-tools" not in cmd
61
+
62
+ def test_minion_argv_unchanged(self, registry):
63
+ cmd = build(registry, "srminion")
64
+ assert cmd == ["savilerow", "-run-solver", MODEL, PARAM]
65
+
66
+ def test_missing_arg_set_for_backend_raises(self, registry):
67
+ # `kissat` in the fixture config has no tune presets at all.
68
+ with pytest.raises(ModelRegistryError, match="sat_args"):
69
+ build(registry, "kissat")
70
+
71
+
72
+ class TestUnknownBackend:
73
+ """A misspelled backend is an error, not a quiet fallback to SAT."""
74
+
75
+ def test_unknown_backend_raises(self, registry):
76
+ # `srbadbackend` says `backend: or-tools` -- the hyphenated spelling of
77
+ # Savile Row's own flag, and so the near-miss a config author is most
78
+ # likely to write -- and carries sat_args, so the lenient path would have
79
+ # built a working SAT command line: a run that succeeds while measuring
80
+ # the wrong solver.
81
+ with pytest.raises(ModelRegistryError, match="unknown backend 'or-tools'"):
82
+ build(registry, "srbadbackend")
83
+
84
+ def test_error_lists_the_known_backends(self, registry):
85
+ with pytest.raises(ModelRegistryError, match="chuffed, gecode, minion"):
86
+ build(registry, "srbadbackend")
87
+
88
+
89
+ class TestTimeoutDialects:
90
+ """Each backend spells its own time limit, in its own unit."""
91
+
92
+ def test_sat_takes_seconds(self, registry):
93
+ assert solver_options(build(registry, "srsat", timeout=45)) == ["--time=45"]
94
+
95
+ def test_ortools_takes_seconds(self, registry):
96
+ assert solver_options(build(registry, "srortools", timeout=45)) == [
97
+ "--time_limit=45"]
98
+
99
+ def test_gecode_takes_milliseconds(self, registry):
100
+ assert solver_options(build(registry, "srgecode", timeout=45)) == [
101
+ "-time 45000"]
102
+
103
+ def test_chuffed_takes_milliseconds(self, registry):
104
+ assert solver_options(build(registry, "srchuffed", timeout=45)) == [
105
+ "--time-out 45000"]
106
+
107
+ def test_minion_takes_no_solver_option(self, registry):
108
+ cmd = build(registry, "srminion", timeout=45)
109
+ assert solver_options(cmd) == []
110
+ assert cmd[cmd.index("-timelimit") + 1] == "45"
111
+
112
+ @pytest.mark.parametrize("solver,_", ALL_BACKENDS)
113
+ def test_savilerow_timelimit_is_always_seconds(self, registry, solver, _):
114
+ # SR's own -timelimit covers translation plus solving and never scales,
115
+ # whatever unit the backend solver happens to speak.
116
+ cmd = build(registry, solver, timeout=45)
117
+ assert cmd[cmd.index("-timelimit") + 1] == "45"
118
+
119
+ @pytest.mark.parametrize("solver,_", ALL_BACKENDS)
120
+ def test_no_timeout_emits_no_limit(self, registry, solver, _):
121
+ cmd = build(registry, solver)
122
+ assert "-timelimit" not in cmd
123
+ assert solver_options(cmd) == []
124
+
125
+ def test_config_may_override_the_dialect(self, registry):
126
+ # The flag name is the config's to change; the unit stays the solver's,
127
+ # so the override is still scaled to milliseconds.
128
+ assert solver_options(build(registry, "srgecodebin", timeout=45)) == [
129
+ "-overall-time 45000"]
130
+
131
+
132
+ class TestObjectiveRecording:
133
+ """Intermediate-objective recording is a SAT facility."""
134
+
135
+ def test_sat_records_intermediate_objectives(self, registry):
136
+ cmd = build(registry, "srsat", objective_file_path="obj.txt")
137
+ assert "-record-intermediate-objective-values" in cmd
138
+ assert cmd[cmd.index("-record-intermediate-objective-values") + 1] == "obj.txt"
139
+
140
+ @pytest.mark.parametrize("solver", ["srminion", "srortools", "srgecode",
141
+ "srchuffed"])
142
+ def test_other_backends_do_not_record(self, registry, solver):
143
+ # They optimise internally, so Savile Row is not running the loop and has
144
+ # no intermediate values to see.
145
+ cmd = build(registry, solver, objective_file_path="obj.txt")
146
+ assert "-record-intermediate-objective-values" not in cmd
147
+
148
+ @pytest.mark.parametrize("solver,expected", [
149
+ ("srsat", True),
150
+ ("kissat", True), # no `backend:` key -- still the SAT arm
151
+ ("srminion", False),
152
+ ("srortools", False),
153
+ ("srgecode", False),
154
+ ("srchuffed", False),
155
+ ("upffd", False), # not a savilerow solver at all
156
+ ])
157
+ def test_records_intermediate_objectives_query(self, registry, solver, expected):
158
+ # The horizon loop asks this before naming an objective file, so that it
159
+ # does not name and clear a file the backend will never write.
160
+ assert registry.records_intermediate_objectives(solver) is expected
161
+
162
+
163
+ class TestSolverBinary:
164
+ """The solver binary is named only when pinned, with that backend's flag."""
165
+
166
+ @pytest.mark.parametrize("solver,_", ALL_BACKENDS)
167
+ def test_omitted_when_unset(self, registry, solver, _):
168
+ # Leaving it to Savile Row's default keeps one machine's build out of a
169
+ # shared registry.
170
+ cmd = build(registry, solver)
171
+ assert not any(a.endswith("-bin") for a in cmd)
172
+
173
+ def test_ortools_uses_the_standard_flatzinc_flag(self, registry):
174
+ cmd = build(registry, "srortoolsbin")
175
+ assert cmd[cmd.index("-fzn-bin") + 1] == "fzn-ortools"
176
+
177
+ def test_gecode_uses_its_own_flag(self, registry):
178
+ # The point of the pair: a per-backend flag, not one hardcoded name.
179
+ cmd = build(registry, "srgecodebin")
180
+ assert "-fzn-bin" not in cmd
181
+ assert cmd[cmd.index("-gecode-bin") + 1] == "fzn-gecode-6"
182
+
183
+
184
+ class TestBackendTable:
185
+ """The table is the single place a backend is described."""
186
+
187
+ def test_every_backend_is_fully_specified(self):
188
+ required = {"bin_option", "timeout_option", "timeout_scale",
189
+ "records_objectives"}
190
+ for name, spec in ModelRegistry.SAVILEROW_BACKENDS.items():
191
+ assert set(spec) == required, name
192
+
193
+ def test_only_the_sat_arm_records_objectives(self):
194
+ recording = {n for n, s in ModelRegistry.SAVILEROW_BACKENDS.items()
195
+ if s["records_objectives"]}
196
+ assert recording == {"sat"}
197
+
198
+
199
+ @pytest.mark.parametrize("solver,_", ALL_BACKENDS)
200
+ def test_model_and_param_come_last(registry, solver, _):
201
+ """Savile Row takes the model and param as trailing positionals."""
202
+ cmd = build(registry, solver, timeout=10, objective_file_path="obj.txt")
203
+ assert cmd[-2:] == [MODEL, PARAM]
@@ -1,57 +0,0 @@
1
- # Synthetic model registry for the core's own tests.
2
- #
3
- # The core ships without a registry -- a consumer injects the path to its own
4
- # models.yaml (see docs/injection-api.md). Tests that reach the registry wire
5
- # THIS file instead, so the core is exercised against a config it owns rather
6
- # than against whichever consumer happens to be installed beside it. Keep it
7
- # minimal: add only what a test actually reads, and keep the shape faithful to a
8
- # real consumer config (that shape is the contract the loader and the accessors
9
- # implement).
10
-
11
- models:
12
- # A constructed UPF model: built in Python, solved through the UPF driver.
13
- upf:
14
- path: upf.py
15
- solver: upffd
16
- detection_type: upf
17
- format-name: upf
18
- proves-unsat: true
19
- description: constructed UPF model, solved by Fast Downward via UPF
20
-
21
- # An optimisation model whose objective is recorded in the solution file, so
22
- # the horizon scan compares objectives between horizons.
23
- scored:
24
- path: models/Scored.eprime
25
- solver: kissat
26
- detection_type: scored
27
- format-name: compact
28
- objective-in-solution: true
29
- description: optimisation model carrying its objective in the solution
30
-
31
- solvers:
32
- upffd:
33
- # Fast Downward through the Unified Planning Framework. The engine and the
34
- # FD search config are BOTH resolved from the effective --tune, which is
35
- # what UpfPlanSolver turns into `-s <engine>` and `--fd-search <search>`.
36
- solver_type: upfplan
37
- command: upfplan
38
- extensions:
39
- - .pddl
40
- default_timeout: 600
41
- engine_name: fast-downward
42
- default_tune: optimal
43
- tune:
44
- optimal:
45
- fd_search: "astar(blind())"
46
- description: cost-optimal blind A*
47
- satisficing:
48
- fd_search: "lazy_greedy([ff()])"
49
- description: satisficing greedy best-first with FF
50
-
51
- kissat:
52
- solver_type: savilerow
53
- command: savilerow
54
- extensions:
55
- - .eprime
56
- default_timeout: 600
57
- description: SAT backend driven through Savile Row
File without changes
File without changes
File without changes