solver-support 0.1.1__py3-none-any.whl
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.
- solver_support/__init__.py +132 -0
- solver_support/command_builders.py +334 -0
- solver_support/file_management.py +243 -0
- solver_support/global_state.py +62 -0
- solver_support/horizon_utils.py +350 -0
- solver_support/model_registry.py +792 -0
- solver_support/objective.py +92 -0
- solver_support/run_summary.py +338 -0
- solver_support/solver_base.py +140 -0
- solver_support/solver_enhsp.py +101 -0
- solver_support/solver_fd.py +261 -0
- solver_support/solver_horizon.py +634 -0
- solver_support/solver_runner.py +702 -0
- solver_support/solver_stats.py +724 -0
- solver_support/solver_upfplan.py +168 -0
- solver_support/utilities.py +146 -0
- solver_support-0.1.1.dist-info/METADATA +120 -0
- solver_support-0.1.1.dist-info/RECORD +21 -0
- solver_support-0.1.1.dist-info/WHEEL +5 -0
- solver_support-0.1.1.dist-info/licenses/LICENSE +29 -0
- solver_support-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Model Registry for solver configurations.
|
|
4
|
+
|
|
5
|
+
This module provides a centralized registry for managing model files, solvers,
|
|
6
|
+
and their configurations. It replaces the hard-coded model paths and logic
|
|
7
|
+
from the original bash run script.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, Any, Optional, List, Tuple, Callable
|
|
12
|
+
import shutil
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ModelRegistryError(Exception):
|
|
17
|
+
"""Exception raised for model registry errors."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Imported after ModelRegistryError so command_builders (which raises it) can
|
|
22
|
+
# back-import it without a cycle; the package __init__ always imports this
|
|
23
|
+
# module before anything reaches command_builders.
|
|
24
|
+
from .command_builders import CommandBuilderMixin
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# --- consumer packaging injection (registered once by a consumer) ---
|
|
28
|
+
#
|
|
29
|
+
# The registry is a generic loader: it does not know which package ships the
|
|
30
|
+
# ``models.yaml`` it loads, nor where the model files that yaml references live,
|
|
31
|
+
# nor where a consumer keeps its optional user-override config. A consumer
|
|
32
|
+
# registers those once, at import (see the setters below), so the core carries
|
|
33
|
+
# no consumer package name. Until a consumer registers them the registry
|
|
34
|
+
# looks only for a config sitting next to this module (a bare core ships none, so
|
|
35
|
+
# construction then raises, telling the caller to supply a config).
|
|
36
|
+
|
|
37
|
+
_default_config_path = None # path/Traversable to the default models.yaml
|
|
38
|
+
_resource_root = None # path/Traversable to the consumer package dir
|
|
39
|
+
_user_config_finder: Optional[Callable[[], Optional[Path]]] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def set_default_config_path(path) -> None:
|
|
43
|
+
"""Register the default ``models.yaml`` the registry loads at startup.
|
|
44
|
+
|
|
45
|
+
Stored as-is (a path or an ``importlib.resources`` traversable), so the
|
|
46
|
+
loader can ``open`` it directly regardless of how the consumer resolved it.
|
|
47
|
+
"""
|
|
48
|
+
global _default_config_path
|
|
49
|
+
_default_config_path = path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def set_resource_root(root) -> None:
|
|
53
|
+
"""Register the consumer package root used to resolve model file paths."""
|
|
54
|
+
global _resource_root
|
|
55
|
+
_resource_root = root
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def set_user_config_finder(finder: Optional[Callable[[], Optional[Path]]]) -> None:
|
|
59
|
+
"""Register ``finder() -> Optional[Path]`` for an optional user-override config.
|
|
60
|
+
|
|
61
|
+
Called at registry *construction* (not import), so cwd-relative candidates
|
|
62
|
+
resolve against the working directory in effect for the run. Unregistered
|
|
63
|
+
means no user-override search.
|
|
64
|
+
"""
|
|
65
|
+
global _user_config_finder
|
|
66
|
+
_user_config_finder = finder
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ModelRegistry(CommandBuilderMixin):
|
|
70
|
+
"""
|
|
71
|
+
Registry for models, solvers, and their configurations.
|
|
72
|
+
|
|
73
|
+
Provides a centralized way to:
|
|
74
|
+
- Map model types to file paths
|
|
75
|
+
- Resolve model variants (e.g., compactrows2, compactrows3)
|
|
76
|
+
- Select appropriate solvers for model types
|
|
77
|
+
- Support custom configuration via YAML files
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, config_path: Optional[Path] = None):
|
|
81
|
+
"""
|
|
82
|
+
Initialize the model registry.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
config_path: Optional path to YAML configuration file.
|
|
86
|
+
If None, will search for config in standard locations.
|
|
87
|
+
"""
|
|
88
|
+
# First, load the default YAML config from package
|
|
89
|
+
default_yaml = self._find_default_yaml_config()
|
|
90
|
+
if not default_yaml:
|
|
91
|
+
raise ModelRegistryError(
|
|
92
|
+
"Default models.yaml configuration file not found. "
|
|
93
|
+
"Please ensure the package is properly installed."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
self.config = {'models': {}, 'solvers': {}}
|
|
97
|
+
self._savilerow_native_warning_shown = False # Track if we've shown the warning
|
|
98
|
+
self._load_yaml_config(default_yaml)
|
|
99
|
+
|
|
100
|
+
# Then, load user configuration to override defaults
|
|
101
|
+
user_yaml = config_path or self._find_user_yaml_config()
|
|
102
|
+
if user_yaml:
|
|
103
|
+
self._load_yaml_config(user_yaml)
|
|
104
|
+
|
|
105
|
+
def _find_default_yaml_config(self) -> Optional[Path]:
|
|
106
|
+
"""
|
|
107
|
+
Find the default YAML configuration the registry loads at startup.
|
|
108
|
+
|
|
109
|
+
Prefers a consumer-registered path (see ``set_default_config_path``);
|
|
110
|
+
otherwise looks for a config sitting next to this module, which a bare
|
|
111
|
+
core does not ship.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Path to default config file, or None if not found.
|
|
115
|
+
"""
|
|
116
|
+
if _default_config_path is not None:
|
|
117
|
+
try:
|
|
118
|
+
if _default_config_path.is_file():
|
|
119
|
+
return _default_config_path
|
|
120
|
+
except (AttributeError, OSError):
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
beside_core = Path(__file__).parent / "config" / "models.yaml"
|
|
124
|
+
return beside_core if beside_core.exists() else None
|
|
125
|
+
|
|
126
|
+
def _find_user_yaml_config(self) -> Optional[Path]:
|
|
127
|
+
"""
|
|
128
|
+
Find an optional user-override config via the consumer's finder.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Path to the user config file, or None if no finder is registered or
|
|
132
|
+
it finds nothing.
|
|
133
|
+
"""
|
|
134
|
+
if _user_config_finder is None:
|
|
135
|
+
return None
|
|
136
|
+
return _user_config_finder()
|
|
137
|
+
|
|
138
|
+
def _load_yaml_config(self, config_path: Path) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Load YAML configuration file and merge with defaults.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
config_path: Path to YAML configuration file.
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
with open(config_path, 'r') as f:
|
|
147
|
+
user_config = yaml.safe_load(f)
|
|
148
|
+
|
|
149
|
+
# Deep merge user config with existing config
|
|
150
|
+
self._deep_merge(self.config, user_config)
|
|
151
|
+
|
|
152
|
+
except Exception as e:
|
|
153
|
+
raise ModelRegistryError(f"Failed to load config file {config_path}: {e}")
|
|
154
|
+
|
|
155
|
+
def _deep_merge(self, base: Dict[str, Any], update: Dict[str, Any]) -> None:
|
|
156
|
+
"""
|
|
157
|
+
Deep merge update dictionary into base dictionary.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
base: Base dictionary to update.
|
|
161
|
+
update: Dictionary with updates to apply.
|
|
162
|
+
"""
|
|
163
|
+
for key, value in update.items():
|
|
164
|
+
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
|
165
|
+
self._deep_merge(base[key], value)
|
|
166
|
+
else:
|
|
167
|
+
base[key] = value
|
|
168
|
+
|
|
169
|
+
def get_model_path(self, model_type: str) -> Path:
|
|
170
|
+
"""
|
|
171
|
+
Get the file path for a model type.
|
|
172
|
+
|
|
173
|
+
Tries to resolve model files from the installed package first,
|
|
174
|
+
then falls back to working directory relative paths.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
model_type: The model type (e.g., 'compact', 'compactrows2').
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Path to the model file or command name for command-based models.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
ModelRegistryError: If model type is not found.
|
|
184
|
+
"""
|
|
185
|
+
model_name, model_config = self.resolve_model_variant(model_type)
|
|
186
|
+
return self._resolve_model_path(model_config, model_name)
|
|
187
|
+
|
|
188
|
+
def _resolve_resource_path(self, path_str: str) -> Optional[Path]:
|
|
189
|
+
"""
|
|
190
|
+
Resolve a model resource path against the consumer's package root.
|
|
191
|
+
|
|
192
|
+
Handles paths that may be:
|
|
193
|
+
1. In the consumer's package resources (the registered resource root)
|
|
194
|
+
2. In the working directory
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
path_str: The path string to resolve.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Path to the resource file if found, None if not found.
|
|
201
|
+
"""
|
|
202
|
+
# Try to resolve from the consumer's registered package resources first.
|
|
203
|
+
if _resource_root is not None:
|
|
204
|
+
try:
|
|
205
|
+
if path_str.startswith('models/'):
|
|
206
|
+
# Extract filename for environments where spec.origin is None
|
|
207
|
+
filename = path_str.split('/')[-1]
|
|
208
|
+
resource_file = _resource_root / 'models' / filename
|
|
209
|
+
else:
|
|
210
|
+
# For non-models paths (like upf.py), resolve under the root.
|
|
211
|
+
resource_file = _resource_root / path_str
|
|
212
|
+
if resource_file.is_file():
|
|
213
|
+
return resource_file
|
|
214
|
+
except (FileNotFoundError, AttributeError, ValueError, TypeError):
|
|
215
|
+
# Fall through to working directory lookup
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
# Fallback to a working-directory relative path.
|
|
219
|
+
fallback_path = Path(path_str)
|
|
220
|
+
if fallback_path.exists():
|
|
221
|
+
return fallback_path
|
|
222
|
+
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
def _resolve_model_path(self, model_config: Dict[str, Any], model_type: str) -> Path:
|
|
226
|
+
"""
|
|
227
|
+
Resolve a model path from config, trying package resources first.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
model_config: The model configuration dictionary.
|
|
231
|
+
model_type: The model type name for error messages.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Path to the model file.
|
|
235
|
+
|
|
236
|
+
Raises:
|
|
237
|
+
ModelRegistryError: If model has no path specified.
|
|
238
|
+
"""
|
|
239
|
+
# Handle path-based models
|
|
240
|
+
if 'path' not in model_config:
|
|
241
|
+
raise ModelRegistryError(f"Model {model_type} has no path specified")
|
|
242
|
+
|
|
243
|
+
resolved_path = self._resolve_resource_path(model_config['path'])
|
|
244
|
+
if resolved_path is None:
|
|
245
|
+
# Return the path as-is if it couldn't be resolved (for backward compatibility)
|
|
246
|
+
return Path(model_config['path'])
|
|
247
|
+
|
|
248
|
+
return resolved_path
|
|
249
|
+
|
|
250
|
+
def get_control_path(self, model_type: str) -> Optional[Path]:
|
|
251
|
+
"""
|
|
252
|
+
Get the control parameter file path for a model type if one exists.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
model_type: The model type to get control path for.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
Path to control file if configured and found, None otherwise.
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
ModelRegistryError: If model type is not found.
|
|
262
|
+
"""
|
|
263
|
+
_, model_config = self.resolve_model_variant(model_type)
|
|
264
|
+
|
|
265
|
+
if 'control-path' not in model_config:
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
return self._resolve_resource_path(model_config['control-path'])
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def resolve_model_variant(self, model_type: str) -> Tuple[str, Dict[str, Any]]:
|
|
272
|
+
"""
|
|
273
|
+
Resolve model type to its canonical name and configuration.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
model_type: The model type to resolve.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Tuple of (canonical_model_name, model_config).
|
|
280
|
+
|
|
281
|
+
Raises:
|
|
282
|
+
ModelRegistryError: If model type cannot be resolved.
|
|
283
|
+
"""
|
|
284
|
+
model_type_lower = model_type.lower()
|
|
285
|
+
|
|
286
|
+
# Direct match
|
|
287
|
+
if model_type_lower in self.config['models']:
|
|
288
|
+
return model_type_lower, self.config['models'][model_type_lower]
|
|
289
|
+
|
|
290
|
+
# Check aliases
|
|
291
|
+
for model_name, model_config in self.config['models'].items():
|
|
292
|
+
aliases = model_config.get('aliases', [])
|
|
293
|
+
if model_type_lower in [alias.lower() for alias in aliases]:
|
|
294
|
+
return model_name, model_config
|
|
295
|
+
|
|
296
|
+
raise ModelRegistryError(f"Cannot resolve model type: {model_type}")
|
|
297
|
+
|
|
298
|
+
def get_solver_for_model(self, model_path: Path) -> str:
|
|
299
|
+
"""
|
|
300
|
+
Determine the appropriate solver for a model file.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
model_path: Path to the model file.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Solver command name.
|
|
307
|
+
|
|
308
|
+
Raises:
|
|
309
|
+
ModelRegistryError: If no solver can be determined.
|
|
310
|
+
"""
|
|
311
|
+
suffix = model_path.suffix.lower()
|
|
312
|
+
|
|
313
|
+
for solver_name, solver_config in self.config['solvers'].items():
|
|
314
|
+
if suffix in solver_config['extensions']:
|
|
315
|
+
return solver_name
|
|
316
|
+
|
|
317
|
+
raise ModelRegistryError(f"No solver found for file extension: {suffix}")
|
|
318
|
+
|
|
319
|
+
def get_solver_config(self, solver_name: str) -> Dict[str, Any]:
|
|
320
|
+
"""
|
|
321
|
+
Get configuration for a solver.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
solver_name: Name of the solver.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
Solver configuration dictionary.
|
|
328
|
+
|
|
329
|
+
Raises:
|
|
330
|
+
ModelRegistryError: If solver is not found.
|
|
331
|
+
"""
|
|
332
|
+
if solver_name not in self.config['solvers']:
|
|
333
|
+
raise ModelRegistryError(f"Unknown solver: {solver_name}")
|
|
334
|
+
|
|
335
|
+
return self.config['solvers'][solver_name]
|
|
336
|
+
|
|
337
|
+
def get_solver_type(self, solver_name: str) -> str:
|
|
338
|
+
"""
|
|
339
|
+
Get the base solver type for a solver variant.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
solver_name: Name of the solver (e.g., 'kissat', 'minion').
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Base solver type (e.g., 'savilerow', 'fd', 'conjure', 'upf').
|
|
346
|
+
Falls back to solver_name if solver_type is not specified.
|
|
347
|
+
|
|
348
|
+
Raises:
|
|
349
|
+
ModelRegistryError: If solver is not found.
|
|
350
|
+
"""
|
|
351
|
+
solver_config = self.get_solver_config(solver_name)
|
|
352
|
+
return solver_config.get('solver_type', solver_name)
|
|
353
|
+
|
|
354
|
+
def get_default_model_for_solver(self, solver_name: str) -> Optional[str]:
|
|
355
|
+
"""
|
|
356
|
+
Get the default model for a given solver.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
solver_name: Name of the solver.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
Default model name or None if no default defined.
|
|
363
|
+
|
|
364
|
+
Raises:
|
|
365
|
+
ModelRegistryError: If solver is not found.
|
|
366
|
+
"""
|
|
367
|
+
solver_config = self.get_solver_config(solver_name)
|
|
368
|
+
return solver_config.get('default_model')
|
|
369
|
+
|
|
370
|
+
def is_solver_compatible_with_model(self, solver_name: str, model_name: str) -> bool:
|
|
371
|
+
"""
|
|
372
|
+
Check if a solver is compatible with a model.
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
solver_name: Name of the solver to check.
|
|
376
|
+
model_name: Name of the model to check.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
True if compatible, False otherwise.
|
|
380
|
+
|
|
381
|
+
Raises:
|
|
382
|
+
ModelRegistryError: If solver or model is not found.
|
|
383
|
+
"""
|
|
384
|
+
# Get the solver type of the given solver
|
|
385
|
+
solver_type = self.get_solver_type(solver_name)
|
|
386
|
+
|
|
387
|
+
# Get the model configuration
|
|
388
|
+
try:
|
|
389
|
+
_, model_config = self.resolve_model_variant(model_name)
|
|
390
|
+
except (ValueError, ModelRegistryError):
|
|
391
|
+
return False
|
|
392
|
+
|
|
393
|
+
# Get the solver configured for this model
|
|
394
|
+
model_solver = model_config.get('solver')
|
|
395
|
+
if not model_solver:
|
|
396
|
+
return False
|
|
397
|
+
|
|
398
|
+
# Get the solver type of the model's solver
|
|
399
|
+
model_solver_type = self.get_solver_type(model_solver)
|
|
400
|
+
|
|
401
|
+
# Compatible if they have the same solver type ...
|
|
402
|
+
if solver_type == model_solver_type:
|
|
403
|
+
return True
|
|
404
|
+
|
|
405
|
+
# ... or if both consume the same instance format. fd / symk / enhsp / upffd
|
|
406
|
+
# all take .pddl, so any of them can solve a .pddl model even though their
|
|
407
|
+
# solver_types differ (direct binary vs UPF). This is what lets, e.g.,
|
|
408
|
+
# `pddlnoaxiom --solver fd` or `pddl --solver enhsp` be expressed as a
|
|
409
|
+
# model + --solver pair rather than minting a combination-model. (Whether the
|
|
410
|
+
# solver then *succeeds* - e.g. enhsp rejects the axiom model - is a runtime
|
|
411
|
+
# matter, not a registry incompatibility.)
|
|
412
|
+
try:
|
|
413
|
+
solver_exts = set(self.get_solver_config(solver_name).get('extensions', []))
|
|
414
|
+
model_solver_exts = set(self.get_solver_config(model_solver).get('extensions', []))
|
|
415
|
+
except (ValueError, ModelRegistryError):
|
|
416
|
+
return False
|
|
417
|
+
return bool(solver_exts & model_solver_exts)
|
|
418
|
+
|
|
419
|
+
def list_models(self) -> List[str]:
|
|
420
|
+
"""
|
|
421
|
+
Get list of all available model types, including aliases.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
List of model type names and their aliases.
|
|
425
|
+
"""
|
|
426
|
+
models = list(self.config['models'].keys())
|
|
427
|
+
|
|
428
|
+
# Add aliases
|
|
429
|
+
for model_config in self.config['models'].values():
|
|
430
|
+
aliases = model_config.get('aliases', [])
|
|
431
|
+
models.extend(aliases)
|
|
432
|
+
|
|
433
|
+
return sorted(models)
|
|
434
|
+
|
|
435
|
+
def list_solvers(self) -> List[str]:
|
|
436
|
+
"""
|
|
437
|
+
Get list of all available solvers.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
List of solver names.
|
|
441
|
+
"""
|
|
442
|
+
return list(self.config['solvers'].keys())
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def get_prob2any_format(self, model_type: str) -> Optional[str]:
|
|
446
|
+
"""
|
|
447
|
+
Get the prob2any format name for a model type.
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
model_type: The model type.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
The prob2any format name, or None if not found.
|
|
454
|
+
"""
|
|
455
|
+
try:
|
|
456
|
+
_, model_config = self.resolve_model_variant(model_type)
|
|
457
|
+
except (ValueError, ModelRegistryError):
|
|
458
|
+
return None
|
|
459
|
+
return model_config.get('format-name')
|
|
460
|
+
|
|
461
|
+
def get_continue_scan_enabled(self, model_type: str) -> bool:
|
|
462
|
+
"""
|
|
463
|
+
Check if a model has continue-scan enabled for horizon scanning.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
model_type: The model type.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
True if continue-scan is enabled, False otherwise.
|
|
470
|
+
"""
|
|
471
|
+
try:
|
|
472
|
+
_, model_config = self.resolve_model_variant(model_type)
|
|
473
|
+
except (ValueError, ModelRegistryError):
|
|
474
|
+
return False
|
|
475
|
+
return model_config.get('continue-scan', False)
|
|
476
|
+
|
|
477
|
+
def get_objective_in_solution(self, model_type: str) -> bool:
|
|
478
|
+
"""Whether a model encodes its objective inside its solution file.
|
|
479
|
+
|
|
480
|
+
True for models whose optimised objective is read back from the produced
|
|
481
|
+
solution - the scored* CP models write a per-cell ``score`` vector the
|
|
482
|
+
horizon loop sums - as opposed to models that report their objective via
|
|
483
|
+
Savile Row's separate ``.obj`` file (e.g. the move-minimising
|
|
484
|
+
``essence``/``essenceld`` models, and the ``reachability-*`` variants).
|
|
485
|
+
The horizon loop uses this to decide where to read a solving horizon's
|
|
486
|
+
objective, whether to force continue-scan under a timeout, and whether to
|
|
487
|
+
salvage a timeout solution's score. Bounded non-optimisation models carry
|
|
488
|
+
no such field and default to False.
|
|
489
|
+
|
|
490
|
+
Deliberately kept distinct from ``opt`` even though today every
|
|
491
|
+
objective-in-solution model is also an optimisation model (and vice versa
|
|
492
|
+
on the CP horizon path): the distinction lets a future opt-but-not-scored
|
|
493
|
+
model keep reading its objective from ``.obj``.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
model_type: The model type.
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
True if the model declares ``objective-in-solution: true``, else False.
|
|
500
|
+
"""
|
|
501
|
+
try:
|
|
502
|
+
_, model_config = self.resolve_model_variant(model_type)
|
|
503
|
+
except (ValueError, ModelRegistryError):
|
|
504
|
+
return False
|
|
505
|
+
return model_config.get('objective-in-solution', False)
|
|
506
|
+
|
|
507
|
+
def get_proves_unsat(self, model_type: str) -> bool:
|
|
508
|
+
"""Whether a model can soundly conclude unsatisfiability.
|
|
509
|
+
|
|
510
|
+
True only for complete decision procedures (the pddl* planning models
|
|
511
|
+
and the upf/upflocal viewpoints, flagged ``proves-unsat: true``) whose
|
|
512
|
+
'no solution' run is a genuine unsat proof. Bounded CP models carry no
|
|
513
|
+
such field and so default to False - their 'failed'/'no solution' is an
|
|
514
|
+
error or an unfinished horizon search, never an unsat verdict.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
model_type: The model type.
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
True if the model declares ``proves-unsat: true``, else False.
|
|
521
|
+
"""
|
|
522
|
+
try:
|
|
523
|
+
_, model_config = self.resolve_model_variant(model_type)
|
|
524
|
+
except (ValueError, ModelRegistryError):
|
|
525
|
+
return False
|
|
526
|
+
return model_config.get('proves-unsat', False)
|
|
527
|
+
|
|
528
|
+
def get_model_status(self, model_type: str) -> Tuple[Optional[str], Optional[str]]:
|
|
529
|
+
"""Return a model's health status and an optional detail string.
|
|
530
|
+
|
|
531
|
+
Status is ``'broken'`` (known to produce wrong results), ``'experimental'``
|
|
532
|
+
(unfinished/slow but not known-wrong), or ``None`` (ordinary). The detail,
|
|
533
|
+
when present, is a short human-readable reason. Resolves aliases, so
|
|
534
|
+
``compactroe`` reports its canonical entry's status. Never raises - an
|
|
535
|
+
unknown model is simply ``(None, None)``.
|
|
536
|
+
"""
|
|
537
|
+
try:
|
|
538
|
+
_, model_config = self.resolve_model_variant(model_type)
|
|
539
|
+
except (ValueError, ModelRegistryError):
|
|
540
|
+
return None, None
|
|
541
|
+
return model_config.get('status'), model_config.get('status-detail')
|
|
542
|
+
|
|
543
|
+
def get_model_default_tune(self, model_type: str) -> Optional[str]:
|
|
544
|
+
"""Return a model's preferred ``--tune`` preset, or None.
|
|
545
|
+
|
|
546
|
+
A model may pin a non-default backend tune via its ``tune:`` field -
|
|
547
|
+
e.g. ``scored3`` pins ``safe`` to dodge a -deletevars bug. The runner
|
|
548
|
+
uses this as the effective tune when the user passes no ``--tune``.
|
|
549
|
+
Resolves aliases and never raises (unknown model -> None).
|
|
550
|
+
"""
|
|
551
|
+
try:
|
|
552
|
+
_, model_config = self.resolve_model_variant(model_type)
|
|
553
|
+
except (ValueError, ModelRegistryError):
|
|
554
|
+
return None
|
|
555
|
+
return model_config.get('tune')
|
|
556
|
+
|
|
557
|
+
def get_tune_backend(self, solver_name: str) -> str:
|
|
558
|
+
"""Return the backend whose tune presets govern ``--tune`` for a solver.
|
|
559
|
+
|
|
560
|
+
Almost always the solver itself. The exception is the Conjure/Essence
|
|
561
|
+
path: its per-horizon inner solve is delegated to kissat (see
|
|
562
|
+
ConjureSolver), so an Essence run's ``--tune`` presets are kissat's, not
|
|
563
|
+
the tune-less ``conjure`` entry's. Keeping this mapping here means the
|
|
564
|
+
CLI validity check and ``build_*_command`` agree on what a tune means.
|
|
565
|
+
"""
|
|
566
|
+
if self.get_solver_type(solver_name) == 'conjure':
|
|
567
|
+
return 'kissat'
|
|
568
|
+
return solver_name
|
|
569
|
+
|
|
570
|
+
def get_valid_tunes(self, solver_name: str) -> List[str]:
|
|
571
|
+
"""Return the tune preset names a solver's tune backend accepts.
|
|
572
|
+
|
|
573
|
+
Empty when the backend defines no presets (e.g. the upf* wrappers, which
|
|
574
|
+
take no ``--tune``). Raises ModelRegistryError for an unknown solver.
|
|
575
|
+
"""
|
|
576
|
+
backend = self.get_tune_backend(solver_name)
|
|
577
|
+
return list(self.get_solver_config(backend).get('tune', {}).keys())
|
|
578
|
+
|
|
579
|
+
def is_valid_tune(self, solver_name: str, tune: str) -> bool:
|
|
580
|
+
"""True if ``tune`` is a preset the solver's tune backend accepts.
|
|
581
|
+
|
|
582
|
+
Mirrors ``build_*_command`` lookup: a name is honoured only when it is a
|
|
583
|
+
key in the backend's tune dict; anything else silently falls back to
|
|
584
|
+
``default_tune`` - which this check exists to turn into an explicit error
|
|
585
|
+
so a mistyped ``--tune`` cannot quietly run the default configuration.
|
|
586
|
+
"""
|
|
587
|
+
return tune in self.get_valid_tunes(solver_name)
|
|
588
|
+
|
|
589
|
+
@staticmethod
|
|
590
|
+
def _effective_tune(solver_config: Dict[str, Any], tune: Optional[str],
|
|
591
|
+
key: str) -> Optional[str]:
|
|
592
|
+
"""Resolve the tune name whose preset carries ``key``, else default_tune.
|
|
593
|
+
|
|
594
|
+
The lenient lookup shared by the ``build_*_command`` / ``get_upf_*``
|
|
595
|
+
helpers: an explicit ``tune`` wins only if its preset actually supplies
|
|
596
|
+
this backend's key (e.g. ``'search'``, ``'planner'``, ``'engine'``,
|
|
597
|
+
``'fd_search'``, or a savilerow ``sat_args``/``minion_args`` key);
|
|
598
|
+
otherwise fall back to the solver's ``default_tune``. Callers then read
|
|
599
|
+
the value out of the resolved preset themselves.
|
|
600
|
+
"""
|
|
601
|
+
tunes = solver_config.get('tune', {})
|
|
602
|
+
if tune and key in tunes.get(tune, {}):
|
|
603
|
+
return tune
|
|
604
|
+
return solver_config.get('default_tune')
|
|
605
|
+
|
|
606
|
+
def get_upf_engine(self, solver_name: str,
|
|
607
|
+
tune: Optional[str] = None) -> Optional[str]:
|
|
608
|
+
"""Return the UPF OneshotPlanner engine for a upfplan solver under ``tune``.
|
|
609
|
+
|
|
610
|
+
The upf* solvers pick their engine the way savilerow picks ``sat_args`` and
|
|
611
|
+
the direct ``enhsp`` solver picks its ``-planner``: from the effective
|
|
612
|
+
``--tune`` preset (its ``engine`` key), falling back to the solver's
|
|
613
|
+
``default_tune`` and then to the base ``engine_name``. This is what makes
|
|
614
|
+
optimality a ``--tune`` dial rather than a separate solver - ``upfenhsp``
|
|
615
|
+
resolves to the metric-optimal ``enhsp-opt`` by default and ``--tune
|
|
616
|
+
satisficing`` swaps in the plain ``enhsp`` engine. A solver with no ``tune``
|
|
617
|
+
block (upffd/upfsymk) simply returns its ``engine_name``.
|
|
618
|
+
|
|
619
|
+
Returns None only when the solver defines neither a tune engine nor an
|
|
620
|
+
``engine_name``; the caller then applies its own hardcoded default.
|
|
621
|
+
"""
|
|
622
|
+
solver_config = self.get_solver_config(solver_name)
|
|
623
|
+
tunes = solver_config.get('tune', {})
|
|
624
|
+
name = self._effective_tune(solver_config, tune, 'engine')
|
|
625
|
+
return tunes.get(name, {}).get('engine') or solver_config.get('engine_name')
|
|
626
|
+
|
|
627
|
+
def get_upf_fd_search(self, solver_name: str,
|
|
628
|
+
tune: Optional[str] = None) -> Optional[str]:
|
|
629
|
+
"""Return the Fast Downward search config for a upfplan solver under ``tune``.
|
|
630
|
+
|
|
631
|
+
The companion to :meth:`get_upf_engine` for the fd-driven upf* solvers
|
|
632
|
+
(``upffd``): the search strategy (optimal blind A* vs satisficing greedy) is a
|
|
633
|
+
``--tune`` preset's ``fd_search`` string, resolved the same way - an explicit
|
|
634
|
+
tune with an ``fd_search`` wins, else ``default_tune``, else the base
|
|
635
|
+
``fd_search`` field. UpfPlanSolver forwards the result to the engine as
|
|
636
|
+
``--fd-search`` (--domain route) or through ``compile_and_solve`` (--build
|
|
637
|
+
route). Returns None for solvers that pin no search (enhsp/symk ignore it).
|
|
638
|
+
"""
|
|
639
|
+
solver_config = self.get_solver_config(solver_name)
|
|
640
|
+
tunes = solver_config.get('tune', {})
|
|
641
|
+
name = self._effective_tune(solver_config, tune, 'fd_search')
|
|
642
|
+
return tunes.get(name, {}).get('fd_search') or solver_config.get('fd_search')
|
|
643
|
+
|
|
644
|
+
def is_optimization_model(self, model_type: str) -> bool:
|
|
645
|
+
"""
|
|
646
|
+
Check if a model is an optimization model (has minimising/maximising objective).
|
|
647
|
+
|
|
648
|
+
Args:
|
|
649
|
+
model_type: The model type.
|
|
650
|
+
|
|
651
|
+
Returns:
|
|
652
|
+
True if the model has optimization, False otherwise.
|
|
653
|
+
"""
|
|
654
|
+
try:
|
|
655
|
+
_, model_config = self.resolve_model_variant(model_type)
|
|
656
|
+
return model_config.get('opt', False)
|
|
657
|
+
except Exception:
|
|
658
|
+
return False
|
|
659
|
+
|
|
660
|
+
def list_prob2any_formats(self) -> List[str]:
|
|
661
|
+
"""
|
|
662
|
+
List all available prob2any format names from the model registry.
|
|
663
|
+
|
|
664
|
+
Returns:
|
|
665
|
+
List of format names that are supported by prob2any.
|
|
666
|
+
"""
|
|
667
|
+
formats = set()
|
|
668
|
+
for model_config in self.config['models'].values():
|
|
669
|
+
format_name = model_config.get('format-name')
|
|
670
|
+
if format_name:
|
|
671
|
+
formats.add(format_name)
|
|
672
|
+
return sorted(list(formats))
|
|
673
|
+
|
|
674
|
+
@staticmethod
|
|
675
|
+
def is_command_available(command: str) -> bool:
|
|
676
|
+
"""
|
|
677
|
+
Check if a command is available in the system PATH.
|
|
678
|
+
|
|
679
|
+
Args:
|
|
680
|
+
command: Command name to check.
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
True if command is available, False otherwise.
|
|
684
|
+
"""
|
|
685
|
+
return shutil.which(command) is not None
|
|
686
|
+
|
|
687
|
+
def get_tune_regime(self, solver_name: str, tune: Optional[str] = None) -> Optional[str]:
|
|
688
|
+
"""The *regime* of the effective tune for ``solver_name`` (the ``regime``
|
|
689
|
+
field in the registry), or ``None`` when unset.
|
|
690
|
+
|
|
691
|
+
The regime is the interpreted "how to search" dial, orthogonal to a
|
|
692
|
+
tune's solver-arg set: ``anytime`` keeps emitting improving solutions
|
|
693
|
+
(the fd portfolio "soup"; the CP horizon-scan's continue-scan), ``first``
|
|
694
|
+
stops at the first solution found. A regime-only preset carries just this
|
|
695
|
+
field and no args, so it composes with the default arg-set.
|
|
696
|
+
|
|
697
|
+
The effective tune is ``tune`` when it names a preset of this solver,
|
|
698
|
+
otherwise the solver's ``default_tune`` (whose regime is normally unset,
|
|
699
|
+
so the common case returns ``None``). An unknown solver returns ``None``.
|
|
700
|
+
"""
|
|
701
|
+
try:
|
|
702
|
+
solver_config = self.get_solver_config(solver_name)
|
|
703
|
+
except ModelRegistryError:
|
|
704
|
+
return None
|
|
705
|
+
tunes = solver_config.get('tune', {})
|
|
706
|
+
name = tune if tune in tunes else solver_config.get('default_tune')
|
|
707
|
+
return tunes.get(name, {}).get('regime')
|
|
708
|
+
|
|
709
|
+
def is_anytime_tune(self, solver_name: str, tune: Optional[str] = None) -> bool:
|
|
710
|
+
"""Whether the effective tune for ``solver_name`` runs in the *anytime*
|
|
711
|
+
regime (``regime: anytime`` in the registry).
|
|
712
|
+
|
|
713
|
+
Anytime is the fd portfolio "soup" mode: it emits successive improving
|
|
714
|
+
plans and tolerates portfolio members that bail out (exit code 34, no
|
|
715
|
+
plan, or running out of time). This predicate is the single trigger for
|
|
716
|
+
that runtime handling, replacing the old ``"pddlsoup" in model_type``
|
|
717
|
+
name-sniff — soup is no longer a model but the ``soup`` tune of the fd
|
|
718
|
+
backend, so the behaviour keys off the active tune, not the model name.
|
|
719
|
+
|
|
720
|
+
Thin wrapper over :meth:`get_tune_regime` so the effective-tune resolution
|
|
721
|
+
lives in exactly one place.
|
|
722
|
+
"""
|
|
723
|
+
return self.get_tune_regime(solver_name, tune) == 'anytime'
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def get_default_registry() -> ModelRegistry:
|
|
727
|
+
"""
|
|
728
|
+
Get a default model registry instance.
|
|
729
|
+
|
|
730
|
+
Returns:
|
|
731
|
+
ModelRegistry instance with default configuration.
|
|
732
|
+
"""
|
|
733
|
+
return ModelRegistry()
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def check_model_registry_consistency(registry: Optional[ModelRegistry] = None) -> List[str]:
|
|
737
|
+
"""
|
|
738
|
+
Check internal consistency of the model registry.
|
|
739
|
+
|
|
740
|
+
Currently checks:
|
|
741
|
+
- Models with optimization objectives match their 'opt' flag setting
|
|
742
|
+
|
|
743
|
+
Args:
|
|
744
|
+
registry: Model registry to check. If None, uses default registry.
|
|
745
|
+
|
|
746
|
+
Returns:
|
|
747
|
+
List of inconsistency messages. Empty list means everything is consistent.
|
|
748
|
+
"""
|
|
749
|
+
import re
|
|
750
|
+
|
|
751
|
+
if registry is None:
|
|
752
|
+
registry = get_default_registry()
|
|
753
|
+
|
|
754
|
+
inconsistencies = []
|
|
755
|
+
opt_pattern = re.compile(r'^\s*(min|max)imi(s|z)ing\s', re.MULTILINE)
|
|
756
|
+
|
|
757
|
+
for model_name, model_config in registry.config['models'].items():
|
|
758
|
+
# Skip models without file paths (like tikz)
|
|
759
|
+
if 'path' not in model_config:
|
|
760
|
+
continue
|
|
761
|
+
|
|
762
|
+
model_path = registry._resolve_resource_path(model_config['path'])
|
|
763
|
+
if model_path is None or not model_path.exists():
|
|
764
|
+
# Skip models whose files can't be found
|
|
765
|
+
continue
|
|
766
|
+
|
|
767
|
+
# Check if model file contains optimization directive
|
|
768
|
+
try:
|
|
769
|
+
with open(model_path, 'r') as f:
|
|
770
|
+
content = f.read()
|
|
771
|
+
has_opt_directive = bool(opt_pattern.search(content))
|
|
772
|
+
|
|
773
|
+
# Check against opt flag
|
|
774
|
+
has_opt_flag = model_config.get('opt', False)
|
|
775
|
+
|
|
776
|
+
if has_opt_directive and not has_opt_flag:
|
|
777
|
+
inconsistencies.append(
|
|
778
|
+
f"Model '{model_name}' has optimization directive but 'opt' flag is not set to True"
|
|
779
|
+
)
|
|
780
|
+
elif not has_opt_directive and has_opt_flag:
|
|
781
|
+
inconsistencies.append(
|
|
782
|
+
f"Model '{model_name}' has 'opt' flag set to True but no optimization directive found"
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
except Exception as e:
|
|
786
|
+
inconsistencies.append(
|
|
787
|
+
f"Could not check model '{model_name}': {e}"
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
return inconsistencies
|
|
791
|
+
|
|
792
|
+
|