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,724 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Solver statistics extractors for parsing solver output and extracting metrics.
|
|
3
|
+
|
|
4
|
+
This module provides functions to extract structured statistics from solver output,
|
|
5
|
+
including timing information, search statistics, and solver-specific metrics.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Dict, Any, Optional, List
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from .objective import reported_score_from_solution, reported_score_from_stdout
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Fast Downward stdout metrics that are a plain "search -> capture group 1 -> cast":
|
|
15
|
+
# stats-key -> (regex, caster). Metrics with extra side effects (plan_length also
|
|
16
|
+
# sets solution_found; the solution_found text fallback) stay explicit in
|
|
17
|
+
# extract_fast_downward_stats rather than living in this table.
|
|
18
|
+
_FD_STAT_PATTERNS = {
|
|
19
|
+
'expanded_states': (r'Expanded (\d+) state\(s\)', int),
|
|
20
|
+
'evaluated_states': (r'Evaluated (\d+) state\(s\)', int),
|
|
21
|
+
'generated_states': (r'Generated (\d+) state\(s\)', int),
|
|
22
|
+
'reopened_states': (r'Reopened (\d+) state\(s\)', int),
|
|
23
|
+
'plan_cost': (r'Plan cost: (\d+)', int),
|
|
24
|
+
'peak_memory_kb': (r'Peak memory: (\d+) KB', int),
|
|
25
|
+
'search_time': (r'Search time: (\d+\.\d+)s', float),
|
|
26
|
+
'total_time': (r'Total time: (\d+\.\d+)s', float),
|
|
27
|
+
'planner_time': (r'Planner time: (\d+\.\d+)s', float),
|
|
28
|
+
'translator_variables': (r'Translator variables: (\d+)', int),
|
|
29
|
+
'translator_operators': (r'Translator operators: (\d+)', int),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Cost-layer progression lines from the Fast Downward / SymK search. Both sweep
|
|
34
|
+
# increasing cost bounds and print a timestamped line as each bound N is reached:
|
|
35
|
+
# FD (A*): "[t=0.0055s, 4353 KB] f = 3, 201 evaluated, 131 expanded"
|
|
36
|
+
# SymK: "[t=0.2445s, 4355 KB] BOUND: 3 < 2147483647 [0/1 plans], dir: FW, ..."
|
|
37
|
+
# The final layer ends at the search-end line, "Solution found!" (FD) or
|
|
38
|
+
# "Actual search time:" (SymK).
|
|
39
|
+
_COST_LAYER_RE = re.compile(
|
|
40
|
+
r'\[t=([\d.]+)s,[^\]]*\]\s*(?:f = (\d+),|BOUND: (\d+) <)')
|
|
41
|
+
_SEARCH_END_RE = re.compile(
|
|
42
|
+
r'\[t=([\d.]+)s,[^\]]*\]\s*(?:Solution found!|Actual search time:)')
|
|
43
|
+
# Any progress timestamp, used to bound the last (incomplete) layer of a search
|
|
44
|
+
# that was cut off before printing a clean end line (a timeout / external kill).
|
|
45
|
+
_ANY_TIMESTAMP_RE = re.compile(r'\[t=([\d.]+)s,')
|
|
46
|
+
# A plan was actually found (so the deepest bound reached carries the solution);
|
|
47
|
+
# absent on a timeout, and on a completed-but-unsolved search.
|
|
48
|
+
_SOLUTION_FOUND_RE = re.compile(r'Solution found|Plan length: \d+ step|Best plan:')
|
|
49
|
+
# Fast Downward / SymK translator (grounding) wall-clock: "Done! [0.16s CPU,
|
|
50
|
+
# 0.143s wall-clock]". The bracketed form is the translator summary; the plain
|
|
51
|
+
# "... done!" progress lines don't match.
|
|
52
|
+
_TRANSLATE_TIME_RE = re.compile(r'Done! \[[\d.]+s CPU, ([\d.]+)s wall-clock\]')
|
|
53
|
+
# UPF's own model-transform (compilation) time, printed by the UPF CLI before
|
|
54
|
+
# the engine runs - the analogue of Savile Row tailoring / Conjure refinement.
|
|
55
|
+
_UP_COMPILE_TIME_RE = re.compile(r'UP compilation time: ([\d.]+)s')
|
|
56
|
+
|
|
57
|
+
# ENHSP prints its timing/search summary in a different shape from Fast Downward's.
|
|
58
|
+
# Aggregate times are in MILLISECONDS (note "Grounding Time:" alone carries no
|
|
59
|
+
# "(msec)" suffix); ``grounding_time`` is ENHSP's lifted-to-grounded step, the
|
|
60
|
+
# preprocessing analogue of FD's translate time and the dominant cost on hard
|
|
61
|
+
# instances. ``H1 Setup Time`` appears only for heuristic configs - a blind search
|
|
62
|
+
# omits it. All folded into seconds on capture.
|
|
63
|
+
_ENHSP_SEARCH_MS_RE = re.compile(r'Search Time \(msec\):\s*(\d+)')
|
|
64
|
+
_ENHSP_TIME_PATTERNS = {
|
|
65
|
+
'grounding_time': re.compile(r'Grounding Time:\s*(\d+)'),
|
|
66
|
+
'h1_setup_time': re.compile(r'H1 Setup Time \(msec\):\s*(\d+)'),
|
|
67
|
+
'search_time': _ENHSP_SEARCH_MS_RE,
|
|
68
|
+
'heuristic_time': re.compile(r'Heuristic Time \(msec\):\s*(\d+)'),
|
|
69
|
+
'planning_time': re.compile(r'Planning Time \(msec\):\s*(\d+)'),
|
|
70
|
+
}
|
|
71
|
+
# The final summary totals (line-anchored so they don't match the per-progress-line
|
|
72
|
+
# "(Expanded Nodes: N, Evaluated States: N" that appears mid-f(n)-line).
|
|
73
|
+
_ENHSP_COUNT_PATTERNS = {
|
|
74
|
+
'expanded_states': re.compile(r'^Expanded Nodes:\s*(\d+)', re.MULTILINE),
|
|
75
|
+
'evaluated_states': re.compile(r'^States Evaluated:\s*(\d+)', re.MULTILINE),
|
|
76
|
+
}
|
|
77
|
+
# One optimising-search cost-bound line, e.g.
|
|
78
|
+
# "f(n) = 4.0 (Expanded Nodes: 426, Evaluated States: 771, Time: 0.118) Frontier Size: 345"
|
|
79
|
+
# the f-value (an integer cost printed as a float) and its search-relative
|
|
80
|
+
# timestamp in seconds - the ENHSP analogue of FD's "[t=Ts] f = N". A satisficing
|
|
81
|
+
# search instead prints " g(n)= G h(n)= H" milestones with NO timestamps, which
|
|
82
|
+
# this deliberately does not match (so satisficing runs yield no cost-layers).
|
|
83
|
+
_ENHSP_LAYER_RE = re.compile(r'f\(n\)\s*=\s*([\d.]+)\s*\([^)]*Time:\s*([\d.]+)\)')
|
|
84
|
+
# A plan was found (the deepest bound carries the solution); ENHSP prints both on
|
|
85
|
+
# success, neither on a timeout / unsolvable.
|
|
86
|
+
_ENHSP_SOLVED_RE = re.compile(r'Problem Solved|Found Plan:')
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SolverStatsExtractor:
|
|
90
|
+
"""Extract structured statistics from solver output."""
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def extract_search_cost_layers(stdout: str,
|
|
94
|
+
end_hint: Optional[float] = None) -> Dict[str, Any]:
|
|
95
|
+
"""Recover the per-cost-layer progression of an A* / symbolic search.
|
|
96
|
+
|
|
97
|
+
Fast Downward (A*) and SymK (symbolic uniform-cost) sweep increasing
|
|
98
|
+
cost bounds, printing a timestamped line as each bound N is reached.
|
|
99
|
+
Bounds ``1..plan_cost`` are the search's own notion of "horizon" - the
|
|
100
|
+
analogue of a Savile Row horizon scan - so recover them as a per-layer
|
|
101
|
+
timing curve. Timestamps are relative to the search process, so the
|
|
102
|
+
parts partition its wall-clock exactly: ``search_setup_time`` (before
|
|
103
|
+
bound 1) plus the layer durations sum to the search-end time.
|
|
104
|
+
|
|
105
|
+
A search cut off before printing a clean end line (a timeout / external
|
|
106
|
+
kill) still yields its layers: the final, incomplete layer is bounded by
|
|
107
|
+
the latest progress timestamp seen - or by ``end_hint`` (a search-relative
|
|
108
|
+
kill time, typically the phase duration minus the translate time) when the
|
|
109
|
+
caller supplies it, since the search keeps working the deepest bound after
|
|
110
|
+
its last printed line. No bound is marked as bearing a solution.
|
|
111
|
+
|
|
112
|
+
Returns (keys omitted when the solver prints no cost-layer lines at all):
|
|
113
|
+
- ``cost_layers``: list of ``(N, duration, found_solution)`` for
|
|
114
|
+
N >= 1, duration the wall-clock spent at bound N (gap to the next
|
|
115
|
+
bound, or to the search end for the last), found_solution marking
|
|
116
|
+
the final, solution-bearing bound (never set on a timed-out run).
|
|
117
|
+
- ``search_setup_time``: wall-clock before bound 1 is reached (reading
|
|
118
|
+
the task, building successor generators / symbolic machinery) - the
|
|
119
|
+
search-side preprocessing that dominates symbolic search on large
|
|
120
|
+
tasks.
|
|
121
|
+
"""
|
|
122
|
+
by_n = {} # bound N -> timestamp (last occurrence wins)
|
|
123
|
+
for m in _COST_LAYER_RE.finditer(stdout):
|
|
124
|
+
t = float(m.group(1))
|
|
125
|
+
n = int(m.group(2) if m.group(2) is not None else m.group(3))
|
|
126
|
+
by_n[n] = t
|
|
127
|
+
if not by_n:
|
|
128
|
+
return {}
|
|
129
|
+
|
|
130
|
+
# Bound the final layer. A clean end line (Solution found! / Actual
|
|
131
|
+
# search time:) means the search finished; without one it was cut off
|
|
132
|
+
# (timeout / external kill), so fall back to the latest progress
|
|
133
|
+
# timestamp seen - the last layer is then incomplete, and no bound
|
|
134
|
+
# carries a solution.
|
|
135
|
+
end_match = _SEARCH_END_RE.search(stdout)
|
|
136
|
+
if end_match is not None:
|
|
137
|
+
end_t = float(end_match.group(1))
|
|
138
|
+
else:
|
|
139
|
+
# Timed out: the last progress timestamp, extended to the caller's
|
|
140
|
+
# search-relative kill time (end_hint) when available - the search
|
|
141
|
+
# keeps working the deepest bound after its last printed line, so
|
|
142
|
+
# without this the final, blown-up layer collapses to ~0.
|
|
143
|
+
last_seen = max(float(t) for t in _ANY_TIMESTAMP_RE.findall(stdout))
|
|
144
|
+
end_t = max(last_seen, end_hint) if end_hint is not None else last_seen
|
|
145
|
+
solved = _SOLUTION_FOUND_RE.search(stdout) is not None
|
|
146
|
+
|
|
147
|
+
return SolverStatsExtractor._assemble_cost_layers(by_n, end_t, solved)
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _assemble_cost_layers(by_n: Dict[int, float], end_t: float,
|
|
151
|
+
solved: bool) -> Dict[str, Any]:
|
|
152
|
+
"""Build ``cost_layers`` + ``search_setup_time`` from a ``{bound: timestamp}`` map.
|
|
153
|
+
|
|
154
|
+
Shared by the Fast Downward / SymK (``[t=Ts] f = N``) and ENHSP
|
|
155
|
+
(``f(n) = N (... Time: T)``) parsers, which differ only in how they read
|
|
156
|
+
the per-bound timestamps and the search-end time; the layering is
|
|
157
|
+
identical, so it lives here:
|
|
158
|
+
|
|
159
|
+
- ``search_setup_time`` is the timestamp of the first tracked bound
|
|
160
|
+
(>= 1) - reaching it ends the search-side setup (task load,
|
|
161
|
+
successor/symbolic machinery, the trivial bound-0 layer) and starts
|
|
162
|
+
the first real horizon. Bound 1 for FD A* / SymK / ENHSP on a moves
|
|
163
|
+
metric; the first compiled-model cost bound for the UPF score models,
|
|
164
|
+
whose bounds do not start at 1.
|
|
165
|
+
- each layer's duration is the gap to the next bound (or to ``end_t``
|
|
166
|
+
for the deepest), and only the deepest bound of a solved search bears
|
|
167
|
+
the solution (a timed-out run's deepest bound was reached but not
|
|
168
|
+
cleared). ``by_n`` empty -> ``{}``.
|
|
169
|
+
"""
|
|
170
|
+
if not by_n:
|
|
171
|
+
return {}
|
|
172
|
+
ordered = sorted(by_n.items()) # [(N, T), ...] by N
|
|
173
|
+
max_n = ordered[-1][0]
|
|
174
|
+
|
|
175
|
+
result: Dict[str, Any] = {}
|
|
176
|
+
real_bounds = [n for n in by_n if n >= 1]
|
|
177
|
+
if real_bounds:
|
|
178
|
+
result['search_setup_time'] = by_n[min(real_bounds)]
|
|
179
|
+
|
|
180
|
+
cost_layers = []
|
|
181
|
+
for idx, (n, t) in enumerate(ordered):
|
|
182
|
+
if n < 1:
|
|
183
|
+
continue
|
|
184
|
+
next_t = ordered[idx + 1][1] if idx + 1 < len(ordered) else end_t
|
|
185
|
+
cost_layers.append((n, max(0.0, next_t - t), solved and n == max_n))
|
|
186
|
+
if cost_layers:
|
|
187
|
+
result['cost_layers'] = cost_layers
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def extract_enhsp_search_layers(text: str,
|
|
192
|
+
end_hint: Optional[float] = None) -> Dict[str, Any]:
|
|
193
|
+
"""Recover ENHSP's optimising-search cost-bound progression as cost_layers.
|
|
194
|
+
|
|
195
|
+
Under an optimising search (opt-blind / opt-hrmax, direct or via UPF)
|
|
196
|
+
ENHSP prints ``f(n) = N (... Time: T) Frontier Size: F`` as each new
|
|
197
|
+
f-bound N is reached, T the search-relative time in seconds - the same
|
|
198
|
+
shape as Fast Downward's cost-layers, fed through the shared assembler.
|
|
199
|
+
The sweep ends at ENHSP's reported ``Search Time (msec)`` (the exact
|
|
200
|
+
post-grounding search wall-clock, tighter than the last printed f(n)
|
|
201
|
+
timestamp); on a timeout that line is absent, so fall back to the last
|
|
202
|
+
f(n) timestamp, extended to ``end_hint`` (the phase duration less
|
|
203
|
+
grounding) when supplied - exactly as the FD parser bounds a cut-off
|
|
204
|
+
final layer. Returns ``{}`` for a satisficing run, whose ``g(n)=``
|
|
205
|
+
milestones carry no timestamps and so no layers.
|
|
206
|
+
"""
|
|
207
|
+
by_n: Dict[int, float] = {}
|
|
208
|
+
for m in _ENHSP_LAYER_RE.finditer(text):
|
|
209
|
+
# f is an integer cost printed as a float ("4.0"); last occurrence
|
|
210
|
+
# of a bound wins, matching the FD parser.
|
|
211
|
+
by_n[int(float(m.group(1)))] = float(m.group(2))
|
|
212
|
+
if not by_n:
|
|
213
|
+
return {}
|
|
214
|
+
|
|
215
|
+
end_match = _ENHSP_SEARCH_MS_RE.search(text)
|
|
216
|
+
if end_match is not None:
|
|
217
|
+
end_t = int(end_match.group(1)) / 1000.0
|
|
218
|
+
else:
|
|
219
|
+
last_seen = max(by_n.values())
|
|
220
|
+
end_t = max(last_seen, end_hint) if end_hint is not None else last_seen
|
|
221
|
+
solved = _ENHSP_SOLVED_RE.search(text) is not None
|
|
222
|
+
|
|
223
|
+
return SolverStatsExtractor._assemble_cost_layers(by_n, end_t, solved)
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def _extract_enhsp_engine_stats(text: str,
|
|
227
|
+
duration: Optional[float] = None) -> Dict[str, Any]:
|
|
228
|
+
"""ENHSP engine numbers shared by the direct and via-UPF routes.
|
|
229
|
+
|
|
230
|
+
Parses ENHSP's aggregate times (milliseconds -> seconds), the final
|
|
231
|
+
expanded/evaluated counts, and the optimising cost-layers. Route-specific
|
|
232
|
+
outcome fields (``solution_found`` / ``plan_length`` / ``score``) are left
|
|
233
|
+
to the callers, which read them differently: the direct route from ENHSP's
|
|
234
|
+
own ``Problem Solved`` / ``Plan-Length``, the UPF route from the UPF CLI's
|
|
235
|
+
``Steps:`` / ``Score:``. Safe to call on a Fast Downward / SymK engine log
|
|
236
|
+
too - none of ENHSP's markers appear there, so it returns ``{}``.
|
|
237
|
+
"""
|
|
238
|
+
stats: Dict[str, Any] = {}
|
|
239
|
+
for key, pat in _ENHSP_TIME_PATTERNS.items():
|
|
240
|
+
m = pat.search(text)
|
|
241
|
+
if m:
|
|
242
|
+
stats[key] = int(m.group(1)) / 1000.0
|
|
243
|
+
for key, pat in _ENHSP_COUNT_PATTERNS.items():
|
|
244
|
+
m = pat.search(text)
|
|
245
|
+
if m:
|
|
246
|
+
stats[key] = int(m.group(1))
|
|
247
|
+
|
|
248
|
+
# A timed-out optimising run has no "Search Time" line, so bound the final
|
|
249
|
+
# cost-layer at the search-relative kill time: phase duration less
|
|
250
|
+
# grounding (grounding runs before ENHSP's search clock starts), mirroring
|
|
251
|
+
# how the FD extractor derives end_hint = duration - translate_time.
|
|
252
|
+
end_hint = None
|
|
253
|
+
if duration is not None:
|
|
254
|
+
end_hint = duration - stats.get('grounding_time', 0.0)
|
|
255
|
+
stats.update(SolverStatsExtractor.extract_enhsp_search_layers(
|
|
256
|
+
text, end_hint=end_hint))
|
|
257
|
+
return stats
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def extract_enhsp_stats(stdout: str, stderr: str,
|
|
261
|
+
duration: Optional[float] = None) -> Dict[str, Any]:
|
|
262
|
+
"""Extract statistics from a direct ENHSP run (the ``enhsp`` solver).
|
|
263
|
+
|
|
264
|
+
ENHSP writes its progress/summary to stdout (stderr folded in defensively).
|
|
265
|
+
On top of the shared engine numbers this reads ENHSP's own outcome: it
|
|
266
|
+
prints ``Problem Solved`` / ``Found Plan:`` on success and a
|
|
267
|
+
``Problem Detected as Unsolvable`` line on failure; ``Plan-Length:`` and
|
|
268
|
+
``Metric (Search):`` give the plan length and (optimised) cost.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
stdout: Standard output from ENHSP execution.
|
|
272
|
+
stderr: Standard error from ENHSP execution.
|
|
273
|
+
duration: wall-clock of the solving phase, used only to bound a
|
|
274
|
+
timed-out final cost-layer (see ``_extract_enhsp_engine_stats``).
|
|
275
|
+
"""
|
|
276
|
+
text = f"{stdout}\n{stderr}"
|
|
277
|
+
stats = SolverStatsExtractor._extract_enhsp_engine_stats(text, duration=duration)
|
|
278
|
+
|
|
279
|
+
if _ENHSP_SOLVED_RE.search(text):
|
|
280
|
+
stats['solution_found'] = True
|
|
281
|
+
elif 'nsolvable' in text or 'No plan' in text:
|
|
282
|
+
stats['solution_found'] = False
|
|
283
|
+
|
|
284
|
+
plan_length_match = re.search(r'Plan-Length:\s*(\d+)', text)
|
|
285
|
+
if plan_length_match:
|
|
286
|
+
stats['plan_length'] = int(plan_length_match.group(1))
|
|
287
|
+
metric_match = re.search(r'Metric \(Search\):\s*([\d.]+)', text)
|
|
288
|
+
if metric_match:
|
|
289
|
+
stats['plan_cost'] = float(metric_match.group(1))
|
|
290
|
+
return stats
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def extract_savilerow_stats(stdout: str, stderr: str, horizon_number: Optional[int] = None) -> Dict[str, Any]:
|
|
294
|
+
"""
|
|
295
|
+
Extract statistics from Savile Row output.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
stdout: Standard output from Savile Row execution
|
|
299
|
+
stderr: Standard error from Savile Row execution
|
|
300
|
+
horizon_number: Horizon number if called during horizon scan (optional)
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Dictionary containing extracted statistics
|
|
304
|
+
"""
|
|
305
|
+
stats = {}
|
|
306
|
+
|
|
307
|
+
# Extract final horizon number from solution file creation
|
|
308
|
+
solution_match = re.search(r'Created solution file.*-(\d+)\.(?:eprime-)?param\.solution', stdout)
|
|
309
|
+
if solution_match:
|
|
310
|
+
stats['final_horizon'] = int(solution_match.group(1))
|
|
311
|
+
stats['solution_found'] = True
|
|
312
|
+
else:
|
|
313
|
+
stats['solution_found'] = False
|
|
314
|
+
|
|
315
|
+
# Outside horizon-scan mode, ask the consumer for the produced solution's
|
|
316
|
+
# reported objective. During a scan (horizon_number set) this is skipped:
|
|
317
|
+
# the continue-scan decision reads objective values from .obj files, so
|
|
318
|
+
# parsing every horizon's solution here would be needlessly expensive.
|
|
319
|
+
solution_file_match = re.search(r'Created solution file (.+\.param\.solution)', stdout)
|
|
320
|
+
if solution_file_match and horizon_number is None:
|
|
321
|
+
reported = reported_score_from_solution(solution_file_match.group(1))
|
|
322
|
+
if reported is not None:
|
|
323
|
+
stats['score'] = reported
|
|
324
|
+
|
|
325
|
+
# Extract SAT solver information
|
|
326
|
+
if 'Created output SAT file' in stdout:
|
|
327
|
+
stats['used_sat_solver'] = True
|
|
328
|
+
|
|
329
|
+
# Look for warnings about intervals
|
|
330
|
+
interval_warning_count = stdout.count('WARNING: interval')
|
|
331
|
+
if interval_warning_count > 0:
|
|
332
|
+
stats['interval_warnings'] = interval_warning_count
|
|
333
|
+
|
|
334
|
+
# Count all warnings from both stdout and stderr
|
|
335
|
+
total_warnings = stdout.count('WARNING:') + stderr.count('WARNING:')
|
|
336
|
+
if total_warnings > 0:
|
|
337
|
+
stats['warning_count'] = total_warnings
|
|
338
|
+
|
|
339
|
+
# Check for timeout or other termination reasons
|
|
340
|
+
if 'timeout' in stdout.lower() or 'timeout' in stderr.lower():
|
|
341
|
+
stats['timed_out'] = True
|
|
342
|
+
|
|
343
|
+
return stats
|
|
344
|
+
|
|
345
|
+
@staticmethod
|
|
346
|
+
def extract_savilerow_info(info_path) -> Dict[str, Any]:
|
|
347
|
+
"""Parse every ``Key:Value`` pair from a Savile Row ``.info`` stats file.
|
|
348
|
+
|
|
349
|
+
Savile Row writes ``<param>.info`` alongside each run: labelled
|
|
350
|
+
``Key:Value`` lines carrying not just the two totals
|
|
351
|
+
(``SavileRowTotalTime`` / ``SolverTotalTime``, see
|
|
352
|
+
:meth:`extract_savilerow_info_times`) but the encoding and search
|
|
353
|
+
measurements a report may want to column up - ``SATVars``,
|
|
354
|
+
``SATClauses``, ``SolverNodes``, ``SolverSatisfiable``, and whatever else
|
|
355
|
+
the running Savile Row emits. Keys are returned **verbatim**, so this
|
|
356
|
+
stays a faithful view of the file rather than a curated subset that has
|
|
357
|
+
to grow every time a consumer wants one more field.
|
|
358
|
+
|
|
359
|
+
Values are coerced to ``int`` where they look like one, else ``float``,
|
|
360
|
+
else left as the stripped string, so a caller can use them numerically
|
|
361
|
+
without re-parsing. Lines with no ``:`` are skipped. Returns ``{}`` when
|
|
362
|
+
the file does not exist or cannot be read - a hard timeout can kill
|
|
363
|
+
Savile Row before it writes the file, and older runs may have had it
|
|
364
|
+
cleaned up.
|
|
365
|
+
"""
|
|
366
|
+
info_path = Path(info_path)
|
|
367
|
+
if not info_path.exists():
|
|
368
|
+
return {}
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
text = info_path.read_text()
|
|
372
|
+
except OSError:
|
|
373
|
+
return {}
|
|
374
|
+
|
|
375
|
+
info: Dict[str, Any] = {}
|
|
376
|
+
for line in text.splitlines():
|
|
377
|
+
key, sep, value = line.partition(':')
|
|
378
|
+
if not sep:
|
|
379
|
+
continue
|
|
380
|
+
key = key.strip()
|
|
381
|
+
if not key:
|
|
382
|
+
continue
|
|
383
|
+
value = value.strip()
|
|
384
|
+
try:
|
|
385
|
+
info[key] = int(value)
|
|
386
|
+
except ValueError:
|
|
387
|
+
try:
|
|
388
|
+
info[key] = float(value)
|
|
389
|
+
except ValueError:
|
|
390
|
+
info[key] = value
|
|
391
|
+
return info
|
|
392
|
+
|
|
393
|
+
@staticmethod
|
|
394
|
+
def extract_savilerow_info_times(info_path) -> Dict[str, Any]:
|
|
395
|
+
"""Parse a Savile Row ``.info`` stats file for the tailoring/solver split.
|
|
396
|
+
|
|
397
|
+
The two totals the phase model cares about: ``SavileRowTotalTime`` (time
|
|
398
|
+
spent tailoring the model - flattening, CSE, SAT encoding) and
|
|
399
|
+
``SolverTotalTime`` (time the backend solver itself ran). The phase
|
|
400
|
+
``duration`` we already record is the wall-clock total (these two plus
|
|
401
|
+
runsolver/process overhead), and the split between them is neither fixed
|
|
402
|
+
nor predictable from that total, so recover the two components here to
|
|
403
|
+
report and plot them separately.
|
|
404
|
+
|
|
405
|
+
Returns a dict with ``savilerow_time`` and/or ``solver_time`` (floats),
|
|
406
|
+
omitting either key that is absent or unparseable, and ``{}`` when the
|
|
407
|
+
file is missing. A caller that wants the rest of the file (SAT encoding
|
|
408
|
+
size, solver nodes, ...) uses :meth:`extract_savilerow_info`, of which
|
|
409
|
+
this is a renaming subset.
|
|
410
|
+
"""
|
|
411
|
+
info = SolverStatsExtractor.extract_savilerow_info(info_path)
|
|
412
|
+
|
|
413
|
+
key_map = {
|
|
414
|
+
'SavileRowTotalTime': 'savilerow_time',
|
|
415
|
+
'SolverTotalTime': 'solver_time',
|
|
416
|
+
}
|
|
417
|
+
stats: Dict[str, Any] = {}
|
|
418
|
+
for src, dst in key_map.items():
|
|
419
|
+
if src not in info:
|
|
420
|
+
continue
|
|
421
|
+
try:
|
|
422
|
+
# Always a float, whatever the file's integer-ness: these feed a
|
|
423
|
+
# timing model whose other components are floats.
|
|
424
|
+
stats[dst] = float(info[src])
|
|
425
|
+
except (TypeError, ValueError):
|
|
426
|
+
continue
|
|
427
|
+
return stats
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
def extract_fast_downward_stats(stdout: str, stderr: str,
|
|
431
|
+
duration: Optional[float] = None) -> Dict[str, Any]:
|
|
432
|
+
"""
|
|
433
|
+
Extract statistics from Fast Downward output.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
stdout: Standard output from Fast Downward execution
|
|
437
|
+
stderr: Standard error from Fast Downward execution
|
|
438
|
+
duration: wall-clock of the whole solving phase, if known. Used only
|
|
439
|
+
to bound the final cost-layer of a timed-out search: the search
|
|
440
|
+
timeline is relative to search start, so ``duration -
|
|
441
|
+
translate_time`` approximates the kill time on that timeline.
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
Dictionary containing extracted statistics
|
|
445
|
+
"""
|
|
446
|
+
stats = {}
|
|
447
|
+
|
|
448
|
+
# Search/timing/translator metrics: one "search -> group(1) -> cast" per
|
|
449
|
+
# entry in _FD_STAT_PATTERNS (see that table for the regex/cast per key).
|
|
450
|
+
for key, (pattern, cast) in _FD_STAT_PATTERNS.items():
|
|
451
|
+
match = re.search(pattern, stdout)
|
|
452
|
+
if match:
|
|
453
|
+
stats[key] = cast(match.group(1))
|
|
454
|
+
|
|
455
|
+
# Plan length is table-shaped but also marks a solution as found.
|
|
456
|
+
plan_length_match = re.search(r'Plan length: (\d+) step\(s\)', stdout)
|
|
457
|
+
if plan_length_match:
|
|
458
|
+
stats['plan_length'] = int(plan_length_match.group(1))
|
|
459
|
+
stats['solution_found'] = True
|
|
460
|
+
|
|
461
|
+
# Translator (grounding) wall-clock - the coarse "prepare" cost that runs
|
|
462
|
+
# before the search process and grows on large tasks.
|
|
463
|
+
translate_match = _TRANSLATE_TIME_RE.search(stdout)
|
|
464
|
+
if translate_match:
|
|
465
|
+
stats['translate_time'] = float(translate_match.group(1))
|
|
466
|
+
|
|
467
|
+
# Per-cost-layer search progression (f = N / BOUND: N) plus the
|
|
468
|
+
# search-side setup time. Shared by FD (A*) and SymK (symbolic), whose
|
|
469
|
+
# cost bounds are the search's own "horizons". On a timeout, the phase
|
|
470
|
+
# duration less the translate time bounds the final layer's real cost.
|
|
471
|
+
end_hint = None
|
|
472
|
+
if duration is not None:
|
|
473
|
+
end_hint = duration - stats.get('translate_time', 0.0)
|
|
474
|
+
stats.update(SolverStatsExtractor.extract_search_cost_layers(
|
|
475
|
+
stdout, end_hint=end_hint))
|
|
476
|
+
|
|
477
|
+
# Check for solution found (fallback if regex didn't match)
|
|
478
|
+
if not stats.get('solution_found', False):
|
|
479
|
+
if 'Solution found' in stdout or 'Created solution file' in stdout:
|
|
480
|
+
stats['solution_found'] = True
|
|
481
|
+
elif 'No solution' in stdout:
|
|
482
|
+
stats['solution_found'] = False
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
return stats
|
|
486
|
+
|
|
487
|
+
@staticmethod
|
|
488
|
+
def extract_conjure_stats(stdout: str, stderr: str) -> Dict[str, Any]:
|
|
489
|
+
"""
|
|
490
|
+
Extract statistics from Conjure/Essence solver output.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
stdout: Standard output from Conjure execution
|
|
494
|
+
stderr: Standard error from Conjure execution
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
Dictionary containing extracted statistics
|
|
498
|
+
"""
|
|
499
|
+
stats = {}
|
|
500
|
+
|
|
501
|
+
# Look for solution indicators - check negative first
|
|
502
|
+
if 'UNSATISFIABLE' in stdout or 'No solution' in stdout:
|
|
503
|
+
stats['solution_found'] = False
|
|
504
|
+
elif 'Solution found' in stdout or 'SATISFIABLE' in stdout or 'Created solution file' in stdout:
|
|
505
|
+
stats['solution_found'] = True
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
return stats
|
|
509
|
+
|
|
510
|
+
@staticmethod
|
|
511
|
+
def extract_upf_stats(stdout: str, stderr: str) -> Dict[str, Any]:
|
|
512
|
+
"""
|
|
513
|
+
Extract statistics from UPF (Unified Planning Framework) output.
|
|
514
|
+
|
|
515
|
+
The UPF CLI prints a found plan as
|
|
516
|
+
``(action args)`` / ``Actions: [...]`` lines followed by a ``Steps: N``
|
|
517
|
+
summary line (and ``Score: N`` on the scored constructed-build route); a
|
|
518
|
+
no-plan run prints ``No plan found ...`` to stderr and exits non-zero.
|
|
519
|
+
Older/other UPF phrasings (``SOLVED``, ``Solution found``,
|
|
520
|
+
``Plan length: N``, ``UNSOLVABLE``) are still honoured as a fallback.
|
|
521
|
+
|
|
522
|
+
The summary latches ``solution_found`` off this stat and takes ``score``
|
|
523
|
+
from it (``RunSummary.add_phase`` / the score reconciliation), so a solved
|
|
524
|
+
upfplan run must set them here - otherwise it is reported as
|
|
525
|
+
``solution_found=False`` / ``score=None`` despite a real plan.
|
|
526
|
+
|
|
527
|
+
Args:
|
|
528
|
+
stdout: Standard output from UPF execution
|
|
529
|
+
stderr: Standard error from UPF execution
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
Dictionary containing extracted statistics
|
|
533
|
+
"""
|
|
534
|
+
stats = {}
|
|
535
|
+
|
|
536
|
+
# A found plan is signalled by the "Steps: N" summary line (both the
|
|
537
|
+
# PDDL-domain and constructed-build routes print it). No-plan runs say so
|
|
538
|
+
# on stderr/stdout; fall back to the legacy UPF phrasings.
|
|
539
|
+
steps_match = re.search(r'^\s*Steps:\s*(\d+)', stdout, re.MULTILINE)
|
|
540
|
+
if steps_match:
|
|
541
|
+
stats['plan_length'] = int(steps_match.group(1))
|
|
542
|
+
stats['solution_found'] = True
|
|
543
|
+
elif ('No plan found' in stdout or 'No plan found' in stderr
|
|
544
|
+
or 'UNSOLVABLE' in stdout or 'No solution' in stdout):
|
|
545
|
+
stats['solution_found'] = False
|
|
546
|
+
elif 'Solution found' in stdout or 'SOLVED' in stdout:
|
|
547
|
+
stats['solution_found'] = True
|
|
548
|
+
|
|
549
|
+
# Legacy "Plan length: N" phrasing (older UPF output; keep if Steps: absent).
|
|
550
|
+
if 'plan_length' not in stats:
|
|
551
|
+
plan_length_match = re.search(r'Plan length: (\d+)', stdout)
|
|
552
|
+
if plan_length_match:
|
|
553
|
+
stats['plan_length'] = int(plan_length_match.group(1))
|
|
554
|
+
|
|
555
|
+
# Ask the consumer for the produced solution's reported objective; the
|
|
556
|
+
# constructed-build route prints it on stdout (as "Score: N").
|
|
557
|
+
reported = reported_score_from_stdout(stdout)
|
|
558
|
+
if reported is not None:
|
|
559
|
+
stats['score'] = reported
|
|
560
|
+
|
|
561
|
+
# UPF streams the underlying engine's search log (Fast Downward / SymK)
|
|
562
|
+
# to stderr - kept off stdout so the plan stays clean for the .solution -
|
|
563
|
+
# so recover the same cost-layer progression and translator time as the
|
|
564
|
+
# direct fd/symk paths from there. This is what makes a UPF run
|
|
565
|
+
# comparable (horizon curves + a preprocessing baseline). The
|
|
566
|
+
# f = N / BOUND: N / Done! lines belong to the engine log and do not
|
|
567
|
+
# appear in UPF's own plan/score output, so they cannot collide with the
|
|
568
|
+
# parsing above. No end-hint is passed: the phase duration includes the
|
|
569
|
+
# UP compilation, which is not on the engine's search timeline, so it
|
|
570
|
+
# would over-extend a timed-out final layer.
|
|
571
|
+
engine_log = f"{stdout}\n{stderr}"
|
|
572
|
+
translate_match = _TRANSLATE_TIME_RE.search(engine_log)
|
|
573
|
+
if translate_match:
|
|
574
|
+
stats['translate_time'] = float(translate_match.group(1))
|
|
575
|
+
# UPF's model compilation (arrays/integers/usertype removal) that precedes
|
|
576
|
+
# the engine - recorded on stderr by the UPF CLI (constructed --build
|
|
577
|
+
# route only; --domain reads PDDL directly and has none).
|
|
578
|
+
compile_match = _UP_COMPILE_TIME_RE.search(engine_log)
|
|
579
|
+
if compile_match:
|
|
580
|
+
stats['up_compile_time'] = float(compile_match.group(1))
|
|
581
|
+
stats.update(SolverStatsExtractor.extract_search_cost_layers(engine_log))
|
|
582
|
+
|
|
583
|
+
# The UPF engine may be ENHSP rather than Fast Downward / SymK, whose
|
|
584
|
+
# search log has a different shape (grounding time; an
|
|
585
|
+
# "f(n) = N (... Time: T)" progression the FD regex does not match). Its
|
|
586
|
+
# markers are absent from FD/SymK output, so merging ENHSP's engine
|
|
587
|
+
# numbers is a no-op there and recovers the grounding baseline +
|
|
588
|
+
# cost-layers when ENHSP is the engine. No duration/end-hint: the phase
|
|
589
|
+
# wall-clock includes UP compilation, off the engine's search timeline -
|
|
590
|
+
# the same reason the FD cost-layer call above passes none.
|
|
591
|
+
stats.update(SolverStatsExtractor._extract_enhsp_engine_stats(engine_log))
|
|
592
|
+
|
|
593
|
+
return stats
|
|
594
|
+
|
|
595
|
+
@staticmethod
|
|
596
|
+
def extract_generic_stats(stdout: str, stderr: str) -> Dict[str, Any]:
|
|
597
|
+
"""
|
|
598
|
+
Extract generic statistics that work for any solver.
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
stdout: Standard output from solver execution
|
|
602
|
+
stderr: Standard error from solver execution
|
|
603
|
+
|
|
604
|
+
Returns:
|
|
605
|
+
Dictionary containing extracted statistics
|
|
606
|
+
"""
|
|
607
|
+
stats = {}
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# Generic solution indicators - check negative indicators first
|
|
611
|
+
no_solution_indicators = [
|
|
612
|
+
'no solution', 'unsatisfiable', 'unsolvable', 'infeasible'
|
|
613
|
+
]
|
|
614
|
+
solution_indicators = [
|
|
615
|
+
'solution found', 'satisfiable', 'solved', 'optimal',
|
|
616
|
+
'created solution file', 'plan found'
|
|
617
|
+
]
|
|
618
|
+
|
|
619
|
+
stdout_lower = stdout.lower()
|
|
620
|
+
stderr_lower = stderr.lower()
|
|
621
|
+
|
|
622
|
+
# Check no solution indicators first (more specific)
|
|
623
|
+
if any(indicator in stdout_lower or indicator in stderr_lower
|
|
624
|
+
for indicator in no_solution_indicators):
|
|
625
|
+
stats['solution_found'] = False
|
|
626
|
+
elif any(indicator in stdout_lower or indicator in stderr_lower
|
|
627
|
+
for indicator in solution_indicators):
|
|
628
|
+
stats['solution_found'] = True
|
|
629
|
+
|
|
630
|
+
return stats
|
|
631
|
+
|
|
632
|
+
@staticmethod
|
|
633
|
+
def extract_stats(solver: str, stdout: str, stderr: str, duration: Optional[float] = None, horizon_number: Optional[int] = None) -> Dict[str, Any]:
|
|
634
|
+
"""
|
|
635
|
+
Extract statistics based on solver type.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
solver: Name of the solver used
|
|
639
|
+
stdout: Standard output from solver execution
|
|
640
|
+
stderr: Standard error from solver execution
|
|
641
|
+
duration: Internal timing in seconds (optional)
|
|
642
|
+
horizon_number: The horizon number being attempted (optional)
|
|
643
|
+
|
|
644
|
+
Returns:
|
|
645
|
+
Dictionary containing extracted statistics
|
|
646
|
+
"""
|
|
647
|
+
solver_lower = solver.lower()
|
|
648
|
+
|
|
649
|
+
if 'savilerow' in solver_lower:
|
|
650
|
+
stats = SolverStatsExtractor.extract_savilerow_stats(stdout, stderr, horizon_number)
|
|
651
|
+
elif ('fd' in solver_lower or 'fast-downward' in solver_lower
|
|
652
|
+
or 'downward' in solver_lower or 'symk' in solver_lower):
|
|
653
|
+
# SymK shares Fast Downward's output format (incl. the cost-layer
|
|
654
|
+
# progression), so it uses the same extractor. Pass the phase
|
|
655
|
+
# duration so a timed-out search's final layer can be bounded.
|
|
656
|
+
stats = SolverStatsExtractor.extract_fast_downward_stats(
|
|
657
|
+
stdout, stderr, duration=duration)
|
|
658
|
+
elif 'conjure' in solver_lower or 'essence' in solver_lower:
|
|
659
|
+
stats = SolverStatsExtractor.extract_conjure_stats(stdout, stderr)
|
|
660
|
+
elif 'upf' in solver_lower or 'unified-planning' in solver_lower:
|
|
661
|
+
# upfenhsp detects as 'upfplan' and lands here; extract_upf_stats now
|
|
662
|
+
# also recovers ENHSP's engine numbers, so this covers ENHSP-via-UPF.
|
|
663
|
+
stats = SolverStatsExtractor.extract_upf_stats(stdout, stderr)
|
|
664
|
+
elif 'enhsp' in solver_lower:
|
|
665
|
+
# Direct ENHSP: its own stats shape (grounding time, f(n) cost-layers).
|
|
666
|
+
stats = SolverStatsExtractor.extract_enhsp_stats(
|
|
667
|
+
stdout, stderr, duration=duration)
|
|
668
|
+
else:
|
|
669
|
+
stats = SolverStatsExtractor.extract_generic_stats(stdout, stderr)
|
|
670
|
+
|
|
671
|
+
# Add internal timing if provided
|
|
672
|
+
if duration is not None:
|
|
673
|
+
stats['real_time'] = duration
|
|
674
|
+
|
|
675
|
+
# Add horizon number if provided
|
|
676
|
+
if horizon_number is not None:
|
|
677
|
+
stats['horizon_number'] = horizon_number
|
|
678
|
+
|
|
679
|
+
return stats
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def detect_solver_from_command(command: List[str]) -> str:
|
|
683
|
+
"""
|
|
684
|
+
Detect solver type from command line.
|
|
685
|
+
|
|
686
|
+
Args:
|
|
687
|
+
command: Command line arguments
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
Detected solver name
|
|
691
|
+
"""
|
|
692
|
+
if not command:
|
|
693
|
+
return "unknown"
|
|
694
|
+
|
|
695
|
+
cmd_str = " ".join(command).lower()
|
|
696
|
+
|
|
697
|
+
# The UPF CLI (matched by the 'upfplan' substring) must be detected BEFORE the
|
|
698
|
+
# engine-name checks below, because its command embeds the chosen engine (e.g.
|
|
699
|
+
# "-s fast-downward") which would otherwise misroute it to the fast-downward
|
|
700
|
+
# extractor - one that does not understand upfplan's own plan format, so a
|
|
701
|
+
# solved run would report solution_found=False / score=None.
|
|
702
|
+
if 'upfplan' in cmd_str:
|
|
703
|
+
return 'upfplan'
|
|
704
|
+
elif 'savilerow' in cmd_str:
|
|
705
|
+
return 'savilerow'
|
|
706
|
+
elif 'conjure' in cmd_str:
|
|
707
|
+
return 'conjure'
|
|
708
|
+
elif 'enhsp' in cmd_str:
|
|
709
|
+
# Direct ENHSP has its own stats shape (grounding time, f(n) cost-layers),
|
|
710
|
+
# not Fast Downward's - keep it off the fd branch below even if a path
|
|
711
|
+
# token happened to contain 'fd'/'symk'. (upfenhsp is caught by the
|
|
712
|
+
# 'upfplan' check above, so it never reaches here.)
|
|
713
|
+
return 'enhsp'
|
|
714
|
+
elif ('fd' in cmd_str or 'fast-downward' in cmd_str or 'downward' in cmd_str
|
|
715
|
+
or 'symk' in cmd_str):
|
|
716
|
+
# SymK IS Fast Downward (same driver and output format), so its stats -
|
|
717
|
+
# including the BOUND: cost-layer progression - are parsed by the FD
|
|
718
|
+
# extractor. Without this it fell through to the generic extractor and
|
|
719
|
+
# got none of them.
|
|
720
|
+
return 'fast-downward'
|
|
721
|
+
elif 'upf' in cmd_str:
|
|
722
|
+
return 'upf'
|
|
723
|
+
else:
|
|
724
|
+
return command[0] # Use first command as solver name
|