solver-support 0.2.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 (45) hide show
  1. {solver_support-0.2.1/solver_support.egg-info → solver_support-0.2.2}/PKG-INFO +1 -1
  2. {solver_support-0.2.1 → solver_support-0.2.2}/docs/timing-model.md +28 -6
  3. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/__init__.py +1 -1
  4. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/model_registry.py +81 -4
  5. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/solver_horizon.py +25 -2
  6. {solver_support-0.2.1 → solver_support-0.2.2/solver_support.egg-info}/PKG-INFO +1 -1
  7. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support.egg-info/SOURCES.txt +2 -0
  8. solver_support-0.2.2/tests/test_registry_dump.py +93 -0
  9. solver_support-0.2.2/tests/test_savilerow_info_recording.py +127 -0
  10. {solver_support-0.2.1 → solver_support-0.2.2}/LICENSE +0 -0
  11. {solver_support-0.2.1 → solver_support-0.2.2}/MANIFEST.in +0 -0
  12. {solver_support-0.2.1 → solver_support-0.2.2}/README.md +0 -0
  13. {solver_support-0.2.1 → solver_support-0.2.2}/docs/injection-api.md +0 -0
  14. {solver_support-0.2.1 → solver_support-0.2.2}/docs/solver-runner.md +0 -0
  15. {solver_support-0.2.1 → solver_support-0.2.2}/pyproject.toml +0 -0
  16. {solver_support-0.2.1 → solver_support-0.2.2}/setup.cfg +0 -0
  17. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/command_builders.py +0 -0
  18. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/file_management.py +0 -0
  19. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/global_state.py +0 -0
  20. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/horizon_utils.py +0 -0
  21. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/objective.py +0 -0
  22. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/run_summary.py +0 -0
  23. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/solver_base.py +0 -0
  24. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/solver_enhsp.py +0 -0
  25. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/solver_fd.py +0 -0
  26. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/solver_runner.py +0 -0
  27. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/solver_stats.py +0 -0
  28. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/solver_upfplan.py +0 -0
  29. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support/utilities.py +0 -0
  30. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support.egg-info/dependency_links.txt +0 -0
  31. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support.egg-info/not-zip-safe +0 -0
  32. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support.egg-info/requires.txt +0 -0
  33. {solver_support-0.2.1 → solver_support-0.2.2}/solver_support.egg-info/top_level.txt +0 -0
  34. {solver_support-0.2.1 → solver_support-0.2.2}/tests/conftest.py +0 -0
  35. {solver_support-0.2.1 → solver_support-0.2.2}/tests/fixtures/models.yaml +0 -0
  36. {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_command_builders.py +0 -0
  37. {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_helpers.py +0 -0
  38. {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_horizon_utils.py +0 -0
  39. {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_import.py +0 -0
  40. {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_run_summary.py +0 -0
  41. {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_run_summary_success.py +0 -0
  42. {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_solution_naming.py +0 -0
  43. {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_solver_runner.py +0 -0
  44. {solver_support-0.2.1 → solver_support-0.2.2}/tests/test_solver_stats.py +0 -0
  45. {solver_support-0.2.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.2.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,12 +76,34 @@ 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
 
@@ -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.2.1"
16
+ __version__ = "0.2.2"
17
17
 
18
18
  # --- model registry (loader + command builders + packaging injection) ---
19
19
  from .model_registry import (
@@ -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.
@@ -227,11 +227,31 @@ class HorizonBasedSolver(Solver):
227
227
  self.runner.summary.phases[-1].solver_stats['objbound'] = self._best_objective
228
228
 
229
229
  def _record_savilerow_times(self, param_file: Path, n: int) -> None:
230
- """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.
231
231
 
232
232
  Reads the ``<param_file>.info`` stats file Savile Row writes for this
233
233
  horizon and records ``savilerow_time`` (tailoring) and ``solver_time``
234
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.
235
255
  ``param_file`` is whatever parameter file Savile Row was run on - the
236
256
  Essence Prime ``.param`` for the direct Savile Row path, or the
237
257
  Conjure-translated ``.eprime-param`` for the Essence path - since Savile
@@ -242,12 +262,15 @@ class HorizonBasedSolver(Solver):
242
262
  the matching ``solving-horizon-{n}`` phase so it cannot scribble on an
243
263
  unrelated phase.
244
264
  """
265
+ info = SolverStatsExtractor.extract_savilerow_info(f"{param_file}.info")
245
266
  times = SolverStatsExtractor.extract_savilerow_info_times(f"{param_file}.info")
246
- if not times:
267
+ if not times and not info:
247
268
  return
248
269
  phases = self.runner.summary.phases
249
270
  if phases and phases[-1].name == f"solving-horizon-{n}":
250
271
  phases[-1].solver_stats.update(times)
272
+ if info:
273
+ phases[-1].solver_stats['savilerow_info'] = info
251
274
 
252
275
  def _build_savilerow_command(self, model_path: str, param_path: str, timeout: Optional[int], obj_file: Optional[Path] = None) -> List[str]:
253
276
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solver-support
3
- Version: 0.2.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
@@ -32,8 +32,10 @@ tests/test_command_builders.py
32
32
  tests/test_helpers.py
33
33
  tests/test_horizon_utils.py
34
34
  tests/test_import.py
35
+ tests/test_registry_dump.py
35
36
  tests/test_run_summary.py
36
37
  tests/test_run_summary_success.py
38
+ tests/test_savilerow_info_recording.py
37
39
  tests/test_solution_naming.py
38
40
  tests/test_solver_runner.py
39
41
  tests/test_solver_stats.py
@@ -0,0 +1,93 @@
1
+ """Reporting what the registry actually resolved to.
2
+
3
+ ``effective_config`` / ``dump_config`` exist to answer "what is in force?", which
4
+ is otherwise only answerable by reading the bundled config and any user override
5
+ and merging them by eye -- and the override's location depends on the working
6
+ directory at construction time, so after the fact it may not be reconstructible
7
+ at all.
8
+
9
+ The dump is deliberately a *view*, not a config generator, and the tests below
10
+ pin the two properties that keep it honest: it reports its sources, and its
11
+ header warns against adopting it wholesale. Both matter because overrides are
12
+ deep-merged, so a full dump adopted as an override pins every key at today's
13
+ value and silently shadows later changes to the bundled config.
14
+ """
15
+
16
+ import yaml
17
+
18
+ import pytest
19
+
20
+ from solver_support.model_registry import ModelRegistry
21
+
22
+
23
+ @pytest.fixture
24
+ def registry(fixture_registry):
25
+ return ModelRegistry()
26
+
27
+
28
+ class TestEffectiveConfig:
29
+
30
+ def test_returns_the_merged_config(self, registry):
31
+ config = registry.effective_config()
32
+ assert set(config) >= {"models", "solvers"}
33
+ assert "srortools" in config["solvers"]
34
+
35
+ def test_copy_is_deep(self, registry):
36
+ config = registry.effective_config()
37
+ config["solvers"]["srortools"]["backend"] = "mutated"
38
+ # The live registry must be untouched, or a caller inspecting the config
39
+ # could silently reconfigure the run it is inspecting.
40
+ assert registry.get_solver_config("srortools")["backend"] == "ortools"
41
+
42
+ def test_unresolved_leaves_implicit_backend_absent(self, registry):
43
+ # srsat has no `backend:` key; unresolved output mirrors the source.
44
+ assert "backend" not in registry.effective_config()["solvers"]["srsat"]
45
+
46
+ def test_resolved_writes_out_the_implicit_backend(self, registry):
47
+ config = registry.effective_config(resolved=True)
48
+ assert config["solvers"]["srsat"]["backend"] == "sat"
49
+ # An explicit one is left alone.
50
+ assert config["solvers"]["srortools"]["backend"] == "ortools"
51
+
52
+ def test_resolved_leaves_non_savilerow_solvers_alone(self, registry):
53
+ # `backend` means nothing to a upfplan solver, so inventing one would be
54
+ # a lie that also happens to load.
55
+ assert "backend" not in registry.effective_config(resolved=True)["solvers"]["upffd"]
56
+
57
+
58
+ class TestConfigSources:
59
+
60
+ def test_records_the_files_merged(self, registry):
61
+ assert len(registry.config_sources) == 1
62
+ assert registry.config_sources[0].name == "models.yaml"
63
+
64
+
65
+ class TestDumpConfig:
66
+
67
+ def test_dump_round_trips_as_yaml(self, registry):
68
+ loaded = yaml.safe_load(registry.dump_config())
69
+ assert loaded == registry.effective_config()
70
+
71
+ def test_resolved_dump_also_round_trips(self, registry):
72
+ # Only real config keys are ever added, so a resolved dump still loads.
73
+ loaded = yaml.safe_load(registry.dump_config(resolved=True))
74
+ assert loaded["solvers"]["srsat"]["backend"] == "sat"
75
+
76
+ def test_header_names_the_sources(self, registry):
77
+ assert "models.yaml" in registry.dump_config().splitlines()[1]
78
+
79
+ def test_header_warns_against_adopting_it(self, registry):
80
+ header = registry.dump_config().split("\n\n")[0]
81
+ assert "not for adopting wholesale" in header
82
+ assert "deep-merged" in header
83
+
84
+ def test_header_can_be_suppressed(self, registry):
85
+ text = registry.dump_config(header=False)
86
+ assert not text.startswith("#")
87
+ assert yaml.safe_load(text) == registry.effective_config()
88
+
89
+ def test_key_order_follows_the_source(self, registry):
90
+ # sort_keys=False: alphabetising a registry makes it unreadable, and the
91
+ # source order is how a maintainer arranged it.
92
+ text = registry.dump_config(header=False)
93
+ assert text.index("models:") < text.index("solvers:")
@@ -0,0 +1,127 @@
1
+ """What ``_record_savilerow_times`` keeps from each horizon's ``.info`` file.
2
+
3
+ Savile Row writes a ``<param>.info`` per horizon. Two of its fields are renamed
4
+ into the core's normalised vocabulary (``savilerow_time`` / ``solver_time``); the
5
+ rest used to be parsed and dropped, so a consumer wanting the encoding size or
6
+ the search-effort counters had to re-parse the files itself -- duplicating the
7
+ parser, and depending on the run's output directory rather than its summary, so
8
+ it stopped working once those files were cleaned up.
9
+
10
+ The whole file is now kept verbatim under ``savilerow_info``, nested rather than
11
+ flattened. Nesting is the point of several tests below: ``solver_stats`` is the
12
+ core's cross-solver vocabulary, while these are one solver's own keys in its own
13
+ spelling, and the backends do not agree on which of them exist.
14
+ """
15
+
16
+ from types import SimpleNamespace
17
+
18
+ import pytest
19
+
20
+ from solver_support.run_summary import PhaseResult
21
+ from solver_support.solver_horizon import SavileRowSolver
22
+
23
+ # A real Minion .info, which carries three keys no other backend emits.
24
+ MINION_INFO = """SavileRowClauseOut:0
25
+ SavileRowTotalTime:0.484
26
+ SolverNodes:1
27
+ SolverSatisfiable:1
28
+ SolverSetupTime:0.001
29
+ SolverSolutionsFound:1
30
+ SolverSolveTime:0.000123
31
+ SolverTimeOut:0
32
+ SolverTotalTime:0.000123
33
+ """
34
+
35
+ # A real OR-Tools .info: no SolverNodes, but SolverFailures and a wall time.
36
+ ORTOOLS_INFO = """SavileRowClauseOut:0
37
+ SavileRowTotalTime:0.51
38
+ SolverFailures:0
39
+ SolverSatisfiable:1
40
+ SolverTimeOut:0
41
+ SolverTotalTime:0.002
42
+ SolverTotalWallTime:0.003
43
+ """
44
+
45
+
46
+ @pytest.fixture
47
+ def solver(tmp_path, monkeypatch):
48
+ """A SavileRowSolver whose summary already holds a horizon-3 phase."""
49
+ monkeypatch.chdir(tmp_path)
50
+ phase = PhaseResult(name="solving-horizon-3", duration=1.0)
51
+ runner = SimpleNamespace(
52
+ summary=SimpleNamespace(phases=[phase]),
53
+ logger=None,
54
+ )
55
+ return SavileRowSolver(runner), phase
56
+
57
+
58
+ def write_info(tmp_path, text, stem="inst.param"):
59
+ (tmp_path / f"{stem}.info").write_text(text)
60
+ return tmp_path / stem
61
+
62
+
63
+ class TestVerbatimInfo:
64
+
65
+ def test_whole_file_is_kept(self, solver, tmp_path):
66
+ sr, phase = solver
67
+ sr._record_savilerow_times(write_info(tmp_path, MINION_INFO), 3)
68
+ info = phase.solver_stats["savilerow_info"]
69
+ assert info["SolverNodes"] == 1
70
+ assert info["SolverSatisfiable"] == 1
71
+ assert info["SolverSolutionsFound"] == 1
72
+
73
+ def test_renamed_times_still_recorded(self, solver, tmp_path):
74
+ # The existing contract must not move: these two are what the phase model
75
+ # reads, and they stay top-level and snake_case.
76
+ sr, phase = solver
77
+ sr._record_savilerow_times(write_info(tmp_path, MINION_INFO), 3)
78
+ assert phase.solver_stats["savilerow_time"] == 0.484
79
+ assert phase.solver_stats["solver_time"] == 0.000123
80
+
81
+ def test_verbatim_keys_are_nested_not_flattened(self, solver, tmp_path):
82
+ # Savile Row's CamelCase must not land beside the core's snake_case.
83
+ sr, phase = solver
84
+ sr._record_savilerow_times(write_info(tmp_path, MINION_INFO), 3)
85
+ assert "SolverNodes" not in phase.solver_stats
86
+ assert set(phase.solver_stats) == {"savilerow_time", "solver_time",
87
+ "savilerow_info"}
88
+
89
+ def test_keys_are_verbatim_and_coerced(self, solver, tmp_path):
90
+ sr, phase = solver
91
+ sr._record_savilerow_times(write_info(tmp_path, ORTOOLS_INFO), 3)
92
+ info = phase.solver_stats["savilerow_info"]
93
+ assert info["SolverTotalWallTime"] == 0.003 # float
94
+ assert info["SolverTimeOut"] == 0 # int, not "0"
95
+
96
+ def test_backends_may_disagree_on_which_keys_exist(self, solver, tmp_path):
97
+ # Which is why this is verbatim rather than an allow-list: Minion emits
98
+ # SolverNodes and no wall time, OR-Tools the reverse.
99
+ sr, phase = solver
100
+ sr._record_savilerow_times(write_info(tmp_path, ORTOOLS_INFO), 3)
101
+ info = phase.solver_stats["savilerow_info"]
102
+ assert "SolverNodes" not in info
103
+ assert "SolverFailures" in info and "SolverTotalWallTime" in info
104
+
105
+
106
+ class TestBestEffort:
107
+
108
+ def test_missing_info_file_records_nothing(self, solver, tmp_path):
109
+ # A hard timeout can kill Savile Row before it writes the file.
110
+ sr, phase = solver
111
+ sr._record_savilerow_times(tmp_path / "absent.param", 3)
112
+ assert phase.solver_stats == {}
113
+
114
+ def test_only_the_matching_horizon_phase_is_touched(self, solver, tmp_path):
115
+ # Guarded so a mismatched horizon cannot scribble on an unrelated phase.
116
+ sr, phase = solver
117
+ sr._record_savilerow_times(write_info(tmp_path, MINION_INFO), 7)
118
+ assert phase.solver_stats == {}
119
+
120
+ def test_info_without_the_two_times_is_still_kept(self, solver, tmp_path):
121
+ # The times are absent if Savile Row was interrupted mid-write; whatever
122
+ # did land is still worth keeping.
123
+ sr, phase = solver
124
+ sr._record_savilerow_times(
125
+ write_info(tmp_path, "SolverSatisfiable:0\nSolverTimeOut:1\n"), 3)
126
+ assert phase.solver_stats["savilerow_info"] == {"SolverSatisfiable": 0,
127
+ "SolverTimeOut": 1}
File without changes
File without changes
File without changes