pyflightstream 0.2.0__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.
- pyflightstream/__init__.py +27 -0
- pyflightstream/cases/__init__.py +332 -0
- pyflightstream/cases/matrix_legacy.py +337 -0
- pyflightstream/commands/__init__.py +544 -0
- pyflightstream/commands/_meta.yaml +18 -0
- pyflightstream/commands/actuators.yaml +167 -0
- pyflightstream/commands/advanced_settings.yaml +136 -0
- pyflightstream/commands/aeroelastic_coupling.yaml +172 -0
- pyflightstream/commands/boundary_conditions.yaml +153 -0
- pyflightstream/commands/ccs_wing_mesh.yaml +74 -0
- pyflightstream/commands/coordinate_systems.yaml +123 -0
- pyflightstream/commands/file_io.yaml +82 -0
- pyflightstream/commands/mesh_import_export.yaml +72 -0
- pyflightstream/commands/motion_definitions.yaml +185 -0
- pyflightstream/commands/probe_points.yaml +116 -0
- pyflightstream/commands/runtime_settings.yaml +160 -0
- pyflightstream/commands/scenes.yaml +14 -0
- pyflightstream/commands/script_controls.yaml +39 -0
- pyflightstream/commands/simulation_controls.yaml +30 -0
- pyflightstream/commands/solver_analysis.yaml +118 -0
- pyflightstream/commands/solver_export.yaml +138 -0
- pyflightstream/commands/solver_initialization.yaml +95 -0
- pyflightstream/commands/solver_settings.yaml +120 -0
- pyflightstream/commands/streamlines.yaml +63 -0
- pyflightstream/commands/surface_sections.yaml +145 -0
- pyflightstream/commands/sweeper.yaml +115 -0
- pyflightstream/commands/unsteady_solver.yaml +68 -0
- pyflightstream/commands/volume_sections.yaml +148 -0
- pyflightstream/farfield/__init__.py +613 -0
- pyflightstream/files/__init__.py +375 -0
- pyflightstream/fsi/__init__.py +57 -0
- pyflightstream/fsi/beam.py +417 -0
- pyflightstream/fsi/centrifugal.py +418 -0
- pyflightstream/fsi/cli.py +206 -0
- pyflightstream/fsi/config.py +316 -0
- pyflightstream/fsi/driver.py +501 -0
- pyflightstream/fsi/kinematics.py +153 -0
- pyflightstream/fsi/loads.py +740 -0
- pyflightstream/fsi/nodes.py +463 -0
- pyflightstream/fsi/state.py +181 -0
- pyflightstream/post/__init__.py +18 -0
- pyflightstream/post/writers.py +195 -0
- pyflightstream/probes/__init__.py +453 -0
- pyflightstream/probes/geometry.py +281 -0
- pyflightstream/probes/planar.py +477 -0
- pyflightstream/qa/__init__.py +59 -0
- pyflightstream/qa/cli.py +364 -0
- pyflightstream/qa/compat.py +285 -0
- pyflightstream/qa/drift.py +406 -0
- pyflightstream/qa/geometry.py +421 -0
- pyflightstream/qa/physics.py +1744 -0
- pyflightstream/qa/probes.py +1023 -0
- pyflightstream/qa/references/PHY-01.yaml +38 -0
- pyflightstream/qa/references/PHY-02.yaml +28 -0
- pyflightstream/qa/references/PHY-05.yaml +29 -0
- pyflightstream/qa/references/PHY-06.yaml +90 -0
- pyflightstream/qa/references/SMI-01.yaml +28 -0
- pyflightstream/qa/references/SMI-02.yaml +28 -0
- pyflightstream/qa/specs.py +1405 -0
- pyflightstream/reference.py +465 -0
- pyflightstream/results/__init__.py +547 -0
- pyflightstream/run/__init__.py +677 -0
- pyflightstream/script/__init__.py +474 -0
- pyflightstream/script/helpers.py +844 -0
- pyflightstream/versions.py +191 -0
- pyflightstream-0.2.0.dist-info/METADATA +88 -0
- pyflightstream-0.2.0.dist-info/RECORD +71 -0
- pyflightstream-0.2.0.dist-info/WHEEL +5 -0
- pyflightstream-0.2.0.dist-info/entry_points.txt +3 -0
- pyflightstream-0.2.0.dist-info/licenses/LICENSE +21 -0
- pyflightstream-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1023 @@
|
|
|
1
|
+
"""Tier 2 command-validity probes (SAD Section 11).
|
|
2
|
+
|
|
3
|
+
Pipeline role: produces the run evidence behind the command database.
|
|
4
|
+
Each probe executes exactly one database command in a minimal script on
|
|
5
|
+
a licensed machine and classifies it from three failure signals:
|
|
6
|
+
|
|
7
|
+
1. Sentinel missing: the log exported after the command never appears,
|
|
8
|
+
so script processing aborted at the command.
|
|
9
|
+
2. Log error patterns: error messages between the probe sentinels.
|
|
10
|
+
3. Failed effect assertion: the script continued past the command but
|
|
11
|
+
its observable effect is absent. A command that runs but does
|
|
12
|
+
nothing is ``broken``, not ``verified``.
|
|
13
|
+
|
|
14
|
+
The generated scripts wrap the target command between two sentinel
|
|
15
|
+
``PRINT`` markers, each followed by an ``EXPORT_LOG``, so the exported
|
|
16
|
+
log carries a delimited region that belongs to the target command
|
|
17
|
+
alone. The harness therefore relies on three instrument commands
|
|
18
|
+
(PRINT, EXPORT_LOG, CLOSE_FLIGHTSTREAM); a baseline probe validates
|
|
19
|
+
them before any command is judged, and a baseline failure aborts the
|
|
20
|
+
whole run instead of writing false evidence (a dead license must never
|
|
21
|
+
read as broken commands).
|
|
22
|
+
|
|
23
|
+
Statuses are promoted from the resulting compat report only through
|
|
24
|
+
``pyfs-qa apply-compat`` (:mod:`pyflightstream.qa.compat`), never by
|
|
25
|
+
hand (CLAUDE.md invariant 3).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import enum
|
|
31
|
+
import hashlib
|
|
32
|
+
import re
|
|
33
|
+
import shutil
|
|
34
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
import pyflightstream
|
|
39
|
+
from pyflightstream.commands import CommandRegistry
|
|
40
|
+
from pyflightstream.run import ExecutionResult, Executor, LocalExecutor
|
|
41
|
+
from pyflightstream.script import Script
|
|
42
|
+
from pyflightstream.script.helpers import initialize_solver
|
|
43
|
+
from pyflightstream.versions import FsVersion, resolve
|
|
44
|
+
|
|
45
|
+
_BEGIN = "PYFS_PROBE_BEGIN"
|
|
46
|
+
_END = "PYFS_PROBE_END"
|
|
47
|
+
_BASELINE_MARKER = "PYFS_BASELINE_ALIVE"
|
|
48
|
+
_LOG_BEFORE = "log_before.txt"
|
|
49
|
+
_LOG_AFTER = "log_after.txt"
|
|
50
|
+
_LOG_FINAL = "log_final.txt"
|
|
51
|
+
_DUMP_BEFORE = "dump_before.txt"
|
|
52
|
+
_DUMP_AFTER = "dump_after.txt"
|
|
53
|
+
_SCRIPT_NAME = "probe_script.txt"
|
|
54
|
+
_BASELINE_DIR = "_baseline"
|
|
55
|
+
|
|
56
|
+
#: Conservative error patterns, scanned only between the probe
|
|
57
|
+
#: sentinels so startup noise never blames the target command. The
|
|
58
|
+
#: list grows on real-log evidence, like every other probe input.
|
|
59
|
+
DEFAULT_ERROR_PATTERNS: tuple[str, ...] = (
|
|
60
|
+
r"(?i)\berror\b",
|
|
61
|
+
r"(?i)\bunable\b",
|
|
62
|
+
r"(?i)\bfailed\b",
|
|
63
|
+
r"(?i)\binvalid\b",
|
|
64
|
+
r"(?i)not recognized",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ProbeOutcome(enum.StrEnum):
|
|
69
|
+
"""Judgment of one probe.
|
|
70
|
+
|
|
71
|
+
``verified`` and ``broken`` are promotable evidence; ``unprobed``
|
|
72
|
+
records why no judgment exists (no probe specification yet, not in
|
|
73
|
+
this run's subset, or an inconclusive execution such as a timeout).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
VERIFIED = "verified"
|
|
77
|
+
BROKEN = "broken"
|
|
78
|
+
UNPROBED = "unprobed"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Requires(enum.StrEnum):
|
|
82
|
+
"""Session state a probe needs before its sentinels (prelude tier).
|
|
83
|
+
|
|
84
|
+
``none`` runs on the empty session; ``sim`` opens a local
|
|
85
|
+
simulation file (explicit input, never committed); ``solver`` adds
|
|
86
|
+
the minimal solver setup and INITIALIZE_SOLVER; ``solution`` adds
|
|
87
|
+
a short START_SOLVER run. Each tier used by a run is validated by
|
|
88
|
+
its own baseline first, so a broken prelude downgrades its probes
|
|
89
|
+
to unprobed instead of blaming the target commands.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
NONE = "none"
|
|
93
|
+
SIM = "sim"
|
|
94
|
+
SOLVER = "solver"
|
|
95
|
+
SOLUTION = "solution"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ProbeEnvironmentError(RuntimeError):
|
|
99
|
+
"""The probe environment is unusable, so no command was judged.
|
|
100
|
+
|
|
101
|
+
Raised when the baseline probe fails (solver not starting, license
|
|
102
|
+
checkout failure, log export not landing) or when a probe working
|
|
103
|
+
directory cannot be prepared safely. Environment failures abort the
|
|
104
|
+
run because recording them as broken commands would be false
|
|
105
|
+
evidence.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class ProbeArtifacts:
|
|
111
|
+
"""Everything a probe left behind, handed to the effect assertion.
|
|
112
|
+
|
|
113
|
+
Attributes
|
|
114
|
+
----------
|
|
115
|
+
workdir : Path
|
|
116
|
+
Working directory of this probe; support files and any solver
|
|
117
|
+
outputs land here.
|
|
118
|
+
log_before : str or None
|
|
119
|
+
Log exported after the BEGIN sentinel and before the target
|
|
120
|
+
command; None when the file never appeared.
|
|
121
|
+
log_after : str or None
|
|
122
|
+
Log exported right after the END sentinel and before the
|
|
123
|
+
epilogue; None when the file never appeared (script aborted at
|
|
124
|
+
the target, or halted by design). Exporting it before the
|
|
125
|
+
epilogue keeps abort attribution clean: an epilogue instrument
|
|
126
|
+
that aborts can never be blamed on the target.
|
|
127
|
+
begin_marker, end_marker : str
|
|
128
|
+
The sentinel texts delimiting the target command's log region.
|
|
129
|
+
execution : ExecutionResult
|
|
130
|
+
Typed outcome of the solver process.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
workdir: Path
|
|
134
|
+
log_before: str | None
|
|
135
|
+
log_after: str | None
|
|
136
|
+
begin_marker: str
|
|
137
|
+
end_marker: str
|
|
138
|
+
execution: ExecutionResult
|
|
139
|
+
|
|
140
|
+
def log_final(self) -> str | None:
|
|
141
|
+
"""Return the log exported after the epilogue, if any."""
|
|
142
|
+
return _read_log(self.workdir / _LOG_FINAL)
|
|
143
|
+
|
|
144
|
+
def dump_before(self) -> str | None:
|
|
145
|
+
"""Return the settings dump taken before the target, if any."""
|
|
146
|
+
return _read_log(self.workdir / _DUMP_BEFORE)
|
|
147
|
+
|
|
148
|
+
def dump_after(self) -> str | None:
|
|
149
|
+
"""Return the settings dump taken after the target, if any."""
|
|
150
|
+
return _read_log(self.workdir / _DUMP_AFTER)
|
|
151
|
+
|
|
152
|
+
def target_region(self) -> str:
|
|
153
|
+
"""Return the log lines belonging to the target command alone.
|
|
154
|
+
|
|
155
|
+
The region runs from the last line carrying the BEGIN marker to
|
|
156
|
+
the first later line carrying the END marker, both exclusive,
|
|
157
|
+
in the log exported after the command. Empty when that log or
|
|
158
|
+
the BEGIN marker is missing.
|
|
159
|
+
"""
|
|
160
|
+
if self.log_after is None:
|
|
161
|
+
return ""
|
|
162
|
+
lines = self.log_after.splitlines()
|
|
163
|
+
start = None
|
|
164
|
+
for index, line in enumerate(lines):
|
|
165
|
+
if self.begin_marker in line:
|
|
166
|
+
start = index
|
|
167
|
+
if start is None:
|
|
168
|
+
return ""
|
|
169
|
+
for index in range(start + 1, len(lines)):
|
|
170
|
+
if self.end_marker in lines[index]:
|
|
171
|
+
return "\n".join(lines[start + 1 : index])
|
|
172
|
+
return "\n".join(lines[start + 1 :])
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def printed_line(text: str, marker: str) -> bool:
|
|
176
|
+
"""Return whether ``marker`` was printed as a log message.
|
|
177
|
+
|
|
178
|
+
A line that carries ``PRINT <marker>`` is the script command being
|
|
179
|
+
echoed, not the message itself, and does not count; this keeps the
|
|
180
|
+
check honest on solvers that echo script lines into the log.
|
|
181
|
+
|
|
182
|
+
Parameters
|
|
183
|
+
----------
|
|
184
|
+
text : str
|
|
185
|
+
Log text to scan.
|
|
186
|
+
marker : str
|
|
187
|
+
Exact marker text the probe printed.
|
|
188
|
+
|
|
189
|
+
Returns
|
|
190
|
+
-------
|
|
191
|
+
bool
|
|
192
|
+
True when a genuine message line carries the marker.
|
|
193
|
+
"""
|
|
194
|
+
for line in text.splitlines():
|
|
195
|
+
if marker in line and f"PRINT {marker}" not in line:
|
|
196
|
+
return True
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True)
|
|
201
|
+
class ProbeSpec:
|
|
202
|
+
"""How to probe one command: emission, and the observable effect.
|
|
203
|
+
|
|
204
|
+
Attributes
|
|
205
|
+
----------
|
|
206
|
+
command : str
|
|
207
|
+
Database name of the target command.
|
|
208
|
+
build_target : callable
|
|
209
|
+
``build_target(script, workdir)`` emits the target command
|
|
210
|
+
(validated emission, never ``raw()``) and may write support
|
|
211
|
+
files into ``workdir``.
|
|
212
|
+
assert_effect : callable, optional
|
|
213
|
+
``assert_effect(artifacts) -> bool | None`` checks the
|
|
214
|
+
observable effect: True verifies, False breaks (a command that
|
|
215
|
+
runs but does nothing is broken, not verified, SAD Section
|
|
216
|
+
11), None records unprobed because the current instruments
|
|
217
|
+
cannot observe the effect; a probe may never guess. Mandatory
|
|
218
|
+
unless ``expects_halt``.
|
|
219
|
+
expects_halt : bool
|
|
220
|
+
The command is expected to halt script processing (STOP); the
|
|
221
|
+
halt itself is the asserted effect, so the sentinel logic
|
|
222
|
+
inverts: the log before the command must exist and the one
|
|
223
|
+
after it must never appear.
|
|
224
|
+
requires : Requires
|
|
225
|
+
Prelude tier emitted before the sentinels; see
|
|
226
|
+
:class:`Requires`. Tiers above ``none`` need the local
|
|
227
|
+
simulation file passed to :func:`probe_version` as ``fsm``.
|
|
228
|
+
early_prelude : callable, optional
|
|
229
|
+
``early_prelude(script, workdir)`` emits setup-phase support
|
|
230
|
+
right after OPEN (before the rest of the tier prelude), for
|
|
231
|
+
objects that must exist before solver initialization, such as
|
|
232
|
+
a named coordinate system a later-phase target cites.
|
|
233
|
+
prelude : callable, optional
|
|
234
|
+
``prelude(script, workdir)`` emits spec-specific setup after
|
|
235
|
+
the tier prelude and before the sentinels (for example the
|
|
236
|
+
object a setter manipulates), so a broken support command is
|
|
237
|
+
never blamed on the target.
|
|
238
|
+
epilogue : callable, optional
|
|
239
|
+
``epilogue(script, workdir)`` emits effect instruments after
|
|
240
|
+
the END sentinel (for example an export whose file the effect
|
|
241
|
+
assertion reads); its log lines land outside the target
|
|
242
|
+
region, so instrument errors are not blamed on the target.
|
|
243
|
+
dump_state : bool
|
|
244
|
+
Bracket the target with OUTPUT_SETTINGS_AND_STATUS dumps
|
|
245
|
+
(``dump_before.txt`` and ``dump_after.txt``) for
|
|
246
|
+
state-difference effect assertions.
|
|
247
|
+
effect_note : str
|
|
248
|
+
One sentence naming the asserted effect; quoted in the compat
|
|
249
|
+
report evidence line.
|
|
250
|
+
timeout_s : float, optional
|
|
251
|
+
Per-probe override of the run timeout; halting probes use a
|
|
252
|
+
short one because the hidden solver may idle after the halt.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
command: str
|
|
256
|
+
build_target: Callable[[Script, Path], None]
|
|
257
|
+
assert_effect: Callable[[ProbeArtifacts], bool | None] | None = None
|
|
258
|
+
expects_halt: bool = False
|
|
259
|
+
requires: Requires = Requires.NONE
|
|
260
|
+
early_prelude: Callable[[Script, Path], None] | None = None
|
|
261
|
+
prelude: Callable[[Script, Path], None] | None = None
|
|
262
|
+
epilogue: Callable[[Script, Path], None] | None = None
|
|
263
|
+
dump_state: bool = False
|
|
264
|
+
effect_note: str = ""
|
|
265
|
+
timeout_s: float | None = None
|
|
266
|
+
|
|
267
|
+
def __post_init__(self) -> None:
|
|
268
|
+
"""Reject a probe that could not distinguish broken from verified."""
|
|
269
|
+
if not self.expects_halt and self.assert_effect is None:
|
|
270
|
+
raise ValueError(
|
|
271
|
+
f"probe for {self.command} declares no effect assertion; a command that "
|
|
272
|
+
"runs but does nothing is broken, not verified (SAD Section 11), so "
|
|
273
|
+
"every probe must assert an observable effect"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass(frozen=True)
|
|
278
|
+
class ProbeResult:
|
|
279
|
+
"""Evidence line of one command in one probe run.
|
|
280
|
+
|
|
281
|
+
Attributes
|
|
282
|
+
----------
|
|
283
|
+
command : str
|
|
284
|
+
Database name of the probed command.
|
|
285
|
+
outcome : ProbeOutcome
|
|
286
|
+
The judgment; see :class:`ProbeOutcome`.
|
|
287
|
+
detail : str
|
|
288
|
+
Human-readable evidence sentence behind the judgment.
|
|
289
|
+
sentinel_before, sentinel_after : bool
|
|
290
|
+
Whether each sentinel marker was found printed in its exported
|
|
291
|
+
log; the pair discriminates "aborted at the command" from
|
|
292
|
+
"never ran".
|
|
293
|
+
effect : bool or None
|
|
294
|
+
Effect assertion result; None when it never ran.
|
|
295
|
+
log_errors : tuple of str
|
|
296
|
+
Error lines matched between the sentinels, verbatim.
|
|
297
|
+
wall_time_s : float or None
|
|
298
|
+
Wall-clock time of the solver process, seconds.
|
|
299
|
+
return_code : int or None
|
|
300
|
+
Solver process return code; None when killed on timeout.
|
|
301
|
+
script_sha256 : str or None
|
|
302
|
+
Hash of the generated probe script, for reproducibility.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
command: str
|
|
306
|
+
outcome: ProbeOutcome
|
|
307
|
+
detail: str
|
|
308
|
+
sentinel_before: bool = False
|
|
309
|
+
sentinel_after: bool = False
|
|
310
|
+
effect: bool | None = None
|
|
311
|
+
log_errors: tuple[str, ...] = ()
|
|
312
|
+
wall_time_s: float | None = None
|
|
313
|
+
return_code: int | None = None
|
|
314
|
+
script_sha256: str | None = None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@dataclass(frozen=True)
|
|
318
|
+
class ProbeRun:
|
|
319
|
+
"""One whole probe run: version, environment identity, evidence.
|
|
320
|
+
|
|
321
|
+
Attributes
|
|
322
|
+
----------
|
|
323
|
+
version : str
|
|
324
|
+
Canonical FlightStream version the run targeted.
|
|
325
|
+
solver_identity : tuple of str
|
|
326
|
+
Verbatim log lines naming the solver version and build, taken
|
|
327
|
+
from the baseline probe (FR-18: the printed version string may
|
|
328
|
+
not discriminate hotfix builds; the build number does).
|
|
329
|
+
fs_exe_name : str
|
|
330
|
+
File name of the executable that ran (never the local path;
|
|
331
|
+
executables live outside Git).
|
|
332
|
+
package_version : str
|
|
333
|
+
pyflightstream version that generated the probes.
|
|
334
|
+
results : tuple of ProbeResult
|
|
335
|
+
One evidence line per database command of this version.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
version: str
|
|
339
|
+
solver_identity: tuple[str, ...]
|
|
340
|
+
fs_exe_name: str
|
|
341
|
+
package_version: str
|
|
342
|
+
results: tuple[ProbeResult, ...]
|
|
343
|
+
|
|
344
|
+
def outcome_counts(self) -> dict[str, int]:
|
|
345
|
+
"""Return how many commands landed in each outcome."""
|
|
346
|
+
counts = {outcome.value: 0 for outcome in ProbeOutcome}
|
|
347
|
+
for result in self.results:
|
|
348
|
+
counts[result.outcome.value] += 1
|
|
349
|
+
return counts
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def file_effect(name: str) -> Callable[[ProbeArtifacts], bool]:
|
|
353
|
+
"""Make an effect assertion: the command produced a non-empty file.
|
|
354
|
+
|
|
355
|
+
Strict on absence: an export that runs without error and writes
|
|
356
|
+
nothing is broken, not verified.
|
|
357
|
+
|
|
358
|
+
Parameters
|
|
359
|
+
----------
|
|
360
|
+
name : str
|
|
361
|
+
File name relative to the probe working directory.
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
def check(artifacts: ProbeArtifacts) -> bool:
|
|
365
|
+
path = artifacts.workdir / name
|
|
366
|
+
return path.is_file() and path.stat().st_size > 0
|
|
367
|
+
|
|
368
|
+
return check
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def dump_gained(token: str, strict: bool = False) -> Callable[[ProbeArtifacts], bool | None]:
|
|
372
|
+
"""Make an effect assertion: a distinctive token entered the state dump.
|
|
373
|
+
|
|
374
|
+
The token is a value only the probe would set (for example
|
|
375
|
+
``7.2531``), searched in the OUTPUT_SETTINGS_AND_STATUS dump taken
|
|
376
|
+
after the target (``dump_state`` probes). Absence means None
|
|
377
|
+
(unobservable, unprobed) unless ``strict``, for settings the dump
|
|
378
|
+
is known to expose, where absence is a real no-op (broken).
|
|
379
|
+
|
|
380
|
+
Parameters
|
|
381
|
+
----------
|
|
382
|
+
token : str
|
|
383
|
+
Distinctive text expected in the dump after the command.
|
|
384
|
+
strict : bool
|
|
385
|
+
Whether absence breaks instead of recording unprobed.
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
def check(artifacts: ProbeArtifacts) -> bool | None:
|
|
389
|
+
after = artifacts.dump_after()
|
|
390
|
+
if after is None:
|
|
391
|
+
return False if strict else None
|
|
392
|
+
if token in after:
|
|
393
|
+
return True
|
|
394
|
+
return False if strict else None
|
|
395
|
+
|
|
396
|
+
return check
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def dump_changed() -> Callable[[ProbeArtifacts], bool | None]:
|
|
400
|
+
"""Make an effect assertion: the state dump differs after the target.
|
|
401
|
+
|
|
402
|
+
A difference proves the command acted; an identical dump proves
|
|
403
|
+
nothing (the dump may simply not expose that state), so it records
|
|
404
|
+
None (unprobed), never False.
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
def check(artifacts: ProbeArtifacts) -> bool | None:
|
|
408
|
+
before = artifacts.dump_before()
|
|
409
|
+
after = artifacts.dump_after()
|
|
410
|
+
if before is None or after is None:
|
|
411
|
+
return None
|
|
412
|
+
return True if before != after else None
|
|
413
|
+
|
|
414
|
+
return check
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def region_printed(marker: str) -> Callable[[ProbeArtifacts], bool]:
|
|
418
|
+
"""Make an effect assertion: a marker was printed in the target region."""
|
|
419
|
+
|
|
420
|
+
def check(artifacts: ProbeArtifacts) -> bool:
|
|
421
|
+
return printed_line(artifacts.target_region(), marker)
|
|
422
|
+
|
|
423
|
+
return check
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def emit_solver_setup(script: Script) -> None:
|
|
427
|
+
"""Emit the minimal steady setup preceding INITIALIZE_SOLVER.
|
|
428
|
+
|
|
429
|
+
Constant free stream, sea-level standard atmosphere, and a short
|
|
430
|
+
iteration budget: the smallest state in which the solver
|
|
431
|
+
initializes and runs on an opened simulation (M2 pipeline shape).
|
|
432
|
+
"""
|
|
433
|
+
script.emit("SET_FREESTREAM", "CONSTANT")
|
|
434
|
+
script.emit("AIR_ALTITUDE", 0.0, "METERS")
|
|
435
|
+
script.emit("SOLVER_SET_VELOCITY", 30.0)
|
|
436
|
+
script.emit("SOLVER_SET_REF_VELOCITY", 30.0)
|
|
437
|
+
script.emit("SOLVER_SET_REF_AREA", 1.0)
|
|
438
|
+
script.emit("SOLVER_SET_REF_LENGTH", 1.0)
|
|
439
|
+
script.emit("SOLVER_SET_ITERATIONS", 5)
|
|
440
|
+
script.emit("SET_SOLVER_STEADY")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def emit_tier_prelude(
|
|
444
|
+
script: Script,
|
|
445
|
+
tier: Requires,
|
|
446
|
+
fsm: Path,
|
|
447
|
+
after_open: Callable[[Script], None] | None = None,
|
|
448
|
+
) -> None:
|
|
449
|
+
"""Emit the standard prelude of one tier (SAD Section 11).
|
|
450
|
+
|
|
451
|
+
``sim`` opens the local simulation file; ``solver`` adds the
|
|
452
|
+
minimal steady setup of the M2 pipeline (constant free stream,
|
|
453
|
+
sea-level atmosphere, short iteration budget) and
|
|
454
|
+
INITIALIZE_SOLVER on all boundaries without symmetry; ``solution``
|
|
455
|
+
adds START_SOLVER. Physical relevance is not the point: the tier
|
|
456
|
+
only manufactures the session state the target command needs to
|
|
457
|
+
act on.
|
|
458
|
+
|
|
459
|
+
Parameters
|
|
460
|
+
----------
|
|
461
|
+
script : Script
|
|
462
|
+
Script under construction.
|
|
463
|
+
tier : Requires
|
|
464
|
+
Tier to emit; ``none`` emits nothing.
|
|
465
|
+
fsm : Path
|
|
466
|
+
Local simulation (.fsm) file for the OPEN command.
|
|
467
|
+
after_open : callable, optional
|
|
468
|
+
``after_open(script)`` emits setup-phase support right after
|
|
469
|
+
OPEN, before the solver setup (the ``early_prelude`` hook).
|
|
470
|
+
"""
|
|
471
|
+
if tier is Requires.NONE:
|
|
472
|
+
return
|
|
473
|
+
script.emit("OPEN", fsm)
|
|
474
|
+
if after_open is not None:
|
|
475
|
+
after_open(script)
|
|
476
|
+
if tier is Requires.SIM:
|
|
477
|
+
return
|
|
478
|
+
emit_solver_setup(script)
|
|
479
|
+
initialize_solver(script)
|
|
480
|
+
if tier is Requires.SOLVER:
|
|
481
|
+
return
|
|
482
|
+
script.emit("START_SOLVER")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def generate_probe_script(
|
|
486
|
+
spec: ProbeSpec,
|
|
487
|
+
version: str | FsVersion,
|
|
488
|
+
workdir: Path,
|
|
489
|
+
registry: CommandRegistry | None = None,
|
|
490
|
+
fsm: Path | None = None,
|
|
491
|
+
) -> Script:
|
|
492
|
+
"""Build the probe script for one command, sentinels included.
|
|
493
|
+
|
|
494
|
+
The script is validated emission end to end (no ``raw()``): the
|
|
495
|
+
tier prelude, the spec prelude, the BEGIN sentinel with its log
|
|
496
|
+
export, the target command (bracketed by state dumps when
|
|
497
|
+
``dump_state``), the END sentinel, the spec epilogue, the final
|
|
498
|
+
log export, and CLOSE_FLIGHTSTREAM so the hidden solver exits.
|
|
499
|
+
|
|
500
|
+
Parameters
|
|
501
|
+
----------
|
|
502
|
+
spec : ProbeSpec
|
|
503
|
+
What to probe and how.
|
|
504
|
+
version : str or FsVersion
|
|
505
|
+
Target FlightStream version; emission is validated against it.
|
|
506
|
+
workdir : Path
|
|
507
|
+
Probe working directory; log exports point into it.
|
|
508
|
+
registry : CommandRegistry, optional
|
|
509
|
+
Alternative database, used by tests.
|
|
510
|
+
fsm : Path, optional
|
|
511
|
+
Local simulation file for tier preludes above ``none``;
|
|
512
|
+
required by those tiers.
|
|
513
|
+
|
|
514
|
+
Returns
|
|
515
|
+
-------
|
|
516
|
+
Script
|
|
517
|
+
The rendered-ready probe script.
|
|
518
|
+
"""
|
|
519
|
+
# The solver runs inside the probe directory, but log exports and
|
|
520
|
+
# support files are addressed absolutely so the script is valid
|
|
521
|
+
# from any execution directory (a relative path made the real
|
|
522
|
+
# 26.120 export fail silently: the target folder did not exist
|
|
523
|
+
# under the execution directory).
|
|
524
|
+
workdir = Path(workdir).resolve()
|
|
525
|
+
script = Script(version, registry=registry)
|
|
526
|
+
script.comment(f"tier 2 probe for {spec.command}, generated by pyflightstream")
|
|
527
|
+
if spec.requires is not Requires.NONE:
|
|
528
|
+
if fsm is None:
|
|
529
|
+
raise ProbeEnvironmentError(
|
|
530
|
+
f"probe for {spec.command} needs the {spec.requires} prelude tier, "
|
|
531
|
+
"which opens a local simulation file; pass fsm (CLI: --fsm)"
|
|
532
|
+
)
|
|
533
|
+
after_open = None
|
|
534
|
+
if spec.early_prelude is not None:
|
|
535
|
+
early = spec.early_prelude
|
|
536
|
+
|
|
537
|
+
def after_open(inner: Script) -> None:
|
|
538
|
+
early(inner, workdir)
|
|
539
|
+
|
|
540
|
+
emit_tier_prelude(script, spec.requires, Path(fsm).resolve(), after_open)
|
|
541
|
+
elif spec.early_prelude is not None:
|
|
542
|
+
spec.early_prelude(script, workdir)
|
|
543
|
+
if spec.prelude is not None:
|
|
544
|
+
spec.prelude(script, workdir)
|
|
545
|
+
script.emit("PRINT", f"{_BEGIN}_{spec.command}")
|
|
546
|
+
script.emit("EXPORT_LOG", workdir / _LOG_BEFORE)
|
|
547
|
+
if spec.dump_state:
|
|
548
|
+
script.emit("OUTPUT_SETTINGS_AND_STATUS", workdir / _DUMP_BEFORE)
|
|
549
|
+
spec.build_target(script, workdir)
|
|
550
|
+
if spec.dump_state:
|
|
551
|
+
script.emit("OUTPUT_SETTINGS_AND_STATUS", workdir / _DUMP_AFTER)
|
|
552
|
+
script.emit("PRINT", f"{_END}_{spec.command}")
|
|
553
|
+
script.emit("EXPORT_LOG", workdir / _LOG_AFTER)
|
|
554
|
+
if spec.epilogue is not None:
|
|
555
|
+
spec.epilogue(script, workdir)
|
|
556
|
+
script.emit("EXPORT_LOG", workdir / _LOG_FINAL)
|
|
557
|
+
script.emit("CLOSE_FLIGHTSTREAM")
|
|
558
|
+
return script
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def probe_version(
|
|
562
|
+
version: str | FsVersion,
|
|
563
|
+
*,
|
|
564
|
+
workroot: str | Path,
|
|
565
|
+
fs_exe: str | Path | None = None,
|
|
566
|
+
executor: Executor | None = None,
|
|
567
|
+
commands: Sequence[str] | None = None,
|
|
568
|
+
specs: Mapping[str, ProbeSpec] | None = None,
|
|
569
|
+
fsm: str | Path | None = None,
|
|
570
|
+
timeout_s: float = 120.0,
|
|
571
|
+
error_patterns: Sequence[str] = DEFAULT_ERROR_PATTERNS,
|
|
572
|
+
registry: CommandRegistry | None = None,
|
|
573
|
+
) -> ProbeRun:
|
|
574
|
+
"""Probe the commands of one FlightStream version.
|
|
575
|
+
|
|
576
|
+
Runs the baseline probe first (aborting on an unusable
|
|
577
|
+
environment), validates each prelude tier the run will use, then
|
|
578
|
+
one probe per command that has a specification, and records every
|
|
579
|
+
remaining database command of the version as ``unprobed``, so the
|
|
580
|
+
compat report carries one evidence line per command.
|
|
581
|
+
|
|
582
|
+
Parameters
|
|
583
|
+
----------
|
|
584
|
+
version : str or FsVersion
|
|
585
|
+
Target version, canonical or alias.
|
|
586
|
+
workroot : str or Path
|
|
587
|
+
Root directory receiving one working subdirectory per probe;
|
|
588
|
+
scratch evidence, never committed (the report is the evidence).
|
|
589
|
+
fs_exe : str or Path, optional
|
|
590
|
+
Explicit path of the FlightStream executable (SAD Section 5:
|
|
591
|
+
never read from the environment or guessed). Required unless
|
|
592
|
+
``executor`` is given.
|
|
593
|
+
executor : Executor, optional
|
|
594
|
+
Alternative executor, used by tests; overrides ``fs_exe``.
|
|
595
|
+
commands : sequence of str, optional
|
|
596
|
+
Subset to probe; every name must exist in the version's view.
|
|
597
|
+
Commands outside the subset are recorded as unprobed.
|
|
598
|
+
specs : mapping of str to ProbeSpec, optional
|
|
599
|
+
Probe specifications; defaults to the packaged catalog in
|
|
600
|
+
:mod:`pyflightstream.qa.specs`.
|
|
601
|
+
fsm : str or Path, optional
|
|
602
|
+
Local simulation (.fsm) file for prelude tiers above ``none``;
|
|
603
|
+
explicit input, never committed. Specs needing it are recorded
|
|
604
|
+
unprobed when it is absent.
|
|
605
|
+
timeout_s : float
|
|
606
|
+
Wall-clock limit per probe, unless the spec overrides it.
|
|
607
|
+
error_patterns : sequence of str
|
|
608
|
+
Regular expressions scanned between the sentinels.
|
|
609
|
+
registry : CommandRegistry, optional
|
|
610
|
+
Alternative database, used by tests.
|
|
611
|
+
|
|
612
|
+
Returns
|
|
613
|
+
-------
|
|
614
|
+
ProbeRun
|
|
615
|
+
One evidence line per command of the version.
|
|
616
|
+
|
|
617
|
+
Raises
|
|
618
|
+
------
|
|
619
|
+
ProbeEnvironmentError
|
|
620
|
+
When the baseline probe fails or a probe directory cannot be
|
|
621
|
+
prepared; no command evidence is produced in that case.
|
|
622
|
+
ValueError
|
|
623
|
+
When ``commands`` names a command outside the version's view.
|
|
624
|
+
"""
|
|
625
|
+
resolved = resolve(version)
|
|
626
|
+
view = (registry or CommandRegistry.load()).for_version(resolved)
|
|
627
|
+
if specs is None:
|
|
628
|
+
from pyflightstream.qa import specs as spec_catalog
|
|
629
|
+
|
|
630
|
+
active_specs: Mapping[str, ProbeSpec] = spec_catalog.PROBE_SPECS
|
|
631
|
+
else:
|
|
632
|
+
active_specs = specs
|
|
633
|
+
if executor is None:
|
|
634
|
+
if fs_exe is None:
|
|
635
|
+
raise ProbeEnvironmentError(
|
|
636
|
+
"probe_version needs the FlightStream executable as explicit input "
|
|
637
|
+
"(fs_exe); paths are never read from the environment or guessed "
|
|
638
|
+
"(SAD Section 5)"
|
|
639
|
+
)
|
|
640
|
+
executor = LocalExecutor(fs_exe)
|
|
641
|
+
fs_exe_name = Path(executor.fs_exe).name if isinstance(executor, LocalExecutor) else "fake"
|
|
642
|
+
workroot = Path(workroot)
|
|
643
|
+
workroot.mkdir(parents=True, exist_ok=True)
|
|
644
|
+
fsm_path = None if fsm is None else Path(fsm).resolve()
|
|
645
|
+
if fsm_path is not None and not fsm_path.is_file():
|
|
646
|
+
raise ProbeEnvironmentError(
|
|
647
|
+
f"the simulation file {fsm_path} does not exist; the prelude tiers need a "
|
|
648
|
+
"real local .fsm as explicit input"
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
available = list(view)
|
|
652
|
+
if commands is not None:
|
|
653
|
+
unknown = sorted(set(commands) - set(available))
|
|
654
|
+
if unknown:
|
|
655
|
+
raise ValueError(
|
|
656
|
+
f"cannot probe {', '.join(unknown)}: not available in FlightStream "
|
|
657
|
+
f"{resolved.canonical}. Probes only run database commands of the "
|
|
658
|
+
"target version."
|
|
659
|
+
)
|
|
660
|
+
requested = None if commands is None else set(commands)
|
|
661
|
+
|
|
662
|
+
solver_identity = _run_baseline(executor, resolved, workroot, timeout_s, registry)
|
|
663
|
+
|
|
664
|
+
planned = [
|
|
665
|
+
active_specs[name]
|
|
666
|
+
for name in available
|
|
667
|
+
if name in active_specs and (requested is None or name in requested)
|
|
668
|
+
]
|
|
669
|
+
tier_failures = _validate_tiers(
|
|
670
|
+
planned, resolved, executor, workroot, fsm_path, timeout_s, registry
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
results: list[ProbeResult] = []
|
|
674
|
+
for name in available:
|
|
675
|
+
spec = active_specs.get(name)
|
|
676
|
+
if requested is not None and name not in requested:
|
|
677
|
+
results.append(ProbeResult(name, ProbeOutcome.UNPROBED, "not probed in this run"))
|
|
678
|
+
elif spec is None:
|
|
679
|
+
results.append(
|
|
680
|
+
ProbeResult(
|
|
681
|
+
name, ProbeOutcome.UNPROBED, "no probe specification for this command yet"
|
|
682
|
+
)
|
|
683
|
+
)
|
|
684
|
+
elif spec.requires is not Requires.NONE and fsm_path is None:
|
|
685
|
+
results.append(
|
|
686
|
+
ProbeResult(
|
|
687
|
+
name,
|
|
688
|
+
ProbeOutcome.UNPROBED,
|
|
689
|
+
f"needs the {spec.requires} prelude tier; supply a local simulation "
|
|
690
|
+
"file (--fsm) to probe it",
|
|
691
|
+
)
|
|
692
|
+
)
|
|
693
|
+
elif spec.requires in tier_failures:
|
|
694
|
+
results.append(
|
|
695
|
+
ProbeResult(
|
|
696
|
+
name,
|
|
697
|
+
ProbeOutcome.UNPROBED,
|
|
698
|
+
f"the {spec.requires} prelude tier failed its baseline, so the "
|
|
699
|
+
f"command was not judged: {tier_failures[spec.requires]}",
|
|
700
|
+
)
|
|
701
|
+
)
|
|
702
|
+
else:
|
|
703
|
+
results.append(
|
|
704
|
+
_run_probe(
|
|
705
|
+
spec,
|
|
706
|
+
resolved,
|
|
707
|
+
executor,
|
|
708
|
+
workroot,
|
|
709
|
+
timeout_s,
|
|
710
|
+
error_patterns,
|
|
711
|
+
registry,
|
|
712
|
+
fsm_path,
|
|
713
|
+
)
|
|
714
|
+
)
|
|
715
|
+
return ProbeRun(
|
|
716
|
+
version=resolved.canonical,
|
|
717
|
+
solver_identity=solver_identity,
|
|
718
|
+
fs_exe_name=fs_exe_name,
|
|
719
|
+
package_version=pyflightstream.__version__,
|
|
720
|
+
results=tuple(results),
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _validate_tiers(
|
|
725
|
+
planned: Sequence[ProbeSpec],
|
|
726
|
+
version: FsVersion,
|
|
727
|
+
executor: Executor,
|
|
728
|
+
workroot: Path,
|
|
729
|
+
fsm: Path | None,
|
|
730
|
+
timeout_s: float,
|
|
731
|
+
registry: CommandRegistry | None,
|
|
732
|
+
) -> dict[Requires, str]:
|
|
733
|
+
"""Run one baseline per prelude tier the planned specs use.
|
|
734
|
+
|
|
735
|
+
A tier whose baseline fails downgrades its probes to unprobed with
|
|
736
|
+
the failure recorded; a broken prelude must never read as broken
|
|
737
|
+
target commands.
|
|
738
|
+
"""
|
|
739
|
+
failures: dict[Requires, str] = {}
|
|
740
|
+
tiers = {spec.requires for spec in planned} - {Requires.NONE}
|
|
741
|
+
if fsm is None:
|
|
742
|
+
return failures
|
|
743
|
+
for tier in sorted(tiers, key=list(Requires).index):
|
|
744
|
+
workdir = _fresh_dir(workroot / f"_tier_{tier.value}")
|
|
745
|
+
script = Script(version, registry=registry)
|
|
746
|
+
script.comment(f"prelude tier baseline: {tier.value}")
|
|
747
|
+
emit_tier_prelude(script, tier, fsm)
|
|
748
|
+
marker = f"{_BASELINE_MARKER}_{tier.value.upper()}"
|
|
749
|
+
script.emit("PRINT", marker)
|
|
750
|
+
script.emit("EXPORT_LOG", workdir / _LOG_AFTER)
|
|
751
|
+
script.emit("CLOSE_FLIGHTSTREAM")
|
|
752
|
+
script_path = workdir / _SCRIPT_NAME
|
|
753
|
+
script_path.write_text(script.render(), encoding="utf-8")
|
|
754
|
+
execution = executor.run_script(script_path, working_dir=workdir, timeout_s=timeout_s)
|
|
755
|
+
log_text = _read_log(workdir / _LOG_AFTER)
|
|
756
|
+
if log_text is None or not printed_line(log_text, marker):
|
|
757
|
+
hint = execution.log_text or execution.stderr or f"return code {execution.return_code}"
|
|
758
|
+
failures[tier] = f"prelude did not reach its sentinel ({hint or 'no solver output'})"
|
|
759
|
+
return failures
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _fresh_dir(workdir: Path) -> Path:
|
|
763
|
+
"""Create an empty probe directory, wiping only what the harness owns.
|
|
764
|
+
|
|
765
|
+
A stale log in a reused directory would fake a probe signal, so the
|
|
766
|
+
directory is recreated; anything not created by a previous probe
|
|
767
|
+
(no probe script inside a non-empty directory) is refused instead
|
|
768
|
+
of deleted.
|
|
769
|
+
"""
|
|
770
|
+
workdir = workdir.resolve()
|
|
771
|
+
if workdir.exists():
|
|
772
|
+
contents = list(workdir.iterdir())
|
|
773
|
+
if contents and not (workdir / _SCRIPT_NAME).is_file():
|
|
774
|
+
raise ProbeEnvironmentError(
|
|
775
|
+
f"refusing to wipe {workdir}: it is not empty and holds no probe script, "
|
|
776
|
+
"so it was not created by the probe harness. Choose a clean workroot."
|
|
777
|
+
)
|
|
778
|
+
shutil.rmtree(workdir)
|
|
779
|
+
workdir.mkdir(parents=True)
|
|
780
|
+
return workdir
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _read_log(path: Path) -> str | None:
|
|
784
|
+
"""Read an exported log, scrubbing the stray NUL bytes of hidden mode.
|
|
785
|
+
|
|
786
|
+
Real 26.120 hidden-mode exports carry NUL bytes between lines
|
|
787
|
+
(RPT-001 finding 2); they are scrubbed here exactly as in
|
|
788
|
+
``parse_residual_history``.
|
|
789
|
+
"""
|
|
790
|
+
if not path.is_file():
|
|
791
|
+
return None
|
|
792
|
+
return path.read_text(encoding="utf-8", errors="replace").replace("\x00", "")
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _run_baseline(
|
|
796
|
+
executor: Executor,
|
|
797
|
+
version: FsVersion,
|
|
798
|
+
workroot: Path,
|
|
799
|
+
timeout_s: float,
|
|
800
|
+
registry: CommandRegistry | None,
|
|
801
|
+
) -> tuple[str, ...]:
|
|
802
|
+
"""Validate the probe instruments and capture the solver identity.
|
|
803
|
+
|
|
804
|
+
The baseline script is PRINT plus EXPORT_LOG plus
|
|
805
|
+
CLOSE_FLIGHTSTREAM, the instrument set every probe relies on. Its
|
|
806
|
+
failure means the environment (executable, license, log export) is
|
|
807
|
+
unusable, and the run aborts instead of producing false evidence.
|
|
808
|
+
"""
|
|
809
|
+
workdir = _fresh_dir(workroot / _BASELINE_DIR)
|
|
810
|
+
log_path = workdir / _LOG_AFTER
|
|
811
|
+
script = Script(version, registry=registry)
|
|
812
|
+
script.comment("tier 2 baseline probe: validates the probe instruments")
|
|
813
|
+
script.emit("PRINT", _BASELINE_MARKER)
|
|
814
|
+
script.emit("EXPORT_LOG", log_path)
|
|
815
|
+
script.emit("CLOSE_FLIGHTSTREAM")
|
|
816
|
+
script_path = workdir / _SCRIPT_NAME
|
|
817
|
+
script_path.write_text(script.render(), encoding="utf-8")
|
|
818
|
+
execution = executor.run_script(script_path, working_dir=workdir, timeout_s=timeout_s)
|
|
819
|
+
log_text = _read_log(log_path)
|
|
820
|
+
if log_text is None or not printed_line(log_text, _BASELINE_MARKER):
|
|
821
|
+
hint = execution.log_text or execution.stderr or f"return code {execution.return_code}"
|
|
822
|
+
raise ProbeEnvironmentError(
|
|
823
|
+
"baseline probe failed: the PRINT sentinel never reached the exported log, "
|
|
824
|
+
"so the environment (executable, license checkout, or log export) is "
|
|
825
|
+
f"unusable and no command was judged. Solver said: {hint or 'nothing'}"
|
|
826
|
+
)
|
|
827
|
+
identity = tuple(
|
|
828
|
+
line.strip()
|
|
829
|
+
for line in log_text.splitlines()
|
|
830
|
+
if ("version" in line.lower() or "build" in line.lower()) and _BASELINE_MARKER not in line
|
|
831
|
+
)[:5]
|
|
832
|
+
return identity
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _run_probe(
|
|
836
|
+
spec: ProbeSpec,
|
|
837
|
+
version: FsVersion,
|
|
838
|
+
executor: Executor,
|
|
839
|
+
workroot: Path,
|
|
840
|
+
timeout_s: float,
|
|
841
|
+
error_patterns: Sequence[str],
|
|
842
|
+
registry: CommandRegistry | None,
|
|
843
|
+
fsm: Path | None = None,
|
|
844
|
+
) -> ProbeResult:
|
|
845
|
+
"""Run one probe end to end and judge its three signals."""
|
|
846
|
+
workdir = _fresh_dir(workroot / spec.command)
|
|
847
|
+
script = generate_probe_script(spec, version, workdir, registry=registry, fsm=fsm)
|
|
848
|
+
text = script.render()
|
|
849
|
+
script_path = workdir / _SCRIPT_NAME
|
|
850
|
+
script_path.write_text(text, encoding="utf-8")
|
|
851
|
+
sha = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
852
|
+
limit = spec.timeout_s if spec.timeout_s is not None else timeout_s
|
|
853
|
+
execution = executor.run_script(script_path, working_dir=workdir, timeout_s=limit)
|
|
854
|
+
artifacts = ProbeArtifacts(
|
|
855
|
+
workdir=workdir,
|
|
856
|
+
log_before=_read_log(workdir / _LOG_BEFORE),
|
|
857
|
+
log_after=_read_log(workdir / _LOG_AFTER),
|
|
858
|
+
begin_marker=f"{_BEGIN}_{spec.command}",
|
|
859
|
+
end_marker=f"{_END}_{spec.command}",
|
|
860
|
+
execution=execution,
|
|
861
|
+
)
|
|
862
|
+
return _judge(spec, artifacts, error_patterns, sha, limit)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _judge(
|
|
866
|
+
spec: ProbeSpec,
|
|
867
|
+
artifacts: ProbeArtifacts,
|
|
868
|
+
error_patterns: Sequence[str],
|
|
869
|
+
script_sha256: str,
|
|
870
|
+
timeout_s: float,
|
|
871
|
+
) -> ProbeResult:
|
|
872
|
+
"""Turn the probe signals into one evidence line."""
|
|
873
|
+
execution = artifacts.execution
|
|
874
|
+
sentinel_before = artifacts.log_before is not None and printed_line(
|
|
875
|
+
artifacts.log_before, artifacts.begin_marker
|
|
876
|
+
)
|
|
877
|
+
sentinel_after = artifacts.log_after is not None and printed_line(
|
|
878
|
+
artifacts.log_after, artifacts.end_marker
|
|
879
|
+
)
|
|
880
|
+
common = {
|
|
881
|
+
"command": spec.command,
|
|
882
|
+
"sentinel_before": sentinel_before,
|
|
883
|
+
"sentinel_after": sentinel_after,
|
|
884
|
+
"wall_time_s": execution.wall_time_s,
|
|
885
|
+
"return_code": execution.return_code,
|
|
886
|
+
"script_sha256": script_sha256,
|
|
887
|
+
}
|
|
888
|
+
if spec.expects_halt:
|
|
889
|
+
return _judge_halt(spec, artifacts, common)
|
|
890
|
+
if execution.timed_out:
|
|
891
|
+
return ProbeResult(
|
|
892
|
+
outcome=ProbeOutcome.UNPROBED,
|
|
893
|
+
detail=(
|
|
894
|
+
f"probe timed out after {timeout_s:g} s and the process was killed; "
|
|
895
|
+
"inconclusive, rerun with a larger timeout or probe by hand"
|
|
896
|
+
),
|
|
897
|
+
**common,
|
|
898
|
+
)
|
|
899
|
+
if artifacts.log_after is None:
|
|
900
|
+
if artifacts.log_before is None:
|
|
901
|
+
return ProbeResult(
|
|
902
|
+
outcome=ProbeOutcome.UNPROBED,
|
|
903
|
+
detail=(
|
|
904
|
+
"the solver exported neither sentinel log although the baseline "
|
|
905
|
+
"probe passed; inconclusive, environment drifted mid-run "
|
|
906
|
+
f"(return code {execution.return_code})"
|
|
907
|
+
),
|
|
908
|
+
**common,
|
|
909
|
+
)
|
|
910
|
+
return ProbeResult(
|
|
911
|
+
outcome=ProbeOutcome.BROKEN,
|
|
912
|
+
detail=(
|
|
913
|
+
"script processing aborted at the command: the log exported before it "
|
|
914
|
+
"exists, the one after it never appeared (END sentinel missing)"
|
|
915
|
+
),
|
|
916
|
+
**common,
|
|
917
|
+
)
|
|
918
|
+
matches = _scan_errors(artifacts.target_region(), error_patterns)
|
|
919
|
+
if matches:
|
|
920
|
+
return ProbeResult(
|
|
921
|
+
outcome=ProbeOutcome.BROKEN,
|
|
922
|
+
detail=(
|
|
923
|
+
"the solver logged errors between the probe sentinels: " + "; ".join(matches[:3])
|
|
924
|
+
),
|
|
925
|
+
log_errors=tuple(matches),
|
|
926
|
+
**common,
|
|
927
|
+
)
|
|
928
|
+
epilogue_note = ""
|
|
929
|
+
if spec.epilogue is not None and artifacts.log_final() is None:
|
|
930
|
+
epilogue_note = (
|
|
931
|
+
" (the epilogue instruments aborted after the target; the effect was "
|
|
932
|
+
"judged from the artifacts they left)"
|
|
933
|
+
)
|
|
934
|
+
effect = spec.assert_effect(artifacts)
|
|
935
|
+
if effect is None:
|
|
936
|
+
return ProbeResult(
|
|
937
|
+
outcome=ProbeOutcome.UNPROBED,
|
|
938
|
+
detail=(
|
|
939
|
+
"the command ran without a script abort or logged error, but its "
|
|
940
|
+
"effect is not observable with the current instruments; asserted "
|
|
941
|
+
f"effect: {spec.effect_note}{epilogue_note}"
|
|
942
|
+
),
|
|
943
|
+
effect=None,
|
|
944
|
+
**common,
|
|
945
|
+
)
|
|
946
|
+
if not effect:
|
|
947
|
+
return ProbeResult(
|
|
948
|
+
outcome=ProbeOutcome.BROKEN,
|
|
949
|
+
detail=(
|
|
950
|
+
"the command ran (script processing continued past it) but its effect "
|
|
951
|
+
f"was not observed; expected: {spec.effect_note}{epilogue_note}"
|
|
952
|
+
),
|
|
953
|
+
effect=False,
|
|
954
|
+
**common,
|
|
955
|
+
)
|
|
956
|
+
return ProbeResult(
|
|
957
|
+
outcome=ProbeOutcome.VERIFIED,
|
|
958
|
+
detail=(
|
|
959
|
+
"script processing continued past the command, no error between the "
|
|
960
|
+
f"sentinels, and the effect was observed: {spec.effect_note}{epilogue_note}"
|
|
961
|
+
),
|
|
962
|
+
effect=True,
|
|
963
|
+
**common,
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _judge_halt(
|
|
968
|
+
spec: ProbeSpec, artifacts: ProbeArtifacts, common: dict[str, object]
|
|
969
|
+
) -> ProbeResult:
|
|
970
|
+
"""Judge a probe whose expected effect is halting the script.
|
|
971
|
+
|
|
972
|
+
Killing the process at the timeout is not a failure here: a hidden
|
|
973
|
+
solver may idle after the halt, and the halt evidence is the pair
|
|
974
|
+
of logs, not the exit.
|
|
975
|
+
"""
|
|
976
|
+
if artifacts.log_before is None or not common["sentinel_before"]:
|
|
977
|
+
return ProbeResult(
|
|
978
|
+
outcome=ProbeOutcome.UNPROBED,
|
|
979
|
+
detail=(
|
|
980
|
+
"the log exported before the command is missing or lacks its sentinel "
|
|
981
|
+
"although the baseline probe passed; inconclusive"
|
|
982
|
+
),
|
|
983
|
+
**common,
|
|
984
|
+
)
|
|
985
|
+
if artifacts.log_after is not None:
|
|
986
|
+
return ProbeResult(
|
|
987
|
+
outcome=ProbeOutcome.BROKEN,
|
|
988
|
+
detail=(
|
|
989
|
+
"the command was expected to halt script processing, but the log after "
|
|
990
|
+
"it was exported (processing continued)"
|
|
991
|
+
),
|
|
992
|
+
effect=False,
|
|
993
|
+
**common,
|
|
994
|
+
)
|
|
995
|
+
killed = (
|
|
996
|
+
" (the idle hidden process was killed at the timeout)"
|
|
997
|
+
if (artifacts.execution.timed_out)
|
|
998
|
+
else ""
|
|
999
|
+
)
|
|
1000
|
+
return ProbeResult(
|
|
1001
|
+
outcome=ProbeOutcome.VERIFIED,
|
|
1002
|
+
detail=(
|
|
1003
|
+
"script processing halted at the command: the log before it exists, the "
|
|
1004
|
+
f"one after it never appeared; expected: {spec.effect_note}{killed}"
|
|
1005
|
+
),
|
|
1006
|
+
effect=True,
|
|
1007
|
+
**common,
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _scan_errors(region: str, error_patterns: Sequence[str]) -> list[str]:
|
|
1012
|
+
"""Return the region lines matching any error pattern, verbatim."""
|
|
1013
|
+
compiled = [re.compile(pattern) for pattern in error_patterns]
|
|
1014
|
+
matches = []
|
|
1015
|
+
for line in region.splitlines():
|
|
1016
|
+
if any(pattern.search(line) for pattern in compiled):
|
|
1017
|
+
matches.append(line.strip()[:160])
|
|
1018
|
+
return matches
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
# The probe specification catalog lives in pyflightstream.qa.specs,
|
|
1022
|
+
# one evidence-backed specification per command; probe_version imports
|
|
1023
|
+
# it lazily so the catalog can build on the machinery of this module.
|