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.
Files changed (71) hide show
  1. pyflightstream/__init__.py +27 -0
  2. pyflightstream/cases/__init__.py +332 -0
  3. pyflightstream/cases/matrix_legacy.py +337 -0
  4. pyflightstream/commands/__init__.py +544 -0
  5. pyflightstream/commands/_meta.yaml +18 -0
  6. pyflightstream/commands/actuators.yaml +167 -0
  7. pyflightstream/commands/advanced_settings.yaml +136 -0
  8. pyflightstream/commands/aeroelastic_coupling.yaml +172 -0
  9. pyflightstream/commands/boundary_conditions.yaml +153 -0
  10. pyflightstream/commands/ccs_wing_mesh.yaml +74 -0
  11. pyflightstream/commands/coordinate_systems.yaml +123 -0
  12. pyflightstream/commands/file_io.yaml +82 -0
  13. pyflightstream/commands/mesh_import_export.yaml +72 -0
  14. pyflightstream/commands/motion_definitions.yaml +185 -0
  15. pyflightstream/commands/probe_points.yaml +116 -0
  16. pyflightstream/commands/runtime_settings.yaml +160 -0
  17. pyflightstream/commands/scenes.yaml +14 -0
  18. pyflightstream/commands/script_controls.yaml +39 -0
  19. pyflightstream/commands/simulation_controls.yaml +30 -0
  20. pyflightstream/commands/solver_analysis.yaml +118 -0
  21. pyflightstream/commands/solver_export.yaml +138 -0
  22. pyflightstream/commands/solver_initialization.yaml +95 -0
  23. pyflightstream/commands/solver_settings.yaml +120 -0
  24. pyflightstream/commands/streamlines.yaml +63 -0
  25. pyflightstream/commands/surface_sections.yaml +145 -0
  26. pyflightstream/commands/sweeper.yaml +115 -0
  27. pyflightstream/commands/unsteady_solver.yaml +68 -0
  28. pyflightstream/commands/volume_sections.yaml +148 -0
  29. pyflightstream/farfield/__init__.py +613 -0
  30. pyflightstream/files/__init__.py +375 -0
  31. pyflightstream/fsi/__init__.py +57 -0
  32. pyflightstream/fsi/beam.py +417 -0
  33. pyflightstream/fsi/centrifugal.py +418 -0
  34. pyflightstream/fsi/cli.py +206 -0
  35. pyflightstream/fsi/config.py +316 -0
  36. pyflightstream/fsi/driver.py +501 -0
  37. pyflightstream/fsi/kinematics.py +153 -0
  38. pyflightstream/fsi/loads.py +740 -0
  39. pyflightstream/fsi/nodes.py +463 -0
  40. pyflightstream/fsi/state.py +181 -0
  41. pyflightstream/post/__init__.py +18 -0
  42. pyflightstream/post/writers.py +195 -0
  43. pyflightstream/probes/__init__.py +453 -0
  44. pyflightstream/probes/geometry.py +281 -0
  45. pyflightstream/probes/planar.py +477 -0
  46. pyflightstream/qa/__init__.py +59 -0
  47. pyflightstream/qa/cli.py +364 -0
  48. pyflightstream/qa/compat.py +285 -0
  49. pyflightstream/qa/drift.py +406 -0
  50. pyflightstream/qa/geometry.py +421 -0
  51. pyflightstream/qa/physics.py +1744 -0
  52. pyflightstream/qa/probes.py +1023 -0
  53. pyflightstream/qa/references/PHY-01.yaml +38 -0
  54. pyflightstream/qa/references/PHY-02.yaml +28 -0
  55. pyflightstream/qa/references/PHY-05.yaml +29 -0
  56. pyflightstream/qa/references/PHY-06.yaml +90 -0
  57. pyflightstream/qa/references/SMI-01.yaml +28 -0
  58. pyflightstream/qa/references/SMI-02.yaml +28 -0
  59. pyflightstream/qa/specs.py +1405 -0
  60. pyflightstream/reference.py +465 -0
  61. pyflightstream/results/__init__.py +547 -0
  62. pyflightstream/run/__init__.py +677 -0
  63. pyflightstream/script/__init__.py +474 -0
  64. pyflightstream/script/helpers.py +844 -0
  65. pyflightstream/versions.py +191 -0
  66. pyflightstream-0.2.0.dist-info/METADATA +88 -0
  67. pyflightstream-0.2.0.dist-info/RECORD +71 -0
  68. pyflightstream-0.2.0.dist-info/WHEEL +5 -0
  69. pyflightstream-0.2.0.dist-info/entry_points.txt +3 -0
  70. pyflightstream-0.2.0.dist-info/licenses/LICENSE +21 -0
  71. 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.