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.
@@ -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