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,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])