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
pyflightstream/qa/cli.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""The ``pyfs-qa`` command line, first console entry point of the package.
|
|
2
|
+
|
|
3
|
+
Pipeline role: drives the qa evidence workflow from a terminal on the
|
|
4
|
+
licensed machine. ``pyfs-qa probe`` runs the Tier 2 command-validity
|
|
5
|
+
probes for one FlightStream version and writes the compat report;
|
|
6
|
+
``pyfs-qa apply-compat`` promotes database statuses from a committed
|
|
7
|
+
report. ``pyfs-qa physics`` runs the Tier 3 physics regression matrix
|
|
8
|
+
and writes the physics report; ``pyfs-qa update-reference`` is the only
|
|
9
|
+
write path into the stored physics references and demands a reason
|
|
10
|
+
string (SAD Section 11); ``pyfs-qa drift`` runs the same case set on
|
|
11
|
+
two versions and diffs the aggregated coefficients (FR-27).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from pyflightstream.commands import CommandRegistry
|
|
21
|
+
from pyflightstream.qa.compat import apply_compat, write_compat_report
|
|
22
|
+
from pyflightstream.qa.drift import run_drift, write_drift_report
|
|
23
|
+
from pyflightstream.qa.physics import (
|
|
24
|
+
PhysicsEnvironmentError,
|
|
25
|
+
run_physics,
|
|
26
|
+
update_reference,
|
|
27
|
+
write_physics_report,
|
|
28
|
+
)
|
|
29
|
+
from pyflightstream.qa.probes import ProbeEnvironmentError, probe_version
|
|
30
|
+
from pyflightstream.versions import resolve
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
34
|
+
parser = argparse.ArgumentParser(
|
|
35
|
+
prog="pyfs-qa",
|
|
36
|
+
description=(
|
|
37
|
+
"Tier 2 and 3 evidence tooling for the pyflightstream command database; "
|
|
38
|
+
"probes run FlightStream locally and need a licensed machine."
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
subparsers = parser.add_subparsers(dest="subcommand", required=True)
|
|
42
|
+
|
|
43
|
+
probe = subparsers.add_parser(
|
|
44
|
+
"probe",
|
|
45
|
+
help="run command-validity probes and write the compat report",
|
|
46
|
+
)
|
|
47
|
+
probe.add_argument(
|
|
48
|
+
"--version",
|
|
49
|
+
required=True,
|
|
50
|
+
help="target FlightStream version, canonical or alias (for example 26.120)",
|
|
51
|
+
)
|
|
52
|
+
probe.add_argument(
|
|
53
|
+
"--fs-exe",
|
|
54
|
+
required=True,
|
|
55
|
+
help="explicit path of the FlightStream executable (never guessed)",
|
|
56
|
+
)
|
|
57
|
+
probe.add_argument(
|
|
58
|
+
"--commands",
|
|
59
|
+
help="comma-separated subset to probe; default: every command with a probe spec",
|
|
60
|
+
)
|
|
61
|
+
probe.add_argument(
|
|
62
|
+
"--fsm",
|
|
63
|
+
help=(
|
|
64
|
+
"local simulation (.fsm) file for prelude tiers above none; specs needing "
|
|
65
|
+
"it are recorded unprobed when absent"
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
probe.add_argument(
|
|
69
|
+
"--workroot",
|
|
70
|
+
default="probe_runs",
|
|
71
|
+
help="scratch root for probe scripts and logs (default probe_runs/, not committed)",
|
|
72
|
+
)
|
|
73
|
+
probe.add_argument(
|
|
74
|
+
"--timeout", type=float, default=120.0, help="per-probe wall-clock limit, seconds"
|
|
75
|
+
)
|
|
76
|
+
probe.add_argument(
|
|
77
|
+
"--report-dir",
|
|
78
|
+
default="reports/compat",
|
|
79
|
+
help="directory receiving the compat report pair (default reports/compat/)",
|
|
80
|
+
)
|
|
81
|
+
probe.add_argument(
|
|
82
|
+
"--label",
|
|
83
|
+
help="report stem suffix distinguishing several reports on one day",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
apply_parser = subparsers.add_parser(
|
|
87
|
+
"apply-compat",
|
|
88
|
+
help="promote database statuses from a committed compat report",
|
|
89
|
+
)
|
|
90
|
+
apply_parser.add_argument("report", help="the compat report YAML file")
|
|
91
|
+
apply_parser.add_argument(
|
|
92
|
+
"--root",
|
|
93
|
+
default=".",
|
|
94
|
+
help="repository root; the report is cited relative to it (default .)",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
physics = subparsers.add_parser(
|
|
98
|
+
"physics",
|
|
99
|
+
help="run the Tier 3 physics regression matrix and write the physics report",
|
|
100
|
+
)
|
|
101
|
+
physics.add_argument(
|
|
102
|
+
"--version",
|
|
103
|
+
required=True,
|
|
104
|
+
help="target FlightStream version, canonical or alias (for example 26.120)",
|
|
105
|
+
)
|
|
106
|
+
physics.add_argument(
|
|
107
|
+
"--fs-exe",
|
|
108
|
+
required=True,
|
|
109
|
+
help="explicit path of the FlightStream executable (never guessed)",
|
|
110
|
+
)
|
|
111
|
+
physics.add_argument(
|
|
112
|
+
"--cases",
|
|
113
|
+
help="comma-separated case subset (for example PHY-01); default: every case",
|
|
114
|
+
)
|
|
115
|
+
physics.add_argument(
|
|
116
|
+
"--workroot",
|
|
117
|
+
default="physics_runs",
|
|
118
|
+
help="scratch root for geometry, scripts, and outputs (default physics_runs/)",
|
|
119
|
+
)
|
|
120
|
+
physics.add_argument(
|
|
121
|
+
"--timeout", type=float, default=900.0, help="per-point wall-clock limit, seconds"
|
|
122
|
+
)
|
|
123
|
+
physics.add_argument(
|
|
124
|
+
"--report-dir",
|
|
125
|
+
default="reports/physics",
|
|
126
|
+
help="directory receiving the report pair (default reports/physics/)",
|
|
127
|
+
)
|
|
128
|
+
physics.add_argument(
|
|
129
|
+
"--label",
|
|
130
|
+
help="report stem suffix distinguishing several reports on one day",
|
|
131
|
+
)
|
|
132
|
+
physics.add_argument(
|
|
133
|
+
"--smi-root",
|
|
134
|
+
help="local SMI geometry root (normally _private/geometry/smi); enables the "
|
|
135
|
+
"SMI drift class, geometry never enters Git",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
drift = subparsers.add_parser(
|
|
139
|
+
"drift",
|
|
140
|
+
help="run the case set on two versions and diff the coefficients (FR-27)",
|
|
141
|
+
)
|
|
142
|
+
drift.add_argument(
|
|
143
|
+
"--versions",
|
|
144
|
+
required=True,
|
|
145
|
+
help="two comma-separated versions, baseline first (for example 26.100,26.120); "
|
|
146
|
+
"the same version twice is the degenerate self-comparison",
|
|
147
|
+
)
|
|
148
|
+
drift.add_argument(
|
|
149
|
+
"--fs-exe",
|
|
150
|
+
action="append",
|
|
151
|
+
required=True,
|
|
152
|
+
metavar="VERSION=PATH",
|
|
153
|
+
help="explicit executable per version (repeatable, never guessed), "
|
|
154
|
+
"for example --fs-exe 26.100=C:/fs26100/FS.exe",
|
|
155
|
+
)
|
|
156
|
+
drift.add_argument(
|
|
157
|
+
"--cases",
|
|
158
|
+
help="comma-separated case subset (for example PHY-01); default: every case",
|
|
159
|
+
)
|
|
160
|
+
drift.add_argument(
|
|
161
|
+
"--workroot",
|
|
162
|
+
default="physics_runs",
|
|
163
|
+
help="scratch root; each version nests under its canonical name",
|
|
164
|
+
)
|
|
165
|
+
drift.add_argument(
|
|
166
|
+
"--timeout", type=float, default=900.0, help="per-point wall-clock limit, seconds"
|
|
167
|
+
)
|
|
168
|
+
drift.add_argument(
|
|
169
|
+
"--report-dir",
|
|
170
|
+
default="reports/physics",
|
|
171
|
+
help="directory receiving the report pair (default reports/physics/)",
|
|
172
|
+
)
|
|
173
|
+
drift.add_argument(
|
|
174
|
+
"--label",
|
|
175
|
+
help="report stem suffix distinguishing several reports on one day",
|
|
176
|
+
)
|
|
177
|
+
drift.add_argument(
|
|
178
|
+
"--smi-root",
|
|
179
|
+
help="local SMI geometry root (normally _private/geometry/smi); enables the "
|
|
180
|
+
"SMI drift class on both versions, geometry never enters Git",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
update = subparsers.add_parser(
|
|
184
|
+
"update-reference",
|
|
185
|
+
help="update or seed one physics reference from a committed physics report",
|
|
186
|
+
)
|
|
187
|
+
update.add_argument("case", help="case identifier, for example PHY-01")
|
|
188
|
+
update.add_argument(
|
|
189
|
+
"--from-report",
|
|
190
|
+
required=True,
|
|
191
|
+
help="committed physics report YAML carrying the measured values",
|
|
192
|
+
)
|
|
193
|
+
update.add_argument(
|
|
194
|
+
"--reason",
|
|
195
|
+
required=True,
|
|
196
|
+
help="why the reference moves; recorded in the reference file (required)",
|
|
197
|
+
)
|
|
198
|
+
return parser
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main(argv: list[str] | None = None) -> int:
|
|
202
|
+
"""Run ``pyfs-qa``; returns the process exit code."""
|
|
203
|
+
args = _build_parser().parse_args(argv)
|
|
204
|
+
if args.subcommand == "probe":
|
|
205
|
+
return _cmd_probe(args)
|
|
206
|
+
if args.subcommand == "physics":
|
|
207
|
+
return _cmd_physics(args)
|
|
208
|
+
if args.subcommand == "drift":
|
|
209
|
+
return _cmd_drift(args)
|
|
210
|
+
if args.subcommand == "update-reference":
|
|
211
|
+
return _cmd_update_reference(args)
|
|
212
|
+
return _cmd_apply_compat(args)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _cmd_probe(args: argparse.Namespace) -> int:
|
|
216
|
+
canonical = resolve(args.version).canonical
|
|
217
|
+
commands = None
|
|
218
|
+
if args.commands:
|
|
219
|
+
commands = [name.strip() for name in args.commands.split(",") if name.strip()]
|
|
220
|
+
try:
|
|
221
|
+
run = probe_version(
|
|
222
|
+
canonical,
|
|
223
|
+
workroot=Path(args.workroot) / canonical,
|
|
224
|
+
fs_exe=args.fs_exe,
|
|
225
|
+
commands=commands,
|
|
226
|
+
fsm=args.fsm,
|
|
227
|
+
timeout_s=args.timeout,
|
|
228
|
+
)
|
|
229
|
+
except ProbeEnvironmentError as error:
|
|
230
|
+
print(f"probe run aborted: {error}", file=sys.stderr)
|
|
231
|
+
return 2
|
|
232
|
+
yaml_path, md_path = write_compat_report(run, args.report_dir, label=args.label)
|
|
233
|
+
counts = run.outcome_counts()
|
|
234
|
+
print(
|
|
235
|
+
f"FlightStream {run.version} ({run.fs_exe_name}): "
|
|
236
|
+
f"{counts['verified']} verified, {counts['broken']} broken, "
|
|
237
|
+
f"{counts['unprobed']} unprobed"
|
|
238
|
+
)
|
|
239
|
+
for line in run.solver_identity:
|
|
240
|
+
print(f"solver: {line}")
|
|
241
|
+
print(f"report: {yaml_path}")
|
|
242
|
+
print(f"report: {md_path}")
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _cmd_physics(args: argparse.Namespace) -> int:
|
|
247
|
+
canonical = resolve(args.version).canonical
|
|
248
|
+
cases = None
|
|
249
|
+
if args.cases:
|
|
250
|
+
cases = [name.strip() for name in args.cases.split(",") if name.strip()]
|
|
251
|
+
try:
|
|
252
|
+
run = run_physics(
|
|
253
|
+
canonical,
|
|
254
|
+
fs_exe=args.fs_exe,
|
|
255
|
+
workroot=args.workroot,
|
|
256
|
+
cases=cases,
|
|
257
|
+
timeout_s=args.timeout,
|
|
258
|
+
smi_root=args.smi_root,
|
|
259
|
+
)
|
|
260
|
+
except PhysicsEnvironmentError as error:
|
|
261
|
+
print(f"physics run aborted: {error}", file=sys.stderr)
|
|
262
|
+
return 2
|
|
263
|
+
yaml_path, md_path = write_physics_report(run, args.report_dir, label=args.label)
|
|
264
|
+
counts = run.verdict_counts()
|
|
265
|
+
print(
|
|
266
|
+
f"FlightStream {run.version} ({run.fs_exe_name}): "
|
|
267
|
+
f"{counts['pass']} pass, {counts['warn']} warn, {counts['fail']} fail, "
|
|
268
|
+
f"{counts['no_reference']} without reference"
|
|
269
|
+
)
|
|
270
|
+
for result in run.results:
|
|
271
|
+
if result.error is not None:
|
|
272
|
+
print(f"{result.case_id} aborted: {result.error}", file=sys.stderr)
|
|
273
|
+
for line in run.solver_identity:
|
|
274
|
+
print(f"solver: {line}")
|
|
275
|
+
print(f"report: {yaml_path}")
|
|
276
|
+
print(f"report: {md_path}")
|
|
277
|
+
aborted = any(result.error is not None for result in run.results)
|
|
278
|
+
return 1 if counts["fail"] or aborted else 0
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _cmd_drift(args: argparse.Namespace) -> int:
|
|
282
|
+
versions = [name.strip() for name in args.versions.split(",") if name.strip()]
|
|
283
|
+
if len(versions) != 2:
|
|
284
|
+
print(
|
|
285
|
+
"drift compares exactly two versions, baseline first "
|
|
286
|
+
"(for example --versions 26.100,26.120)",
|
|
287
|
+
file=sys.stderr,
|
|
288
|
+
)
|
|
289
|
+
return 2
|
|
290
|
+
fs_exes: dict[str, str] = {}
|
|
291
|
+
for item in args.fs_exe:
|
|
292
|
+
version, separator, path = item.partition("=")
|
|
293
|
+
if not separator or not path:
|
|
294
|
+
print(f"--fs-exe expects VERSION=PATH, got {item!r}", file=sys.stderr)
|
|
295
|
+
return 2
|
|
296
|
+
fs_exes[resolve(version.strip()).canonical] = path.strip()
|
|
297
|
+
canonicals = [resolve(version).canonical for version in versions]
|
|
298
|
+
missing = [canonical for canonical in canonicals if canonical not in fs_exes]
|
|
299
|
+
if missing:
|
|
300
|
+
print(
|
|
301
|
+
f"no executable given for {', '.join(missing)}; pass --fs-exe VERSION=PATH",
|
|
302
|
+
file=sys.stderr,
|
|
303
|
+
)
|
|
304
|
+
return 2
|
|
305
|
+
cases = None
|
|
306
|
+
if args.cases:
|
|
307
|
+
cases = [name.strip() for name in args.cases.split(",") if name.strip()]
|
|
308
|
+
try:
|
|
309
|
+
run = run_drift(
|
|
310
|
+
canonicals[0],
|
|
311
|
+
canonicals[1],
|
|
312
|
+
fs_exes=fs_exes,
|
|
313
|
+
workroot=args.workroot,
|
|
314
|
+
cases=cases,
|
|
315
|
+
timeout_s=args.timeout,
|
|
316
|
+
smi_root=args.smi_root,
|
|
317
|
+
)
|
|
318
|
+
except PhysicsEnvironmentError as error:
|
|
319
|
+
print(f"drift run aborted: {error}", file=sys.stderr)
|
|
320
|
+
return 2
|
|
321
|
+
yaml_path, md_path = write_drift_report(run, args.report_dir, label=args.label)
|
|
322
|
+
counts = run.verdict_counts()
|
|
323
|
+
print(
|
|
324
|
+
f"FlightStream {run.version_a} vs {run.version_b}: "
|
|
325
|
+
f"{counts['pass']} pass, {counts['warn']} warn, {counts['fail']} fail, "
|
|
326
|
+
f"{counts['no_reference']} without declared bands"
|
|
327
|
+
)
|
|
328
|
+
for result in run.results:
|
|
329
|
+
if result.error is not None:
|
|
330
|
+
print(f"{result.case_id} aborted: {result.error}", file=sys.stderr)
|
|
331
|
+
for line in run.solver_identity:
|
|
332
|
+
print(f"solver: {line}")
|
|
333
|
+
print(f"report: {yaml_path}")
|
|
334
|
+
print(f"report: {md_path}")
|
|
335
|
+
aborted = any(result.error is not None for result in run.results)
|
|
336
|
+
return 1 if counts["fail"] or aborted else 0
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _cmd_update_reference(args: argparse.Namespace) -> int:
|
|
340
|
+
try:
|
|
341
|
+
path = update_reference(args.case, args.from_report, args.reason)
|
|
342
|
+
except ValueError as error:
|
|
343
|
+
print(f"reference not updated: {error}", file=sys.stderr)
|
|
344
|
+
return 2
|
|
345
|
+
print(f"reference written: {path}")
|
|
346
|
+
print("commit it alone: a reference update never shares a commit with code changes")
|
|
347
|
+
return 0
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _cmd_apply_compat(args: argparse.Namespace) -> int:
|
|
351
|
+
promotions = apply_compat(args.report, repo_root=args.root)
|
|
352
|
+
if not promotions:
|
|
353
|
+
print("no verified or broken judgment in the report; nothing promoted")
|
|
354
|
+
return 0
|
|
355
|
+
for name, status, chapter in promotions:
|
|
356
|
+
print(f"{name}: {status} ({chapter})")
|
|
357
|
+
CommandRegistry.load.cache_clear()
|
|
358
|
+
CommandRegistry.load()
|
|
359
|
+
print(f"{len(promotions)} status(es) promoted; database reloaded and valid")
|
|
360
|
+
return 0
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
if __name__ == "__main__":
|
|
364
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Compat reports and status promotion (SAD Sections 3.2 and 11).
|
|
2
|
+
|
|
3
|
+
Pipeline role: turns a probe run into committed evidence and the
|
|
4
|
+
evidence into database statuses. :func:`write_compat_report` writes the
|
|
5
|
+
machine-readable YAML report and its rendered Markdown table under
|
|
6
|
+
``reports/compat/``, one evidence line per database command of the
|
|
7
|
+
probed version. :func:`apply_compat` reads a committed report back and
|
|
8
|
+
promotes ``documented`` statuses to ``verified`` or ``broken`` in the
|
|
9
|
+
chapter YAML files, citing the report in each promoted entry; statuses
|
|
10
|
+
are never hand-edited (CLAUDE.md invariant 3), and the schema rejects a
|
|
11
|
+
``verified`` or ``broken`` entry without its report citation.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import datetime
|
|
17
|
+
import re
|
|
18
|
+
from importlib import resources
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
from pyflightstream.commands import CommandEntry
|
|
24
|
+
from pyflightstream.qa.probes import ProbeOutcome, ProbeRun
|
|
25
|
+
|
|
26
|
+
COMPAT_SCHEMA = "pyflightstream-compat-report/1"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_compat_report(
|
|
30
|
+
run: ProbeRun, out_dir: str | Path, *, date: str | None = None, label: str | None = None
|
|
31
|
+
) -> tuple[Path, Path]:
|
|
32
|
+
"""Write one probe run as a compat report pair (YAML plus Markdown).
|
|
33
|
+
|
|
34
|
+
The YAML file is the machine-readable evidence ``apply_compat``
|
|
35
|
+
reads; the Markdown file renders the same content as a table for
|
|
36
|
+
review. Both share the stem ``CMP-<version digits>_<date>`` plus
|
|
37
|
+
the optional label. Existing report files are never overwritten:
|
|
38
|
+
evidence supersedes evidence only through a new, dated report.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
run : ProbeRun
|
|
43
|
+
The probe run to record.
|
|
44
|
+
out_dir : str or Path
|
|
45
|
+
Target directory, normally ``reports/compat/``.
|
|
46
|
+
date : str, optional
|
|
47
|
+
ISO date stamped into the report; defaults to today.
|
|
48
|
+
label : str, optional
|
|
49
|
+
Stem suffix distinguishing several reports on the same day
|
|
50
|
+
(for example ``"full"`` after a pilot).
|
|
51
|
+
|
|
52
|
+
Returns
|
|
53
|
+
-------
|
|
54
|
+
tuple of Path
|
|
55
|
+
The YAML path and the Markdown path, in that order.
|
|
56
|
+
|
|
57
|
+
Raises
|
|
58
|
+
------
|
|
59
|
+
FileExistsError
|
|
60
|
+
When a report with the same stem already exists.
|
|
61
|
+
"""
|
|
62
|
+
out_dir = Path(out_dir)
|
|
63
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
date = date or datetime.date.today().isoformat()
|
|
65
|
+
stem = f"CMP-{run.version.replace('.', '')}_{date}"
|
|
66
|
+
if label:
|
|
67
|
+
stem += f"_{label}"
|
|
68
|
+
yaml_path = out_dir / f"{stem}.yaml"
|
|
69
|
+
md_path = out_dir / f"{stem}.md"
|
|
70
|
+
for path in (yaml_path, md_path):
|
|
71
|
+
if path.exists():
|
|
72
|
+
raise FileExistsError(
|
|
73
|
+
f"{path} already exists; compat reports are evidence and are never "
|
|
74
|
+
"overwritten. Remove the stale file deliberately or pick another date."
|
|
75
|
+
)
|
|
76
|
+
counts = run.outcome_counts()
|
|
77
|
+
document = {
|
|
78
|
+
"schema": COMPAT_SCHEMA,
|
|
79
|
+
"fs_version": run.version,
|
|
80
|
+
"date": date,
|
|
81
|
+
"package_version": run.package_version,
|
|
82
|
+
"fs_exe": run.fs_exe_name,
|
|
83
|
+
"executor": "LocalExecutor, -hidden --script (SRC-003 pp.279-280)",
|
|
84
|
+
"solver_identity": list(run.solver_identity),
|
|
85
|
+
"summary": counts,
|
|
86
|
+
"commands": {
|
|
87
|
+
result.command: {
|
|
88
|
+
"outcome": result.outcome.value,
|
|
89
|
+
"detail": result.detail,
|
|
90
|
+
"signals": {
|
|
91
|
+
"sentinel_before": result.sentinel_before,
|
|
92
|
+
"sentinel_after": result.sentinel_after,
|
|
93
|
+
"effect": result.effect,
|
|
94
|
+
"log_errors": list(result.log_errors),
|
|
95
|
+
},
|
|
96
|
+
"wall_time_s": None if result.wall_time_s is None else round(result.wall_time_s, 2),
|
|
97
|
+
"return_code": result.return_code,
|
|
98
|
+
"script_sha256": result.script_sha256,
|
|
99
|
+
}
|
|
100
|
+
for result in run.results
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
yaml_path.write_text(yaml.safe_dump(document, sort_keys=False, width=100), encoding="utf-8")
|
|
104
|
+
md_path.write_text(_render_markdown(run, date, counts), encoding="utf-8")
|
|
105
|
+
return yaml_path, md_path
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _render_markdown(run: ProbeRun, date: str, counts: dict[str, int]) -> str:
|
|
109
|
+
"""Render the human-readable side of the report."""
|
|
110
|
+
lines = [
|
|
111
|
+
f"# Compat report: FlightStream {run.version} ({date})",
|
|
112
|
+
"",
|
|
113
|
+
"Tier 2 command-validity evidence produced by the probe harness",
|
|
114
|
+
"(`pyfs-qa probe`); one evidence line per database command of this",
|
|
115
|
+
"version. Database statuses are promoted from this report only",
|
|
116
|
+
"through `pyfs-qa apply-compat`, never edited by hand (CLAUDE.md",
|
|
117
|
+
"invariant 3). Probe scripts and logs are local scratch; this",
|
|
118
|
+
"report is the committed evidence.",
|
|
119
|
+
"",
|
|
120
|
+
"## Setup",
|
|
121
|
+
"",
|
|
122
|
+
"| Item | Value |",
|
|
123
|
+
"|---|---|",
|
|
124
|
+
f"| Executable | {run.fs_exe_name} (local, `_private/exe/`, never committed) |",
|
|
125
|
+
"| Executor | LocalExecutor, `-hidden --script` (SRC-003 pp.279-280) |",
|
|
126
|
+
f"| Package | pyflightstream {run.package_version} |",
|
|
127
|
+
f"| Solver identity lines | {'; '.join(run.solver_identity) or 'none captured'} |",
|
|
128
|
+
"",
|
|
129
|
+
"## Summary",
|
|
130
|
+
"",
|
|
131
|
+
f"{counts['verified']} verified, {counts['broken']} broken, {counts['unprobed']} unprobed.",
|
|
132
|
+
"",
|
|
133
|
+
"## Evidence per command",
|
|
134
|
+
"",
|
|
135
|
+
"| Command | Outcome | Evidence |",
|
|
136
|
+
"|---|---|---|",
|
|
137
|
+
]
|
|
138
|
+
for result in run.results:
|
|
139
|
+
detail = result.detail.replace("|", "\\|")
|
|
140
|
+
lines.append(f"| {result.command} | {result.outcome.value} | {detail} |")
|
|
141
|
+
lines.append("")
|
|
142
|
+
return "\n".join(lines)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def read_compat_report(path: str | Path) -> dict:
|
|
146
|
+
"""Load and check a machine-readable compat report.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
path : str or Path
|
|
151
|
+
The report YAML file.
|
|
152
|
+
|
|
153
|
+
Returns
|
|
154
|
+
-------
|
|
155
|
+
dict
|
|
156
|
+
The parsed report document.
|
|
157
|
+
|
|
158
|
+
Raises
|
|
159
|
+
------
|
|
160
|
+
ValueError
|
|
161
|
+
When the file does not carry the compat report schema marker.
|
|
162
|
+
"""
|
|
163
|
+
document = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
|
|
164
|
+
if not isinstance(document, dict) or document.get("schema") != COMPAT_SCHEMA:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
f"{path} is not a compat report (expected schema {COMPAT_SCHEMA!r}); "
|
|
167
|
+
"apply-compat only promotes statuses from probe evidence"
|
|
168
|
+
)
|
|
169
|
+
return document
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def apply_compat(
|
|
173
|
+
report_path: str | Path,
|
|
174
|
+
*,
|
|
175
|
+
repo_root: str | Path = ".",
|
|
176
|
+
commands_dir: str | Path | None = None,
|
|
177
|
+
) -> list[tuple[str, str, str]]:
|
|
178
|
+
"""Promote database statuses from a committed compat report.
|
|
179
|
+
|
|
180
|
+
Every command the report judged ``verified`` or ``broken`` gets its
|
|
181
|
+
version line in the chapter YAML rewritten to the new status with
|
|
182
|
+
the report cited in the ``report`` field; ``unprobed`` commands are
|
|
183
|
+
untouched. The edit is line-level on the flow-mapping version
|
|
184
|
+
lines, so chapter comments and layout survive; a version entry
|
|
185
|
+
spanning several lines is refused loudly rather than mangled.
|
|
186
|
+
|
|
187
|
+
Parameters
|
|
188
|
+
----------
|
|
189
|
+
report_path : str or Path
|
|
190
|
+
The committed report YAML (``reports/compat/CMP-*.yaml``).
|
|
191
|
+
repo_root : str or Path
|
|
192
|
+
Repository root; the report citation written into the database
|
|
193
|
+
is the report path relative to it, in POSIX form.
|
|
194
|
+
commands_dir : str or Path, optional
|
|
195
|
+
Chapter YAML directory; defaults to the installed
|
|
196
|
+
``pyflightstream.commands`` package data (the working tree in
|
|
197
|
+
an editable install). Tests point it at a copy.
|
|
198
|
+
|
|
199
|
+
Returns
|
|
200
|
+
-------
|
|
201
|
+
list of tuple
|
|
202
|
+
One ``(command, status, chapter file name)`` per promotion.
|
|
203
|
+
|
|
204
|
+
Raises
|
|
205
|
+
------
|
|
206
|
+
ValueError
|
|
207
|
+
When the report is not a compat report, when a judged command
|
|
208
|
+
or its version line cannot be found, or when a rewritten entry
|
|
209
|
+
fails schema validation.
|
|
210
|
+
"""
|
|
211
|
+
report = read_compat_report(report_path)
|
|
212
|
+
canonical = report["fs_version"]
|
|
213
|
+
citation = Path(report_path).resolve().relative_to(Path(repo_root).resolve()).as_posix()
|
|
214
|
+
if commands_dir is None:
|
|
215
|
+
commands_dir = Path(str(resources.files("pyflightstream.commands")))
|
|
216
|
+
commands_dir = Path(commands_dir)
|
|
217
|
+
|
|
218
|
+
targets = {
|
|
219
|
+
name: body
|
|
220
|
+
for name, body in report["commands"].items()
|
|
221
|
+
if body["outcome"] in (ProbeOutcome.VERIFIED.value, ProbeOutcome.BROKEN.value)
|
|
222
|
+
}
|
|
223
|
+
promotions: list[tuple[str, str, str]] = []
|
|
224
|
+
pending = dict(targets)
|
|
225
|
+
for chapter_path in sorted(commands_dir.glob("*.yaml")):
|
|
226
|
+
if chapter_path.name == "_meta.yaml":
|
|
227
|
+
continue
|
|
228
|
+
text = chapter_path.read_text(encoding="utf-8")
|
|
229
|
+
names = [name for name in yaml.safe_load(text) if name in pending]
|
|
230
|
+
if not names:
|
|
231
|
+
continue
|
|
232
|
+
for name in names:
|
|
233
|
+
body = pending.pop(name)
|
|
234
|
+
text = _rewrite_version_line(text, chapter_path.name, name, canonical, body, citation)
|
|
235
|
+
chapter_path.write_text(text, encoding="utf-8")
|
|
236
|
+
_validate_chapter(chapter_path, names)
|
|
237
|
+
promotions.extend((name, targets[name]["outcome"], chapter_path.name) for name in names)
|
|
238
|
+
if pending:
|
|
239
|
+
raise ValueError(
|
|
240
|
+
f"report judges {', '.join(sorted(pending))} but no chapter file defines "
|
|
241
|
+
"them; the report and the database have diverged"
|
|
242
|
+
)
|
|
243
|
+
return promotions
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _rewrite_version_line(
|
|
247
|
+
text: str, chapter: str, name: str, canonical: str, body: dict, citation: str
|
|
248
|
+
) -> str:
|
|
249
|
+
"""Rewrite one command's version line to its promoted status."""
|
|
250
|
+
lines = text.splitlines()
|
|
251
|
+
start = None
|
|
252
|
+
end = len(lines)
|
|
253
|
+
for index, line in enumerate(lines):
|
|
254
|
+
if start is None:
|
|
255
|
+
if line.rstrip() == f"{name}:":
|
|
256
|
+
start = index
|
|
257
|
+
elif re.match(r"^[A-Za-z0-9_]+:", line):
|
|
258
|
+
end = index
|
|
259
|
+
break
|
|
260
|
+
if start is None:
|
|
261
|
+
raise ValueError(f"{chapter}: command block {name} not found")
|
|
262
|
+
pattern = re.compile(rf'^(\s+)"{re.escape(canonical)}":\s*\{{.*\}}\s*$')
|
|
263
|
+
for index in range(start, end):
|
|
264
|
+
match = pattern.match(lines[index])
|
|
265
|
+
if match is None:
|
|
266
|
+
continue
|
|
267
|
+
status = body["outcome"]
|
|
268
|
+
fields = f'status: {status}, report: "{citation}"'
|
|
269
|
+
if status == ProbeOutcome.BROKEN.value:
|
|
270
|
+
note = str(body.get("detail", "")).replace('"', "'").replace("\n", " ")[:140]
|
|
271
|
+
fields += f', note: "{note}"'
|
|
272
|
+
lines[index] = f'{match.group(1)}"{canonical}": {{{fields}}}'
|
|
273
|
+
return "\n".join(lines) + "\n"
|
|
274
|
+
raise ValueError(
|
|
275
|
+
f"{chapter}: no single-line version entry for {canonical!r} in {name}; "
|
|
276
|
+
"promote this entry manually reviewable or normalize the block first"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _validate_chapter(chapter_path: Path, names: list[str]) -> None:
|
|
281
|
+
"""Re-validate the rewritten entries against the command schema."""
|
|
282
|
+
data = yaml.safe_load(chapter_path.read_text(encoding="utf-8"))
|
|
283
|
+
chapter = chapter_path.name.removesuffix(".yaml")
|
|
284
|
+
for name in names:
|
|
285
|
+
CommandEntry(name=name, chapter=chapter, **data[name])
|