dynamic-systems-testing-framework 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1059 @@
1
+ """Render per-test interactive reports and orchestrate the report suite.
2
+
3
+ The rendering half of the per-test report: Jinja env, the figure dicts the
4
+ page paints, the ``comparison_data.json`` sidecar + size budget, and the
5
+ threaded per-suite orchestration. The context it renders is built by
6
+ :mod:`dstf.reporting.context_builder`.
7
+ """
8
+
9
+ import json
10
+ import math
11
+ import os
12
+ import platform
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import uuid
17
+ from collections.abc import Mapping
18
+ from pathlib import Path
19
+
20
+ from ..comparison.comparator import VariableComparison
21
+ from ..comparison.types import TestComparison
22
+ from .context_builder import _build_template_context
23
+ from .rows import summary_fields
24
+ from .status import classify_with_class, refusal
25
+ from .tree_rows import criteria_rows
26
+
27
+ _TEMPLATE_DIR = Path(__file__).parent / "templates"
28
+
29
+ #: Vendored browser dependencies, shipped in the wheel under
30
+ #: ``reporting/templates/vendor/``. The report page used to pull Plotly
31
+ #: from a CDN, which renders a blank page in an air-gapped lab (and in
32
+ #: Playwright's offline mode).
33
+ #:
34
+ #: ONE copy per report suite, at the reports root — the directory holding
35
+ #: the per-test report dirs — reached from each page as
36
+ #: ``../plotly.min.js``. A copy per test dir cost 1.25 MB × N: 15 MB for
37
+ #: the 12-test demo, ~414 MB for the owner's 331-test TRANSFORM suite.
38
+ _VENDOR_DIR = _TEMPLATE_DIR / "vendor"
39
+ VENDOR_ASSETS: tuple[str, ...] = ("plotly.min.js",)
40
+
41
+ # The report page loads ONE ``interactive.js``: the axes script first (it
42
+ # defines what report.js calls), then report.js. The served name is
43
+ # historical.
44
+ INTERACTIVE_JS_PARTS: tuple[str, ...] = ("axes.js", "report.js")
45
+
46
+
47
+ def interactive_js_source() -> str:
48
+ """Return the concatenated ``interactive.js`` served with a report."""
49
+ return "".join(
50
+ (_TEMPLATE_DIR / part).read_text(encoding="utf-8")
51
+ for part in INTERACTIVE_JS_PARTS
52
+ )
53
+
54
+
55
+ def copy_vendor_assets(dest_root: Path) -> list[Path]:
56
+ """Copy the vendored browser libraries into *dest_root*, once.
57
+
58
+ *dest_root* is the REPORTS ROOT (the directory holding the per-test
59
+ report dirs), not a page directory: every page reaches the asset as
60
+ ``../plotly.min.js``.
61
+
62
+ Idempotent and safe to call from the report thread pool — every
63
+ per-test render calls it for the same destination. A same-size file
64
+ already there is left alone, and the copy itself lands on a unique
65
+ temp name before an atomic ``os.replace``, so a concurrent reader can
66
+ never see a half-written library.
67
+ """
68
+ written = []
69
+ dest_root.mkdir(parents=True, exist_ok=True)
70
+ for name in VENDOR_ASSETS:
71
+ src = _VENDOR_DIR / name
72
+ dest = dest_root / name
73
+ if dest.exists() and dest.stat().st_size == src.stat().st_size:
74
+ written.append(dest)
75
+ continue
76
+ tmp = dest.with_name(f"{name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
77
+ shutil.copyfile(src, tmp)
78
+ os.replace(tmp, dest)
79
+ written.append(dest)
80
+ return written
81
+
82
+
83
+ def write_report_assets(page_dir: Path) -> Path:
84
+ """Stage everything a page in *page_dir* loads by relative path.
85
+
86
+ The concatenated ``interactive.js`` lands IN *page_dir* (the page
87
+ loads it as ``interactive.js``); the vendored Plotly lands in
88
+ ``page_dir.parent``, the reports root shared by every page of the
89
+ suite (``../plotly.min.js``). Single entry point for both the
90
+ production report copy and the test fixtures that stage a page for a
91
+ browser: a page written without one of these is a blank report, and a
92
+ second copy of the asset list is a second thing to forget.
93
+
94
+ Returns the JS path.
95
+ """
96
+ dest = page_dir / "interactive.js"
97
+ # LF on every platform. Unlike the rest of a report, this
98
+ # file is byte-stable across runs, so an archived report tree
99
+ # regenerated from the other OS would otherwise diff wholly for
100
+ # nothing.
101
+ dest.write_text(interactive_js_source(), encoding="utf-8", newline="\n")
102
+ copy_vendor_assets(page_dir.parent)
103
+ return dest
104
+
105
+
106
+ def _json_sanitize(obj):
107
+ """Recursively replace non-finite floats (inf / -inf / nan) with None.
108
+
109
+ review 2026-07-06 finding 68: ``comparison_data.json`` is the documented
110
+ downstream artifact and the same context is embedded into
111
+ interactive.html — dumping with ``allow_nan=True`` injected literal
112
+ ``Infinity`` / ``NaN`` tokens (e.g. from missing-baseline sentinel
113
+ comparisons), which is invalid JSON for every strict consumer. Returns
114
+ new containers; the input is not mutated.
115
+ """
116
+ if isinstance(obj, float):
117
+ return obj if math.isfinite(obj) else None
118
+ if isinstance(obj, dict):
119
+ return {k: _json_sanitize(v) for k, v in obj.items()}
120
+ if isinstance(obj, (list, tuple)):
121
+ return [_json_sanitize(v) for v in obj]
122
+ return obj
123
+
124
+
125
+ def _sanitize_filename(name: str) -> str:
126
+ """Sanitize a MODEL ID for use in a directory name.
127
+
128
+ Collapses whitespace, replaces Modelica punctuation, and removes any
129
+ remaining filesystem-unsafe character. It took variable names once,
130
+ when a name could still be the text a multi-line `x={...}` spelled
131
+ (D132 made a variable's name the result file's); the one caller now
132
+ passes a model id.
133
+ """
134
+ # Collapse all whitespace (newlines, spaces, tabs) into single underscore
135
+ name = re.sub(r"\s+", "_", name)
136
+ # Replace common Modelica punctuation
137
+ name = name.replace("[", "_").replace("]", "").replace(".", "_")
138
+ name = name.replace(",", "_").replace("(", "").replace(")", "")
139
+ # Remove anything that's not alphanumeric, underscore, or hyphen
140
+ name = re.sub(r"[^\w\-]", "", name)
141
+ # Collapse multiple underscores
142
+ name = re.sub(r"_+", "_", name).strip("_")
143
+ return name
144
+
145
+
146
+ def plot_inputs(test, comp_variables, result, backend_cls, test_dir):
147
+ """``(plots, extra_series)`` for one test's page: the plots it draws and
148
+ every series of theirs the page's scored and no-baseline entries do not
149
+ answer — context series and watch plots.
150
+
151
+ The plots are :func:`~dstf.discovery.plots.plots_of_test`' — the ONE rule
152
+ the authoring tool's `Session.plots` draws by, so both tiers draw the
153
+ same plots: derived from the criteria tree, decorated by the entry's
154
+ ``plots`` extras, none for a failed run.
155
+
156
+ A test with no extras — or no backend class or test dir to read a
157
+ result file through — opens no result FILE: every derived subject is a
158
+ scored or tracked variable the render context already carries, so 331
159
+ TRANSFORM reports pay nothing, and what the result carries is what the
160
+ plots can draw. An extra's series is taken from the result in memory
161
+ when it is there, and read from the file only when it is not.
162
+ """
163
+ from ..discovery.plots import plots_of_test
164
+
165
+ if test is None:
166
+ return (), {}
167
+ extras = tuple(test.plots or ())
168
+ readable = bool(extras) and backend_cls is not None and test_dir is not None
169
+ plots = plots_of_test(
170
+ test,
171
+ [v.name for v in comp_variables or ()],
172
+ result,
173
+ listed=backend_cls.result_names(test_dir) if readable else None,
174
+ )
175
+ derived = {name for plot in plots if not plot.watch for name in plot.subject}
176
+ wanted = [
177
+ name
178
+ for plot in plots
179
+ for name in plot.series
180
+ if name not in derived and name not in plot.missing
181
+ ]
182
+ in_memory = {
183
+ var.name: (var.time, var.values)
184
+ for var in getattr(result, "variables", None) or ()
185
+ if var.name in wanted
186
+ }
187
+ extra_series = {
188
+ name: in_memory[name] for name in dict.fromkeys(wanted) if name in in_memory
189
+ }
190
+ unread = [name for name in dict.fromkeys(wanted) if name not in in_memory]
191
+ if readable and unread:
192
+ extra_series.update(backend_cls.result_series(test_dir, unread))
193
+ return plots, extra_series
194
+
195
+
196
+ def _context_labels(context: dict, key: str = "label") -> dict[str, str]:
197
+ """Name → what a render context reads for every series it can draw.
198
+
199
+ ``key`` picks which of the two readings
200
+ (:func:`~dstf.reporting.context_builder.test_texts`): ``"label"`` for
201
+ the surfaces that show the muted name beside it — the section heading,
202
+ the per-variable status badge — and ``"text"`` for every other one.
203
+ Element ids, ``data-variable``, the series lists and ``meta.id`` are
204
+ always the NAME.
205
+
206
+ ONE source: the ``variable_texts`` the context stamped, which is the
207
+ whole TEST's scope — every variable of the result, every scored
208
+ comparison and every plot series. Re-deriving it here from the entries
209
+ that happen to carry a label (the scored variables, the no-baseline
210
+ trajectories) gave a narrower scope that left a TRACKED but unscored
211
+ variable out, and it read its bare name on four surfaces of a page
212
+ where the tool read its expression. A context with no stamp — a
213
+ hand-built one — reads names, which is what a variable with no
214
+ expression reads anyway.
215
+ """
216
+ return {
217
+ name: entry.get(key) or name
218
+ for name, entry in (context.get("variable_texts") or {}).items()
219
+ }
220
+
221
+
222
+ def plot_page_keys(
223
+ context, plots, extra_series, metric_tree, max_samples, theme: str = "default"
224
+ ) -> dict:
225
+ """``plots`` (the sidecar's record), ``plot_figures``, ``plot_series``
226
+ and ``plot_sections`` from a render context, one of each per plot of
227
+ ``plots`` (:func:`plot_inputs`), in order. The figures are drawn in the
228
+ bundled theme ``theme`` — baked into the page, as its stylesheet is.
229
+
230
+ THREE sources answer a series name, in order: the scored variables
231
+ (``variables_by_name``), the no-baseline trajectories — a test with no
232
+ reference scores nothing, so its curves live only there — and
233
+ ``extra_series``, the context and watch series read out of the result.
234
+ A name in none of the three draws nothing and joins ``missing``; so does
235
+ one the plot already calls missing, even when a source answers it — a
236
+ tree leaf the result lacks is scored (and fails), and its entry has no
237
+ curve to draw.
238
+
239
+ A SUBJECT draws as the variable it is — its reference, its criteria'
240
+ geometry and its overlays; a CONTEXT series as its actual curve alone,
241
+ grey, even when it is scored on a plot of its own (its criteria are
242
+ there).
243
+
244
+ The ONE producer of the page's plot keys: the template loops over plot
245
+ sections, so a caller or a fixture that hand-rolled them would be a
246
+ second layout nobody renders through.
247
+ """
248
+ from ..figures.assemble import leaves_by_variable, view_from_trajectory
249
+ from ..figures.families import family_label, group_families
250
+ from ..figures.plotly import plot_figure
251
+ from ..figures.view import build_view
252
+
253
+ by_name = context.get("variables_by_name") or {}
254
+ nobaseline = {
255
+ t["name"]: t
256
+ for t in context.get("nobaseline_trajectories") or []
257
+ if t.get("name")
258
+ }
259
+ arrays = extra_series or {}
260
+ vidx = {n: i for i, n in enumerate(by_name)}
261
+ # Two readings of the same scope: `labels` where the muted name rides
262
+ # beside (the section heading), `texts` everywhere else.
263
+ labels = _context_labels(context)
264
+ texts = _context_labels(context, "text")
265
+ leaves_by_var = leaves_by_variable(metric_tree)
266
+ budget = max_samples if max_samples and max_samples > 0 else None
267
+ tree_view = context.get("tree_view")
268
+
269
+ def curve(n: str) -> tuple:
270
+ """``(time, values)`` of a series' actual curve, from whichever
271
+ source answers it."""
272
+ if n in by_name:
273
+ traj = by_name[n].get("trajectory") or {}
274
+ return traj.get("act_time"), traj.get("act_values")
275
+ if n in nobaseline:
276
+ return nobaseline[n].get("time"), nobaseline[n].get("values")
277
+ return arrays[n][0], arrays[n][1]
278
+
279
+ def subject_view(n: str):
280
+ if n in by_name:
281
+ return view_from_trajectory(
282
+ n, by_name[n].get("trajectory") or {}, leaves_by_var.get(n, [])
283
+ )
284
+ if n in nobaseline:
285
+ t = nobaseline[n]
286
+ return build_view(
287
+ n,
288
+ label=texts.get(n, n),
289
+ act_time=t.get("time"),
290
+ act_values=t.get("values"),
291
+ overlays=t.get("overlays") or [],
292
+ )
293
+ time, values = curve(n)
294
+ return build_view(n, label=texts.get(n, n), act_time=time, act_values=values)
295
+
296
+ def context_view(n: str):
297
+ time, values = curve(n)
298
+ return build_view(n, label=texts.get(n, n), act_time=time, act_values=values)
299
+
300
+ def drawable(n: str) -> bool:
301
+ return n in by_name or n in nobaseline or n in arrays
302
+
303
+ records: list[dict] = []
304
+ figures: list[dict | None] = []
305
+ series: list[list[str]] = []
306
+ sections: list[dict] = []
307
+ for position, plot in enumerate(plots):
308
+ gone = set(plot.missing)
309
+ subject = [n for n in plot.subject if n not in gone and drawable(n)]
310
+ context_names = [n for n in plot.context if n not in gone and drawable(n)]
311
+ drawn = subject + context_names
312
+ missing = list(
313
+ dict.fromkeys([*plot.missing, *(n for n in plot.series if n not in drawn)])
314
+ )
315
+ # No drawable SUBJECT, no chart (the template's `plot.subject`),
316
+ # whatever the context: no figure either. The null keeps the
317
+ # section's place — report.js paints `PLOT_FIGURES[idx]` into
318
+ # `plot-${idx}`.
319
+ figures.append(
320
+ plot_figure(
321
+ [subject_view(n) for n in subject],
322
+ budget,
323
+ context=[context_view(n) for n in context_names],
324
+ theme=theme,
325
+ )
326
+ if subject
327
+ else None
328
+ )
329
+ variables = [
330
+ {
331
+ "name": n,
332
+ "vidx": vidx[n],
333
+ **{k: v for k, v in by_name[n].items() if k != "trajectory"},
334
+ "overlays": [
335
+ _overlay_row(ov) for ov in by_name[n].get("overlays") or []
336
+ ],
337
+ }
338
+ # Every scored member's verdict, drawn or not: a leaf on a
339
+ # variable the result lacks failed, and its badge says so.
340
+ for n in plot.subject
341
+ if n in by_name
342
+ ]
343
+ # Overlays and the error select: a subject of ONE variable (the
344
+ # context does not count), drawn. A no-baseline variable has
345
+ # overlays and no reference to take an error against.
346
+ single = len(plot.subject) == 1
347
+ lone = subject[0] if single and subject else None
348
+ overlays = (
349
+ by_name[lone].get("overlays")
350
+ if lone in by_name
351
+ else nobaseline[lone].get("overlays")
352
+ if lone in nobaseline
353
+ else None
354
+ ) or []
355
+ criteria = criteria_rows(tree_view, plot.subject)
356
+ records.append(
357
+ {
358
+ "kind": plot.kind,
359
+ "series": drawn,
360
+ "subject": list(plot.subject),
361
+ "context": list(plot.context),
362
+ "watch": plot.watch,
363
+ "missing": missing,
364
+ }
365
+ )
366
+ series.append(drawn)
367
+ sections.append(
368
+ {
369
+ # By POSITION, not by ``plot.index``: report.js paints
370
+ # ``PLOT_FIGURES[idx]`` into ``plot-${idx}``, so a section
371
+ # numbered by anything else renders a blank chart.
372
+ "index": position,
373
+ # The title READS the plain labels of the SUBJECT; the
374
+ # names ride muted beside it, and only when one of them
375
+ # differs from its label — otherwise every title would say
376
+ # its variable twice. The muted name is what tells two
377
+ # series sharing one expression apart here, so the title
378
+ # does not repeat it. The fold is over the texts, as the
379
+ # tool's card title folds (`plot_card.folded`).
380
+ "title": ", ".join(
381
+ family_label(key, members, labels)
382
+ for key, members in group_families(list(plot.subject), texts)
383
+ ),
384
+ "names": ""
385
+ if all(labels.get(n, n) == n for n in plot.subject)
386
+ else ", ".join(
387
+ family_label(key, members)
388
+ for key, members in group_families(list(plot.subject))
389
+ ),
390
+ "series": drawn,
391
+ "subject": subject,
392
+ "missing": missing,
393
+ "single": single,
394
+ # The error select: ONE scored variable, drawn.
395
+ "errors": lone in by_name,
396
+ "watch": plot.watch,
397
+ "variables": variables,
398
+ "overlays": [_overlay_row(ov) for ov in overlays],
399
+ "criteria": criteria,
400
+ # Every crumb as wide as the longest, in ch: the leaf names
401
+ # start in one column.
402
+ "crumb_ch": max((len(row["crumbs"]) for row in criteria), default=0),
403
+ # Read as the texts: no muted name rides beside them.
404
+ "context": [texts.get(n, n) for n in plot.context],
405
+ }
406
+ )
407
+ return {
408
+ "plots": records,
409
+ "plot_figures": figures,
410
+ "plot_series": series,
411
+ "plot_sections": sections,
412
+ }
413
+
414
+
415
+ def _build_figures(context: dict, max_samples: int | None, theme: str) -> dict:
416
+ """The page's diagnostic figures, in the theme the page is generated
417
+ in; the plots' are :func:`plot_page_keys`'.
418
+ Built from the FULL-resolution context — the view decimates under
419
+ ``max_samples`` — and after the sidecar write, so
420
+ ``comparison_data.json`` keeps its trajectory shape and no figure."""
421
+ from ..figures.plotly import variable_figure
422
+ from ..figures.view import build_view
423
+
424
+ budget = max_samples if max_samples and max_samples > 0 else None
425
+ diag = [
426
+ variable_figure(
427
+ build_view(
428
+ t["name"],
429
+ act_time=t.get("act_time"),
430
+ act_values=t.get("act_values"),
431
+ ref_time=t.get("ref_time"),
432
+ ref_values=t.get("ref_values"),
433
+ ),
434
+ budget,
435
+ theme=theme,
436
+ )
437
+ for t in context.get("diag_trajectories") or []
438
+ ]
439
+ return {"diag_figures": diag}
440
+
441
+
442
+ def generate_comparison_plots(
443
+ model_id: str,
444
+ ref_data: dict | None,
445
+ result,
446
+ comparisons: list[VariableComparison],
447
+ plot_dir: Path,
448
+ test_dir: Path | None = None,
449
+ test_model=None,
450
+ ref_file: Path | None = None,
451
+ warnings: list | None = None,
452
+ last_run_at: float | None = None,
453
+ metric_tree=None,
454
+ artifact_files: tuple[tuple[str, str], ...] = (),
455
+ # Must track Config.max_embedded_samples — both production callers pass
456
+ # it explicitly, so this default only fires for direct callers (tests).
457
+ max_embedded_samples: int = 1000,
458
+ # Config.theme, said by both production callers as the budget is: the
459
+ # bundled theme the page is generated in (D148).
460
+ theme: str = "default",
461
+ overlays: list | None = None,
462
+ comparison: TestComparison | None = None,
463
+ ref_id: str | None = None,
464
+ run_metadata: dict | None = None,
465
+ plots=None,
466
+ extra_series=None,
467
+ ) -> Path | None:
468
+ """Render this test's interactive.html (Plotly) + comparison_data.json sidecar.
469
+
470
+ *comparison* is the test's ``TestComparison``: the sidecar's verdict
471
+ comes from it. Without one (a direct caller holding only the leaves)
472
+ the sidecar carries the numbers and no verdict.
473
+
474
+ *plots* / *extra_series* are :func:`plot_inputs`' — the suite and the
475
+ review path resolve them with the backend's result-file readers; a
476
+ direct caller that passes none gets them from the same function over
477
+ *test_model*, with no file to read.
478
+
479
+ Returns the path to the rendered interactive.html. With neither
480
+ *plots* nor *test_model* there is nothing to derive the plots from, and
481
+ a page with none would say nothing about it: ValueError.
482
+ """
483
+ if plots is None:
484
+ if test_model is None:
485
+ raise ValueError(
486
+ "generate_comparison_plots needs plots= or the test_model "
487
+ "they derive from"
488
+ )
489
+ plots, extra_series = plot_inputs(test_model, comparisons, result, None, None)
490
+ if plot_dir.exists():
491
+ shutil.rmtree(plot_dir)
492
+ plot_dir.mkdir(parents=True, exist_ok=True)
493
+
494
+ cur_stats = result.statistics if result else None
495
+ context = _build_template_context(
496
+ model_id,
497
+ comparisons,
498
+ ref_data,
499
+ cur_stats,
500
+ test_dir,
501
+ test_model,
502
+ result,
503
+ ref_file=ref_file,
504
+ warnings=warnings,
505
+ last_run_at=last_run_at,
506
+ metric_tree=metric_tree,
507
+ artifact_files=artifact_files,
508
+ overlays=overlays,
509
+ run_metadata=run_metadata,
510
+ # The page's label scope takes the plots too: a context or watch
511
+ # series named like another variable's expression is what decides
512
+ # whether that other one reads its name beside its label. Missing
513
+ # names included, as the tool's `Session.variable_texts` includes
514
+ # them: the plot still names them ("Not in this result: z").
515
+ plot_series=[n for p in plots for n in p.series],
516
+ )
517
+
518
+ # The page's plot keys, from the full-resolution context. Only the
519
+ # LAYOUT — names, no arrays — joins the sidecar; the figures are popped
520
+ # out and put back after the dump, where _build_figures is called.
521
+ page_keys = plot_page_keys(
522
+ context, plots, extra_series, metric_tree, max_embedded_samples, theme=theme
523
+ )
524
+ context["plots"] = page_keys.pop("plots")
525
+
526
+ # Write comparison_data.json alongside the HTML. This is the
527
+ # full-resolution data artifact for downstream tooling (notebooks,
528
+ # future JS lazy-fetch on zoom). ORDER CONSTRAINT: nothing that THINS
529
+ # the data may be IN the context at this point. The figures are built
530
+ # above (the layout record has to be in the sidecar, and it comes out
531
+ # of the same call) but are held OUT of ``context`` until after this
532
+ # write; only ``plots`` — names, no arrays — joins it. The page
533
+ # context, which drops the trajectory arrays, is built after it too.
534
+ data_path = plot_dir / "comparison_data.json"
535
+ # Summary block for the unified dashboard. The full context dict is
536
+ # the per-variable rendering input; the dashboard only needs row-level
537
+ # summary fields, exposed under "summary" so build_dashboard_context
538
+ # can read them without parsing the heavy variable arrays.
539
+ # `written_at` + `model_id` form a defensive double-entry-bookkeeping
540
+ # check for the dashboard's enricher: stale sidecars from a prior run
541
+ # (written_at < snapshot start_wall) and bookkeeping drift (sidecar's
542
+ # model_id ≠ row's model_id) are both filtered out so they can't
543
+ # override a fresh verdict.
544
+ import time as _time
545
+
546
+ # The one computation of a row's numbers (reporting.rows), shared with
547
+ # the dashboard's rows and, in the tool, the Suite table.
548
+ fields = summary_fields(
549
+ comparison
550
+ if comparison is not None
551
+ else TestComparison(
552
+ model_id=model_id, passed=True, variables=list(comparisons or ())
553
+ ),
554
+ warnings=warnings,
555
+ stats=cur_stats if isinstance(cur_stats, dict) else None,
556
+ ref_id=ref_id,
557
+ ref_file=ref_file,
558
+ field_sources=getattr(test_model, "field_sources", None) or {},
559
+ )
560
+ if comparison is None:
561
+ fields.update(status=None, status_text=None, status_class=None)
562
+ context["summary"] = {
563
+ "model_id": model_id,
564
+ "written_at": _time.time(),
565
+ **fields,
566
+ }
567
+ # review 2026-07-06 finding 68: strict JSON — sanitize non-finite
568
+ # floats to null and forbid NaN/Infinity tokens at dump time.
569
+ data_path.write_text(
570
+ json.dumps(_json_sanitize(context), indent=2, default=str, allow_nan=False),
571
+ encoding="utf-8",
572
+ )
573
+
574
+ # Figures come from the full-resolution context and decimate themselves;
575
+ # they are added AFTER the sidecar write so the sidecar's shape (the
576
+ # handoff artifact dashboard_render reads) does not change.
577
+ context.update(page_keys)
578
+ context.update(_build_figures(context, max_embedded_samples, theme))
579
+ # Why the comparison refused the test, for the summary line: the page's
580
+ # own flags read a test with no scored variable as one with no baseline.
581
+ context["refused"] = refusal(comparison) if comparison is not None else None
582
+
583
+ # ORDER CONSTRAINT: the page context is built LAST — after the sidecar
584
+ # write (which needs the full-resolution arrays) and after the figure
585
+ # build (which reads them, and whose ``full_samples`` stamps are what
586
+ # ``decimated`` is computed from). Stripping any earlier would empty
587
+ # the artifact or the figures instead of just the page.
588
+ page_context = _page_context(context)
589
+ page_context["decimated"] = _decimated_traces(context)
590
+
591
+ # review 2026-07-06 finding 68: the embedded page context must be
592
+ # strict JSON too — Jinja's |tojson would otherwise emit Infinity/NaN
593
+ # literals into window.MT_REPORT.
594
+ page_context = _json_sanitize(page_context)
595
+
596
+ # Render interactive HTML (Plotly, inline figure dicts).
597
+ # Stage 5: the static ``comparison.html`` path was retired. The index
598
+ # page linked only to interactive.html; the per-variable PNGs still
599
+ # render next to it for direct reference.
600
+ interactive_path = plot_dir / "interactive.html"
601
+ _render_template("interactive.html", page_context, interactive_path, theme)
602
+
603
+ # Write the standalone interactive.js beside the HTML and the vendored
604
+ # Plotly one level up (shared by the suite) so the `<script src=...>`
605
+ # tags resolve. The JS is pure-static (no Jinja interpolations) — the
606
+ # HTML marshals the per-report data into window.MT_REPORT; the JS reads
607
+ # from there. Plotly ships with the report rather than loading from a
608
+ # CDN: an air-gapped lab otherwise gets no plots and no error.
609
+ write_report_assets(plot_dir)
610
+
611
+ return interactive_path
612
+
613
+
614
+ def _overlay_row(ov: dict) -> dict:
615
+ """An overlay reduced to what the picker markup needs: its identity."""
616
+ return {"name": ov.get("name"), "role": ov.get("role"), "kind": ov.get("kind")}
617
+
618
+
619
+ def _page_context(context: dict) -> dict:
620
+ """The page's copy of the render context, with no trajectory arrays.
621
+
622
+ The page draws figure dicts, so the arrays would only be a second
623
+ full-resolution copy of data the figures already carry. Built as a
624
+ COPY, after the sidecar write — ``comparison_data.json`` is the
625
+ documented downstream artifact and keeps its trajectory shape.
626
+
627
+ ``variables_by_name`` is dropped whole: the page is laid out per PLOT,
628
+ and each section already carries its variables' verdicts, criteria and
629
+ overlay rows (:func:`plot_page_keys`). A no-baseline trajectory keeps
630
+ only its name: the summary line counts them, and its plot's section
631
+ carries the rest.
632
+ """
633
+ page = dict(context)
634
+ page.pop("trajectories", None)
635
+ page.pop("variables_by_name", None)
636
+ page["diag_trajectories"] = [
637
+ {"name": t.get("name")} for t in context.get("diag_trajectories") or []
638
+ ]
639
+ page["nobaseline_trajectories"] = [
640
+ {"name": t.get("name")} for t in context.get("nobaseline_trajectories") or []
641
+ ]
642
+ return page
643
+
644
+
645
+ def _trace_label(tr: dict, texts: Mapping[str, str] | None) -> str | None:
646
+ """What the banner calls one trace.
647
+
648
+ On a plot of one subject the trace's own name is right, and the banner
649
+ has always printed it; ``texts`` is None there. On a FAMILY's plot it
650
+ is not: a family's lead member carries the family's LEGEND label, so
651
+ the banner read ``pipe.T[1:4]`` where one member belonged, and a family
652
+ of one printed its name twice (once as the plot's title, once as the
653
+ trace). There the series comes off ``meta.id`` — the NAME — and
654
+ ``texts`` turns it back into what the rest of the page calls it, so one
655
+ banner line does not pair a plot full of expressions with a trace
656
+ spelled as a result-file name. ``texts`` is the TEST's scope, like the
657
+ plot's own name in the row beside it. A derived trace — a band, an
658
+ error series, a spectrum — and a context trace keep their own names.
659
+ """
660
+ name = tr.get("name")
661
+ if texts is None:
662
+ return name
663
+ trace_id = (tr.get("meta") or {}).get("id")
664
+ if not isinstance(trace_id, str):
665
+ return name
666
+ for prefix, suffix in (("actual/", ""), ("reference/", " (reference)")):
667
+ if trace_id.startswith(prefix):
668
+ series = trace_id[len(prefix) :]
669
+ return f"{texts.get(series, series)}{suffix}"
670
+ return name
671
+
672
+
673
+ def _decimated_traces(context: dict) -> list[dict]:
674
+ """Every figure trace the sample budget shortened, named.
675
+
676
+ The figure layer stamps each trace's pre-decimation count in
677
+ ``meta.full_samples`` and the count it actually carries in
678
+ ``meta.samples``; a trace whose stamp exceeds what it carries was
679
+ decimated. Both counts are samples, not points on the wire — a band
680
+ trace is a closed polygon holding twice its sample count — so the
681
+ embedded side reads ``meta.samples`` and falls back to the x length
682
+ only for a trace the figure layer does not decimate. The template
683
+ renders this list as the banner, so the reader sees WHICH curves were
684
+ thinned rather than a page-wide total. A plot's trace is placed by its
685
+ PLOT's SUBJECT, folded the way the section heading folds it but read as
686
+ the TEXTS: the banner shows no muted name, so two plots of two
687
+ variables sharing one expression would otherwise be two identical rows.
688
+ A context trace is a trace of that plot, under its own name.
689
+ """
690
+ out: list[dict] = []
691
+ texts = _context_labels(context, "text")
692
+
693
+ def scan(varname, fig: dict, *, texts: Mapping[str, str] | None = None) -> None:
694
+ for tr in fig.get("data") or []:
695
+ meta = tr.get("meta") or {}
696
+ full = meta.get("full_samples")
697
+ embedded = int(meta.get("samples", len(tr.get("x") or [])))
698
+ if full is None or full <= embedded:
699
+ continue
700
+ out.append(
701
+ {
702
+ "variable": varname,
703
+ "trace": _trace_label(tr, texts),
704
+ "full": int(full),
705
+ "embedded": embedded,
706
+ }
707
+ )
708
+
709
+ from ..figures.families import family_label, group_families
710
+
711
+ for section, fig in zip(
712
+ context.get("plot_sections") or [], context.get("plot_figures") or []
713
+ ):
714
+ if fig is None:
715
+ continue # a section with no chart
716
+ subject = section.get("subject") or []
717
+ scan(
718
+ ", ".join(
719
+ family_label(key, members, texts)
720
+ for key, members in group_families(subject, texts)
721
+ ),
722
+ fig,
723
+ texts=texts if len(subject) > 1 else None,
724
+ )
725
+ for traj, fig in zip(
726
+ context.get("diag_trajectories") or [], context.get("diag_figures") or []
727
+ ):
728
+ scan(traj.get("name"), fig)
729
+ return out
730
+
731
+
732
+ def jinja_env(theme: str = "default"):
733
+ """The per-test report's Jinja environment: the templates dir, autoescape,
734
+ and the token block every page starts its stylesheet with, in the
735
+ bundled theme ``theme``. One constructor, so a test that renders a page
736
+ gets the same globals."""
737
+ from jinja2 import Environment, FileSystemLoader
738
+
739
+ from .theme import css_variables
740
+
741
+ env = Environment(loader=FileSystemLoader(str(_TEMPLATE_DIR)), autoescape=True)
742
+ env.globals["dstf_css"] = css_variables(theme)
743
+ return env
744
+
745
+
746
+ def _render_template(
747
+ template_name: str, context: dict, output_path: Path, theme: str
748
+ ) -> None:
749
+ """Render a Jinja2 template with the given context."""
750
+ template = jinja_env(theme).get_template(template_name)
751
+ html = template.render(**context)
752
+ output_path.write_text(html, encoding="utf-8")
753
+
754
+
755
+ def _build_per_test_args(
756
+ comp,
757
+ results,
758
+ test_lookup,
759
+ store,
760
+ config,
761
+ manifest_meta_by_model,
762
+ artifact_files=(),
763
+ backend_cls=None,
764
+ ):
765
+ """Resolve all per-test inputs needed to render that test's report."""
766
+ from .overlay_loader import load_overlays
767
+
768
+ model_id = comp.model_id
769
+ result = results.get(model_id)
770
+ test = test_lookup.get(model_id)
771
+ ref_data = store.get_reference(model_id)
772
+ meta = manifest_meta_by_model.get(model_id, {})
773
+ test_key = meta.get("test_key")
774
+ last_run_at = meta.get("last_run_at")
775
+ # A2 — load every registered overlay for this model (soft_checks +
776
+ # companions). Failures are absorbed into Overlay.status so the
777
+ # report never breaks on a moved/renamed companion file.
778
+ # Passing ``config`` also auto-discovers peer-backend refs as
779
+ # sibling-backend companions (visual-only pre-accept cross-check).
780
+ overlays = load_overlays(store, model_id, config=config)
781
+
782
+ # One classifier for every surface (reporting.status). The private copy
783
+ # that used to live here existed only because simulate_only was readable
784
+ # from the TestModel and not from the comparison; TestComparison carries
785
+ # it now, so the carve-out is gone (D117 closed).
786
+ status_text, status_class = classify_with_class(comp)
787
+
788
+ test_dir = config.work_dir / test_key if test_key else None
789
+
790
+ # The page's plots, through the one helper (it derives them from the
791
+ # tree itself, so this path and cli/review.py cannot drift).
792
+ plots, extra_series = plot_inputs(
793
+ test, comp.variables, result, backend_cls, test_dir
794
+ )
795
+
796
+ ref_file = None
797
+ test_id = store.index.get_id(model_id)
798
+ if test_id:
799
+ from ..storage.ref_index import RefIndex
800
+
801
+ ref_file = store.ref_dir / RefIndex.ref_filename(test_id)
802
+
803
+ if comp.test_id:
804
+ report_id = f"ref_{comp.test_id}"
805
+ elif test_key:
806
+ report_id = test_key
807
+ else:
808
+ report_id = _sanitize_filename(model_id)
809
+
810
+ # The test's baseline, for its row's Reference: the one it was compared
811
+ # against, else the one the store holds for it. A comparison that
812
+ # compared nothing (a failed simulation's, a test's with no result)
813
+ # names none, and its sidecar said null beside that baseline's file.
814
+ ref_number = comp.test_id or test_id
815
+
816
+ return {
817
+ "model_id": model_id,
818
+ "ref_data": ref_data,
819
+ "result": result,
820
+ "test": test,
821
+ "test_dir": test_dir,
822
+ "ref_file": ref_file,
823
+ "warnings": comp.warnings,
824
+ "report_id": report_id,
825
+ "test_key": test_key,
826
+ "status_text": status_text,
827
+ "status_class": status_class,
828
+ "ref_id": f"ref_{ref_number}" if ref_number else None,
829
+ "last_run_at": last_run_at,
830
+ "comparison": comp,
831
+ "comp_variables": comp.variables,
832
+ "metric_tree": comp.metric_tree,
833
+ "artifact_files": artifact_files,
834
+ "max_embedded_samples": config.max_embedded_samples,
835
+ "theme": config.theme,
836
+ "overlays": overlays,
837
+ "plots": plots,
838
+ "extra_series": extra_series,
839
+ }
840
+
841
+
842
+ def _render_one_test(args: dict, report_dir: Path) -> dict:
843
+ """Render a single test's report. Returns the index entry."""
844
+ import time as _time
845
+
846
+ t0 = _time.monotonic()
847
+ plot_dir = report_dir / args["report_id"]
848
+ html_path = generate_comparison_plots(
849
+ model_id=args["model_id"],
850
+ ref_data=args["ref_data"],
851
+ result=args["result"],
852
+ comparisons=args["comp_variables"],
853
+ plot_dir=plot_dir,
854
+ test_dir=args["test_dir"],
855
+ test_model=args["test"],
856
+ ref_file=args["ref_file"],
857
+ warnings=args["warnings"],
858
+ last_run_at=args["last_run_at"],
859
+ metric_tree=args.get("metric_tree"),
860
+ artifact_files=args.get("artifact_files", ()),
861
+ max_embedded_samples=args["max_embedded_samples"],
862
+ theme=args["theme"],
863
+ overlays=args.get("overlays"),
864
+ comparison=args.get("comparison"),
865
+ ref_id=args.get("ref_id"),
866
+ run_metadata=args.get("run_metadata"),
867
+ plots=args.get("plots"),
868
+ extra_series=args.get("extra_series"),
869
+ )
870
+ render_elapsed = _time.monotonic() - t0
871
+ return {
872
+ "model_id": args["model_id"],
873
+ "status_text": args["status_text"],
874
+ "status_class": args["status_class"],
875
+ "ref_id": args["ref_id"],
876
+ "test_key": args["test_key"],
877
+ "last_run_at": args["last_run_at"],
878
+ "report_path": f"{args['report_id']}/interactive.html" if html_path else None,
879
+ "_render_elapsed": render_elapsed,
880
+ }
881
+
882
+
883
+ def _read_run_metadata(work_dir: Path) -> dict | None:
884
+ """Read + shape the run provenance from ``work_dir/status.json`` for the
885
+ per-test banner. Returns None (banner omitted) when status.json is absent,
886
+ unreadable, or predates the ``metadata`` field. Never raises."""
887
+ from ..backends.progress import read_status
888
+ from .dashboard_render import format_run_metadata
889
+
890
+ snapshot = read_status(work_dir)
891
+ if snapshot is None:
892
+ return None
893
+ return format_run_metadata(snapshot.get("metadata"))
894
+
895
+
896
+ def generate_report_suite(
897
+ comparisons: list,
898
+ results: dict,
899
+ tests: list,
900
+ store,
901
+ config,
902
+ ) -> Path:
903
+ """Generate per-test comparison reports.
904
+
905
+ Per-test report rendering runs on a thread pool sized by config.parallel.
906
+ The hot work is Plotly trace JSON serialization + comparison_data.json
907
+ dump + decimation; numpy/json release the GIL well enough that threads
908
+ give a meaningful speedup without the pickling cost of a process pool.
909
+
910
+ Returns the path to the unified dashboard HTML file (rendered separately
911
+ by ``dashboard_render.render_final``).
912
+ """
913
+ import time as _time
914
+ from concurrent.futures import ThreadPoolExecutor, as_completed
915
+
916
+ report_wall_start = _time.monotonic()
917
+ report_dir = config.work_dir / "reports"
918
+ report_dir.mkdir(parents=True, exist_ok=True)
919
+
920
+ test_lookup = {t.model_id: t for t in tests}
921
+
922
+ # Resolve the backend CLASS once up front — its artifact file list and
923
+ # the result-file readers a test's plot extras need are both class
924
+ # attributes, so no backend is instantiated (which would trigger the
925
+ # fmpy import if the extra isn't installed). An unregistered backend
926
+ # leaves both at their empty defaults rather than raising per test.
927
+ from ..backends import get_backend_class
928
+
929
+ try:
930
+ backend_cls = get_backend_class(config)
931
+ artifact_files = tuple(backend_cls.artifact_files)
932
+ except ValueError:
933
+ backend_cls = None
934
+ artifact_files = ()
935
+
936
+ # Cache test_key + last_run_at lookup once instead of scanning manifests per test
937
+ manifest_meta_by_model: dict[str, dict] = {}
938
+ manifest_paths = sorted(config.work_dir.glob("batch_manifest.json"))
939
+ if manifest_paths:
940
+ from ..backends import BatchManifest
941
+
942
+ for mp in manifest_paths:
943
+ bm = BatchManifest.load(mp)
944
+ for tk, entry in bm.manifest.items():
945
+ manifest_meta_by_model.setdefault(
946
+ entry["model_id"],
947
+ {
948
+ "test_key": tk,
949
+ "last_run_at": entry.get("last_run_at"),
950
+ },
951
+ )
952
+
953
+ # Run provenance for the per-test banner — read once from status.json and
954
+ # shaped by the same formatter the dashboard uses, so both surfaces agree.
955
+ # None when status.json predates the metadata field (banner is omitted).
956
+ run_metadata = _read_run_metadata(config.work_dir)
957
+
958
+ # Resolve per-test args sequentially (cheap dict/store lookups), then
959
+ # render reports in parallel
960
+ work = [
961
+ _build_per_test_args(
962
+ comp,
963
+ results,
964
+ test_lookup,
965
+ store,
966
+ config,
967
+ manifest_meta_by_model,
968
+ artifact_files,
969
+ backend_cls,
970
+ )
971
+ for comp in comparisons
972
+ ]
973
+ for args in work:
974
+ args["run_metadata"] = run_metadata
975
+
976
+ n_workers = max(1, min(getattr(config, "parallel", 1) or 1, len(work)))
977
+ index_tests: list[dict] = []
978
+ total_reports = len(work)
979
+
980
+ from ..backends.base import _print_progress
981
+
982
+ print(f"Generating {total_reports} reports (parallel={n_workers})...")
983
+
984
+ if n_workers <= 1 or len(work) <= 1:
985
+ for i, args in enumerate(work, 1):
986
+ index_tests.append(_render_one_test(args, report_dir))
987
+ short = args["model_id"].rsplit(".", 1)[-1]
988
+ _print_progress(i, total_reports, short, "ok")
989
+ else:
990
+ # Preserve original test order in the index
991
+ results_by_model: dict[str, dict] = {}
992
+ completed = 0
993
+ with ThreadPoolExecutor(
994
+ max_workers=n_workers, thread_name_prefix="report"
995
+ ) as pool:
996
+ futures = {
997
+ pool.submit(_render_one_test, args, report_dir): args["model_id"]
998
+ for args in work
999
+ }
1000
+ for future in as_completed(futures):
1001
+ entry = future.result()
1002
+ results_by_model[entry["model_id"]] = entry
1003
+ completed += 1
1004
+ short = entry["model_id"].rsplit(".", 1)[-1]
1005
+ _print_progress(completed, total_reports, short, "ok")
1006
+ index_tests = [results_by_model[args["model_id"]] for args in work]
1007
+
1008
+ # Per-test interactive.html + comparison_data.json sidecars are
1009
+ # written above. The unified work_dir/dashboard.html is rendered
1010
+ # by cli._generate_report_suite via dashboard_render.render_final;
1011
+ # the standalone index.html that used to live here is retired.
1012
+
1013
+ # Phase timing — exposes whether parallelism is helping
1014
+ wall = _time.monotonic() - report_wall_start
1015
+ elapsed = [t.get("_render_elapsed", 0.0) for t in index_tests]
1016
+ total_work = sum(elapsed)
1017
+ if elapsed:
1018
+ slowest = max(elapsed)
1019
+ avg = total_work / len(elapsed)
1020
+ speedup = (total_work / wall) if wall > 0 else 0.0
1021
+ print(
1022
+ f"Report phase: {wall:.0f}s wall, {total_work:.0f}s total work, "
1023
+ f"{speedup:.1f}x parallel speedup (avg {avg:.1f}s/test, slowest {slowest:.1f}s)"
1024
+ )
1025
+ return config.work_dir / "dashboard.html"
1026
+
1027
+
1028
+ def open_in_browser(path: Path, *, launch: bool = True) -> None:
1029
+ """Open a file in the system's default browser/viewer.
1030
+
1031
+ Always prints a ``file://`` URL so the user has a clickable fallback
1032
+ (most terminals make it clickable); ``launch=False`` — ``run`` and
1033
+ ``compare`` under ``--no-browser`` — stops there.
1034
+ """
1035
+ print(f"View: {path.resolve().as_uri()}")
1036
+ if launch:
1037
+ _launch_opener(path)
1038
+
1039
+
1040
+ def _launch_opener(path: Path) -> None:
1041
+ """Hand ``path`` to the system's opener. Subprocess stdout/stderr are
1042
+ silenced — on headless Linux/WSL ``xdg-open`` prints a long list of
1043
+ "Permission denied" candidates when no browser is registered, and
1044
+ that noise has no actionable signal for the user.
1045
+
1046
+ The one place a process is started for a browser: the test suite
1047
+ replaces it (``tests/conftest.py``), so no test opens one.
1048
+ """
1049
+ system = platform.system()
1050
+ silent = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
1051
+ try:
1052
+ if system == "Windows":
1053
+ subprocess.Popen(["start", "", str(path)], shell=True, **silent)
1054
+ elif system == "Darwin":
1055
+ subprocess.Popen(["open", str(path)], **silent)
1056
+ else:
1057
+ subprocess.Popen(["xdg-open", str(path)], **silent)
1058
+ except OSError:
1059
+ pass