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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {solver_support-0.1.1/solver_support.egg-info → solver_support-0.2.2}/PKG-INFO +1 -1
  2. {solver_support-0.1.1 → solver_support-0.2.2}/docs/timing-model.md +67 -6
  3. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/__init__.py +1 -1
  4. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/command_builders.py +149 -35
  5. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/model_registry.py +81 -4
  6. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/solver_horizon.py +42 -5
  7. {solver_support-0.1.1 → solver_support-0.2.2/solver_support.egg-info}/PKG-INFO +1 -1
  8. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support.egg-info/SOURCES.txt +3 -0
  9. solver_support-0.2.2/tests/fixtures/models.yaml +205 -0
  10. solver_support-0.2.2/tests/test_command_builders.py +203 -0
  11. solver_support-0.2.2/tests/test_registry_dump.py +93 -0
  12. solver_support-0.2.2/tests/test_savilerow_info_recording.py +127 -0
  13. solver_support-0.1.1/tests/fixtures/models.yaml +0 -57
  14. {solver_support-0.1.1 → solver_support-0.2.2}/LICENSE +0 -0
  15. {solver_support-0.1.1 → solver_support-0.2.2}/MANIFEST.in +0 -0
  16. {solver_support-0.1.1 → solver_support-0.2.2}/README.md +0 -0
  17. {solver_support-0.1.1 → solver_support-0.2.2}/docs/injection-api.md +0 -0
  18. {solver_support-0.1.1 → solver_support-0.2.2}/docs/solver-runner.md +0 -0
  19. {solver_support-0.1.1 → solver_support-0.2.2}/pyproject.toml +0 -0
  20. {solver_support-0.1.1 → solver_support-0.2.2}/setup.cfg +0 -0
  21. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/file_management.py +0 -0
  22. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/global_state.py +0 -0
  23. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/horizon_utils.py +0 -0
  24. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/objective.py +0 -0
  25. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/run_summary.py +0 -0
  26. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/solver_base.py +0 -0
  27. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/solver_enhsp.py +0 -0
  28. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/solver_fd.py +0 -0
  29. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/solver_runner.py +0 -0
  30. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/solver_stats.py +0 -0
  31. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/solver_upfplan.py +0 -0
  32. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support/utilities.py +0 -0
  33. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support.egg-info/dependency_links.txt +0 -0
  34. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support.egg-info/not-zip-safe +0 -0
  35. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support.egg-info/requires.txt +0 -0
  36. {solver_support-0.1.1 → solver_support-0.2.2}/solver_support.egg-info/top_level.txt +0 -0
  37. {solver_support-0.1.1 → solver_support-0.2.2}/tests/conftest.py +0 -0
  38. {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_helpers.py +0 -0
  39. {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_horizon_utils.py +0 -0
  40. {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_import.py +0 -0
  41. {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_run_summary.py +0 -0
  42. {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_run_summary_success.py +0 -0
  43. {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_solution_naming.py +0 -0
  44. {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_solver_runner.py +0 -0
  45. {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_solver_stats.py +0 -0
  46. {solver_support-0.1.1 → solver_support-0.2.2}/tests/test_solvers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solver-support
3
- Version: 0.1.1
3
+ Version: 0.2.2
4
4
  Summary: Generic Conjure / Savile Row / Fast Downward / SymK / ENHSP / UPF solver pipeline
5
5
  Author: András Salamon
6
6
  License-Expression: BSD-3-Clause
@@ -76,15 +76,76 @@ fields. All times are seconds unless noted.
76
76
  `solver_time`, attaching them to the matching phase. Recorded for *every*
77
77
  horizon, so intermediate UNSAT horizons carry the split too.
78
78
  - **The rest of the file**: those two are what the phase model needs, but the
79
- same `.info` carries the encoding size and search counters (`SATVars`,
80
- `SATClauses`, `SolverNodes`, `SolverSatisfiable`, ...).
81
- `SolverStatsExtractor.extract_savilerow_info(path)` returns every `Key:Value`
82
- pair with keys verbatim and values coerced to int/float, for a consumer
83
- reporting those columns; `extract_savilerow_info_times` is the renaming subset
84
- of it that the core itself uses.
79
+ same `.info` carries the encoding size, the search counters, and the outcome
80
+ flags. All of it is recorded verbatim on the phase under
81
+ `solver_stats['savilerow_info']`, so a consumer never has to go back to the
82
+ `.info` files — which matters because that would depend on the run's output
83
+ directory rather than its summary, and so stops working once those files are
84
+ cleaned up. `SolverStatsExtractor.extract_savilerow_info(path)` is the parser
85
+ (keys verbatim, values coerced to int/float, `{}` for a missing file);
86
+ `extract_savilerow_info_times` is the renaming subset that produces the two
87
+ fields above.
88
+ - **Nested, not flattened, and deliberately not curated.** `solver_stats` is the
89
+ core's normalised cross-solver vocabulary; `savilerow_info` is one solver's own
90
+ keys in its own spelling, so nesting keeps CamelCase off the snake_case
91
+ namespace. It is verbatim because the backends do not agree on which keys
92
+ exist — measured on one model:
93
+
94
+ | key | minion | ortools | gecode | chuffed | kissat |
95
+ |---|:-:|:-:|:-:|:-:|:-:|
96
+ | `SavileRowTotalTime` `SolverTotalTime` `SolverSatisfiable` `SolverTimeOut` `SavileRowTimeOut` `SavileRowClauseOut` | ✓ | ✓ | ✓ | ✓ | ✓ |
97
+ | `SolverNodes` | ✓ | — | ✓ | ✓ | ✓ |
98
+ | `SolverFailures` `SolverTotalWallTime` | — | ✓ | ✓ | ✓ | — |
99
+ | `SolverSetupTime` `SolverSolveTime` `SolverSolutionsFound` | ✓ | — | — | — | — |
100
+ | `SATVars` `SATClauses` `SolverMemOut` | — | — | — | — | ✓ |
101
+
102
+ Any allow-list would already be wrong, and would need extending for every new
103
+ backend or Savile Row version. Note `SolverSatisfiable` is one of the six every
104
+ backend emits: it distinguishes a **refuted horizon** from one that gave up —
105
+ which is *not* problem-level unsat, since a bounded model cannot prove that
106
+ (see `hlim`, above).
85
107
  - **Best-effort**: a hard timeout can kill Savile Row before it writes `.info`,
86
108
  leaving the split absent (`None`).
87
109
 
110
+ #### Backends
111
+
112
+ Savile Row drives five backends, chosen by a solver config's `backend:` key and
113
+ described by one row each in `CommandBuilderMixin.SAVILEROW_BACKENDS`. A config
114
+ with no `backend:` key is the SAT arm, which is what every pre-existing config
115
+ relies on. The row supplies three things that vary and nothing that does not; the
116
+ arg-set key is *derived* as `<backend>_args`, so it needs no row.
117
+
118
+ | `backend:` | arg-set key | solver's own time limit | binary flag |
119
+ |---|---|---|---|
120
+ | *(absent)* / `sat` | `sat_args` | `--time=N` (s) | `-satsolver-bin` |
121
+ | `minion` | `minion_args` | *(none)* | `-minion-bin` |
122
+ | `ortools` | `ortools_args` | `--time_limit=N` (s) | `-fzn-bin` |
123
+ | `gecode` | `gecode_args` | `-time N` (**ms**) | `-gecode-bin` |
124
+ | `chuffed` | `chuffed_args` | `--time-out N` (**ms**) | `-chuffed-bin` |
125
+
126
+ Savile Row's own `-timelimit` is always added, always in seconds, and covers
127
+ translation plus solving. The solver's limit is passed through `-solver-options`
128
+ in addition, so the solver stops itself and reports rather than being killed with
129
+ nothing to show. Repeated `-solver-options` accumulate, so a tune preset may
130
+ carry its own without displacing the timeout.
131
+
132
+ **The units are a trap.** Gecode and Chuffed take milliseconds and neither says
133
+ so in its flag name. An unscaled value does not fail loudly — a 30s budget
134
+ becomes 30ms and the solver gives up instantly, which reads as a hard instance.
135
+ `timeout_scale` in the table is what converts; a config overriding
136
+ `solver_timeout_option` changes the flag's *name* only, since the unit belongs to
137
+ the solver rather than to the spelling.
138
+
139
+ **Only the SAT arm records intermediate objectives.** It is the one configuration
140
+ where Savile Row runs the optimisation loop itself; every other backend optimises
141
+ internally, leaving nothing for `-record-intermediate-objective-values` to see.
142
+ So for a non-SAT backend `_generate_objective_file_path` returns `None` and no
143
+ `.obj` file is named or cleared, and `_check_continue_scan` falls back to the raw
144
+ objective read out of the *solution* by the injected extractor. A consumer that
145
+ wires only the objective-file route will find continue-scan stops at the first
146
+ solving horizon on those backends instead of improving — wire
147
+ `set_raw_objective_from_solution` to use them for optimisation models.
148
+
88
149
  ### Fast Downward / SymK (PDDL models)
89
150
 
90
151
  - **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.2"
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:
@@ -9,6 +9,7 @@ from the original bash run script.
9
9
 
10
10
  from pathlib import Path
11
11
  from typing import Dict, Any, Optional, List, Tuple, Callable
12
+ import copy
12
13
  import shutil
13
14
  import yaml
14
15
 
@@ -95,8 +96,14 @@ class ModelRegistry(CommandBuilderMixin):
95
96
 
96
97
  self.config = {'models': {}, 'solvers': {}}
97
98
  self._savilerow_native_warning_shown = False # Track if we've shown the warning
99
+ # Every file merged in, in the order merged, so a caller can report what
100
+ # actually contributed. Without this the answer is only reconstructible
101
+ # by re-running the same two searches by hand - and the user-override one
102
+ # depends on the cwd at construction time, so it is not reproducible
103
+ # later. See `dump_config`.
104
+ self.config_sources: List[Path] = []
98
105
  self._load_yaml_config(default_yaml)
99
-
106
+
100
107
  # Then, load user configuration to override defaults
101
108
  user_yaml = config_path or self._find_user_yaml_config()
102
109
  if user_yaml:
@@ -145,10 +152,11 @@ class ModelRegistry(CommandBuilderMixin):
145
152
  try:
146
153
  with open(config_path, 'r') as f:
147
154
  user_config = yaml.safe_load(f)
148
-
155
+
149
156
  # Deep merge user config with existing config
150
157
  self._deep_merge(self.config, user_config)
151
-
158
+ self.config_sources.append(Path(str(config_path)))
159
+
152
160
  except Exception as e:
153
161
  raise ModelRegistryError(f"Failed to load config file {config_path}: {e}")
154
162
 
@@ -165,7 +173,76 @@ class ModelRegistry(CommandBuilderMixin):
165
173
  self._deep_merge(base[key], value)
166
174
  else:
167
175
  base[key] = value
168
-
176
+
177
+ def effective_config(self, resolved: bool = False) -> Dict[str, Any]:
178
+ """The configuration actually in force, as a plain dict.
179
+
180
+ A deep copy of the merge of every file in ``config_sources``: the
181
+ consumer's bundled config first, then any user override merged over it.
182
+ Answering "what is in force?" otherwise means reading both files and
183
+ performing the merge by eye - and the override's location depends on the
184
+ working directory at construction time, so it cannot reliably be
185
+ reconstructed afterwards.
186
+
187
+ With ``resolved``, values a *builder* supplies when a key is absent are
188
+ written out explicitly - currently a savilerow solver's implicit
189
+ ``backend: sat``, the one default whose absence changes which arg-set,
190
+ timeout dialect and binary flag are used. Only real config keys are ever
191
+ added, so a resolved dump still loads; see :meth:`dump_config` for why
192
+ loading one back is not what it is for.
193
+
194
+ The copy is deep, so a caller may edit the result without disturbing the
195
+ live registry.
196
+ """
197
+ config = copy.deepcopy(self.config)
198
+ if resolved:
199
+ for solver_config in config.get('solvers', {}).values():
200
+ if (isinstance(solver_config, dict)
201
+ and solver_config.get('solver_type') == 'savilerow'):
202
+ solver_config.setdefault('backend', 'sat')
203
+ return config
204
+
205
+ def dump_config(self, resolved: bool = False, header: bool = True) -> str:
206
+ """The effective configuration as YAML text, for inspection.
207
+
208
+ Intended for a consumer's ``--show-registry``-style flag: it answers what
209
+ the registry resolved to and which files contributed. It is deliberately
210
+ *not* a config generator. Two reasons, both stated in the emitted header:
211
+
212
+ - Comments are not preserved. A config's comments carry the reasoning for
213
+ its arg-sets and timeouts, and a YAML round-trip drops all of them, so
214
+ the dump is a view of the configuration rather than a replacement.
215
+ - Overrides are deep-merged, so adopting a full dump as a user config
216
+ pins every key at today's value and silently shadows later changes to
217
+ the bundled config. A partial override is the supported shape: copy
218
+ only the keys being changed.
219
+ """
220
+ text = yaml.safe_dump(self.effective_config(resolved=resolved),
221
+ sort_keys=False, default_flow_style=False,
222
+ allow_unicode=True)
223
+ if not header:
224
+ return text
225
+
226
+ lines = ["# Effective solver-support registry, merged in this order:"]
227
+ lines.extend(f"# {i}. {source}"
228
+ for i, source in enumerate(self.config_sources, start=1))
229
+ if not self.config_sources:
230
+ lines.append("# (none)")
231
+ if resolved:
232
+ lines.append("#")
233
+ lines.append("# Defaults a builder would supply are written out "
234
+ "explicitly here.")
235
+ lines.extend([
236
+ "#",
237
+ "# For reading, not for adopting wholesale. Comments in the source",
238
+ "# files are not preserved, and overrides are deep-merged - so using",
239
+ "# this whole file as an override would pin every key at today's",
240
+ "# value and silently shadow later changes. Copy only what you mean",
241
+ "# to change.",
242
+ "",
243
+ ])
244
+ return "\n".join(lines) + "\n" + text
245
+
169
246
  def get_model_path(self, model_type: str) -> Path:
170
247
  """
171
248
  Get the file path for a model type.
@@ -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]:
@@ -213,11 +227,31 @@ class HorizonBasedSolver(Solver):
213
227
  self.runner.summary.phases[-1].solver_stats['objbound'] = self._best_objective
214
228
 
215
229
  def _record_savilerow_times(self, param_file: Path, n: int) -> None:
216
- """Attach Savile Row's tailoring/solver time split to horizon n's phase.
230
+ """Attach Savile Row's ``.info`` stats to horizon n's phase.
217
231
 
218
232
  Reads the ``<param_file>.info`` stats file Savile Row writes for this
219
233
  horizon and records ``savilerow_time`` (tailoring) and ``solver_time``
220
234
  on the phase, alongside the wall-clock ``duration`` already stored.
235
+
236
+ The whole file is also kept verbatim under ``savilerow_info``. The two
237
+ renamed times are what the phase model needs, but the same file carries
238
+ the encoding-size and search-effort numbers (``SATVars``, ``SolverNodes``,
239
+ ``SolverFailures``, ...) and the outcome flags (``SolverSatisfiable``
240
+ distinguishes a refuted horizon from one that gave up; ``SolverTimeOut``
241
+ / ``SavileRowTimeOut`` attribute a timeout to the solver or to
242
+ tailoring). Without this a consumer wanting any of them has to re-parse
243
+ the ``.info`` files itself, which duplicates the parser and needs the
244
+ run's output directory rather than just its summary - so it stops working
245
+ once those files are cleaned up.
246
+
247
+ Kept **nested and verbatim** rather than flattened in. ``solver_stats``
248
+ is the core's normalised, cross-solver vocabulary; these are one solver's
249
+ own keys in its own spelling, and the backends do not even agree on which
250
+ of them exist - Minion alone emits ``SolverSetupTime`` /
251
+ ``SolverSolveTime`` / ``SolverSolutionsFound``, the FlatZinc backends
252
+ alone ``SolverTotalWallTime``, kissat alone ``SATVars`` / ``SATClauses``.
253
+ A curated allow-list would therefore be wrong already, and nesting also
254
+ keeps CamelCase keys from colliding with the snake_case ones beside them.
221
255
  ``param_file`` is whatever parameter file Savile Row was run on - the
222
256
  Essence Prime ``.param`` for the direct Savile Row path, or the
223
257
  Conjure-translated ``.eprime-param`` for the Essence path - since Savile
@@ -228,12 +262,15 @@ class HorizonBasedSolver(Solver):
228
262
  the matching ``solving-horizon-{n}`` phase so it cannot scribble on an
229
263
  unrelated phase.
230
264
  """
265
+ info = SolverStatsExtractor.extract_savilerow_info(f"{param_file}.info")
231
266
  times = SolverStatsExtractor.extract_savilerow_info_times(f"{param_file}.info")
232
- if not times:
267
+ if not times and not info:
233
268
  return
234
269
  phases = self.runner.summary.phases
235
270
  if phases and phases[-1].name == f"solving-horizon-{n}":
236
271
  phases[-1].solver_stats.update(times)
272
+ if info:
273
+ phases[-1].solver_stats['savilerow_info'] = info
237
274
 
238
275
  def _build_savilerow_command(self, model_path: str, param_path: str, timeout: Optional[int], obj_file: Optional[Path] = None) -> List[str]:
239
276
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solver-support
3
- Version: 0.1.1
3
+ Version: 0.2.2
4
4
  Summary: Generic Conjure / Savile Row / Fast Downward / SymK / ENHSP / UPF solver pipeline
5
5
  Author: András Salamon
6
6
  License-Expression: BSD-3-Clause
@@ -28,11 +28,14 @@ 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
35
+ tests/test_registry_dump.py
34
36
  tests/test_run_summary.py
35
37
  tests/test_run_summary_success.py
38
+ tests/test_savilerow_info_recording.py
36
39
  tests/test_solution_naming.py
37
40
  tests/test_solver_runner.py
38
41
  tests/test_solver_stats.py