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,868 @@
1
+ """Evaluate a parsed MetricTree spec against simulation + reference data.
2
+
3
+ Takes a :class:`SpecNode` from :mod:`tree_spec` plus the per-variable
4
+ simulation result and reference dicts, and walks the tree to produce an
5
+ evaluated :class:`MetricResult` root.
6
+
7
+ Leaves evaluate via the existing :func:`resolve_mode` factory — the spec
8
+ is just a declarative envelope around the same per-variable comparison
9
+ that :func:`compare_test` already does in the implicit-AND path. Internal
10
+ nodes look ``spec.combinator`` up in :mod:`registry` to get the concrete
11
+ ``Combinator`` from :mod:`metric_tree`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from dataclasses import dataclass, field
18
+
19
+ import numpy as np
20
+
21
+ from ..backends import VariableResult
22
+ from ..backends.common.statistics import read_summary
23
+ from ..backends.types import DatasetType
24
+ from .comparator import VariableComparison, migration_hint
25
+ from .geometry import Window
26
+ from .metric_tree import (
27
+ Combinator,
28
+ MetricResult,
29
+ leaf_from_variable,
30
+ stamp_leaf_path,
31
+ )
32
+ from .modes import ComparisonMode, resolve_mode
33
+ from .registry import DEFAULT_METRIC, MetricDescriptor
34
+ from .registry import combinator as registry_combinator
35
+ from .registry import metric as registry_metric
36
+ from .sides import Sides, parse_sides
37
+ from .tree_spec import CombinatorSpec, LeafSpec, SpecNode, resolves_a_variable
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ PRIMARY_BASELINE = "primary"
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class BaselineView:
46
+ """Pre-processed view of one baseline for leaf evaluation.
47
+
48
+ Packages the per-variable reference dicts and the shared time vector
49
+ (or ``None`` when each variable carries its own time). Multiple named
50
+ baselines coexist in the evaluator's input (``{"primary": ...,
51
+ "experiment": ...}``); a leaf's ``against`` field picks which one it
52
+ scores against.
53
+ """
54
+
55
+ name: str
56
+ ref_vars_by_name: dict[str, dict]
57
+ shared_ref_time: np.ndarray | None = None
58
+ #: Statistics captured with the baseline (dotted keys like
59
+ #: ``translation.scalar_unknowns``) and the stored diagnostics summaries
60
+ #: (``{"name", "final", "min", "max"}``) — the baseline side of a
61
+ #: ``scalar`` leaf's stats-path quantity.
62
+ statistics: dict = field(default_factory=dict)
63
+ diagnostics: list[dict] = field(default_factory=list)
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class ScalarSources:
68
+ """The current run's stored scalars, for stats-path ``scalar`` leaves.
69
+
70
+ Mirrors :class:`BaselineView`'s two scalar fields for the actual side.
71
+ The difference is the diagnostics shape: a run's diagnostics are
72
+ :class:`~dstf.backends.VariableResult` trajectories, so ``final`` /
73
+ ``min`` / ``max`` are computed live rather than read from a summary.
74
+ """
75
+
76
+ statistics: dict = field(default_factory=dict)
77
+ diagnostics: list[VariableResult] = field(default_factory=list)
78
+
79
+
80
+ def evaluate_spec(
81
+ spec: SpecNode,
82
+ var_results_by_name: dict[str, VariableResult],
83
+ baselines: dict[str, BaselineView],
84
+ base_tolerance: float,
85
+ dataset_kind: DatasetType = DatasetType.TIME_SERIES,
86
+ scalars: ScalarSources | None = None,
87
+ path: str = "/metrics",
88
+ model_id: str = "",
89
+ _warned: set[tuple[str, str]] | None = None,
90
+ ) -> MetricResult:
91
+ """Walk a spec tree and produce an evaluated :class:`MetricResult` root.
92
+
93
+ ``base_tolerance`` is used when a leaf's params omit ``tolerance`` (the
94
+ per-test / config / default chain resolved by the caller).
95
+
96
+ ``baselines`` maps baseline name (``"primary"``, ``"experiment"``, ...)
97
+ to a :class:`BaselineView`; each leaf's ``against`` field picks which
98
+ one it scores against, defaulting to ``"primary"``.
99
+
100
+ ``dataset_kind`` is the :class:`~dstf.backends.types.DatasetType` the
101
+ supplied results carry. Every backend produces ``TIME_SERIES`` today,
102
+ so it is a parameter rather than a field on ``VariableResult`` — a leaf
103
+ whose metric doesn't accept the kind hard-fails with a diagnostic.
104
+
105
+ ``scalars`` carries the run's statistics + diagnostics for ``scalar``
106
+ leaves whose quantity is a stats path; leaves that reduce a tracked
107
+ series never touch it.
108
+
109
+ ``path`` is this node's RFC 6902 path; leaves are stamped with it
110
+ (``stamp_leaf_path``) so results address the spec, not a position in a
111
+ flat list.
112
+
113
+ ``model_id`` only names the test in the migrate-hint log line: a suite
114
+ of tests printing the same nameless sentence says how many baselines
115
+ are un-migrated and not one of the models whose baseline to open.
116
+ """
117
+ sources = scalars if scalars is not None else ScalarSources()
118
+ # The migrate hint is a fact about a VARIABLE's baseline entry, not
119
+ # about a leaf: a TRANSFORM test with five criteria on one un-migrated
120
+ # variable printed the same sentence five times. The set is created by
121
+ # the outermost call and threaded down, so it spans one test. Keyed by
122
+ # the baseline too — the same variable missing from the primary AND
123
+ # from a soft_check is two things to fix, not one repeat.
124
+ seen = _warned if _warned is not None else set()
125
+ if isinstance(spec, LeafSpec):
126
+ result = _evaluate_leaf(
127
+ spec,
128
+ var_results_by_name,
129
+ baselines,
130
+ base_tolerance,
131
+ dataset_kind,
132
+ sources,
133
+ seen,
134
+ model_id,
135
+ )
136
+ stamp_leaf_path(result, path)
137
+ return result
138
+
139
+ children = [
140
+ evaluate_spec(
141
+ c,
142
+ var_results_by_name,
143
+ baselines,
144
+ base_tolerance,
145
+ dataset_kind,
146
+ sources,
147
+ path=f"{path}/children/{i}",
148
+ model_id=model_id,
149
+ _warned=seen,
150
+ )
151
+ for i, c in enumerate(spec.children)
152
+ ]
153
+ return _build_combinator(spec).combine(children)
154
+
155
+
156
+ def collect_leaf_variables(tree: MetricResult) -> list[VariableComparison]:
157
+ """Return the ``VariableComparison`` stashed on each leaf, in tree order.
158
+
159
+ ``leaf_from_variable`` puts the originating ``VariableComparison`` in
160
+ ``diagnostics['variable']``. This helper walks the tree and collects
161
+ them so the reporter (which still takes a flat list) sees the
162
+ variables referenced by the user's tree.
163
+ """
164
+ out: list[VariableComparison] = []
165
+ _walk_leaves(tree, out)
166
+ return out
167
+
168
+
169
+ def leaf_comparisons_by_path(tree: MetricResult) -> dict[str, VariableComparison]:
170
+ """``{leaf_path: VariableComparison}`` for every stamped leaf, in tree order.
171
+
172
+ The report and the figure layer look leaves up here, by the path a patch
173
+ op would use — never by position in a flat list.
174
+ """
175
+ out: dict[str, VariableComparison] = {}
176
+
177
+ def _walk(node: MetricResult) -> None:
178
+ if not node.children:
179
+ vc = node.diagnostics.get("variable")
180
+ path = node.diagnostics.get("leaf_path")
181
+ if vc is not None and path is not None:
182
+ out[path] = vc
183
+ return
184
+ for child in node.children:
185
+ _walk(child)
186
+
187
+ _walk(tree)
188
+ return out
189
+
190
+
191
+ def advisory_comparisons(tree: MetricResult | None) -> list[VariableComparison]:
192
+ """The leaf comparisons under a ``warn``, in tree order — the same
193
+ objects :func:`collect_leaf_variables` returns. ``warn`` never fails its
194
+ parent, so a failure among them informs and cannot fail the test: a
195
+ surface that counts failures leaves these out, or calls them advisory.
196
+ ``[]`` for no tree.
197
+
198
+ A ``warn`` node is the one whose diagnostics carry ``warned``
199
+ (``WarnCombinator.combine`` always sets it, to either value)."""
200
+ out: list[VariableComparison] = []
201
+
202
+ def _walk(node: MetricResult, under_warn: bool) -> None:
203
+ if not node.children:
204
+ if under_warn:
205
+ _walk_leaves(node, out)
206
+ return
207
+ for child in node.children:
208
+ _walk(child, under_warn or "warned" in (node.diagnostics or {}))
209
+
210
+ if tree is not None:
211
+ _walk(tree, False)
212
+ return out
213
+
214
+
215
+ def failed_groups(tree: MetricResult | None) -> list[tuple[str, MetricResult]]:
216
+ """The groups that fail ON THEIR OWN: a combinator that did not pass
217
+ though every child of it did — a ``weighted`` whose sum is on the wrong
218
+ side of its threshold, or undefined — as ``(place, node)`` in tree
219
+ order, the place as a parse error writes it (``metrics.children[1]``).
220
+ None from under a ``warn``: nothing there can fail the test. ``[]`` for
221
+ no tree.
222
+
223
+ A test can FAIL with no failing criterion; it then fails at one of
224
+ these, and a surface that lists what failed has only them to name."""
225
+ out: list[tuple[str, MetricResult]] = []
226
+
227
+ def _walk(node: MetricResult, place: str) -> None:
228
+ if not node.children or "warned" in (node.diagnostics or {}):
229
+ return
230
+ if not node.passed and all(child.passed for child in node.children):
231
+ out.append((place, node))
232
+ for i, child in enumerate(node.children):
233
+ _walk(child, f"{place}.children[{i}]")
234
+
235
+ if tree is not None:
236
+ _walk(tree, "metrics")
237
+ return out
238
+
239
+
240
+ def flatten_evaluation(
241
+ tree: MetricResult,
242
+ *,
243
+ root: str = "/metrics",
244
+ ) -> dict[str, dict]:
245
+ """Flatten an evaluated MetricResult tree into a ``{path: {...}}`` dict.
246
+
247
+ The returned mapping is keyed by the same JSON-Pointer paths that
248
+ :func:`tree_spec.collect_leaf_paths` / :func:`tree_spec.spec_to_view`
249
+ produce, so the reporter can merge evaluation results into a spec
250
+ view by path lookup.
251
+
252
+ Each entry carries ``passed``, ``score``, ``label``, plus any diagnostics
253
+ the evaluator stashed on that node (``mode``, ``tolerance``,
254
+ ``max_abs_error``, ``against``, ``window``, ``n_passed``, ``warned``, ...).
255
+ """
256
+ out: dict[str, dict] = {}
257
+ _flatten_evaluation(tree, root, out)
258
+ return out
259
+
260
+
261
+ def _flatten_evaluation(
262
+ node: MetricResult,
263
+ path: str,
264
+ out: dict[str, dict],
265
+ ) -> None:
266
+ entry = {
267
+ "passed": bool(node.passed),
268
+ "score": node.score,
269
+ "label": node.label,
270
+ }
271
+ diag = node.diagnostics or {}
272
+ # Copy JSON-safe diagnostic keys (skip the stashed VariableComparison).
273
+ for key, val in diag.items():
274
+ if key == "variable":
275
+ continue
276
+ entry[key] = val
277
+ out[path] = entry
278
+ for i, child in enumerate(node.children):
279
+ _flatten_evaluation(child, f"{path}/children/{i}", out)
280
+
281
+
282
+ def to_view(tree: MetricResult) -> dict:
283
+ """Serialize a ``MetricResult`` tree into a JSON-safe nested dict
284
+ for Jinja consumption. Shape is render-oriented — no simulation data,
285
+ no numpy arrays.
286
+
287
+ Fields per node:
288
+ * ``kind`` — "leaf" | "combinator"
289
+ * ``passed`` — bool
290
+ * ``label`` — human-readable (variable name for leaves,
291
+ "and[N]" / "or[N]" / "warn" / "k-of-n[K/N]" for combinators)
292
+ * ``score`` — float or None
293
+ * ``mode`` — metric mode on leaves (nrmse / tube / points)
294
+ * ``tolerance`` — on leaves, the tolerance applied
295
+ * ``warned`` — True on ``warn`` nodes whose child failed
296
+ * ``children`` — recursive list (empty for leaves)
297
+ """
298
+ node: dict = {
299
+ "kind": "leaf" if not tree.children else "combinator",
300
+ "passed": bool(tree.passed),
301
+ "label": tree.label,
302
+ "score": tree.score,
303
+ "children": [to_view(c) for c in tree.children],
304
+ }
305
+ diag = tree.diagnostics or {}
306
+ if not tree.children:
307
+ # Leaf diagnostics come from leaf_from_variable + _evaluate_leaf
308
+ if "mode" in diag:
309
+ node["mode"] = diag["mode"]
310
+ if "tolerance" in diag:
311
+ node["tolerance"] = diag["tolerance"]
312
+ if "max_abs_error" in diag:
313
+ node["max_abs_error"] = diag["max_abs_error"]
314
+ if "against" in diag:
315
+ node["against"] = diag["against"]
316
+ else:
317
+ # Combinator diagnostics: n_failed / n_passed / k / n / warned
318
+ for key in ("n_failed", "n_passed", "k", "n", "warned"):
319
+ if key in diag:
320
+ node[key] = diag[key]
321
+ return node
322
+
323
+
324
+ # ---------------------------------------------------------------------------
325
+ # Internals
326
+ # ---------------------------------------------------------------------------
327
+
328
+
329
+ def _walk_leaves(node: MetricResult, out: list[VariableComparison]) -> None:
330
+ if not node.children:
331
+ vc = node.diagnostics.get("variable")
332
+ if vc is not None:
333
+ out.append(vc)
334
+ return
335
+ for child in node.children:
336
+ _walk_leaves(child, out)
337
+
338
+
339
+ def _build_combinator(spec: CombinatorSpec) -> Combinator:
340
+ desc = registry_combinator(spec.combinator)
341
+ if desc is None:
342
+ # tree_spec validates on parse; this is just a defensive fallback.
343
+ raise ValueError(f"Unknown combinator: {spec.combinator!r}")
344
+ return desc.build(spec)
345
+
346
+
347
+ def _evaluate_leaf(
348
+ leaf: LeafSpec,
349
+ var_results_by_name: dict[str, VariableResult],
350
+ baselines: dict[str, BaselineView],
351
+ base_tolerance: float,
352
+ dataset_kind: DatasetType = DatasetType.TIME_SERIES,
353
+ scalars: ScalarSources | None = None,
354
+ warned: set[tuple[str, str]] | None = None,
355
+ model_id: str = "",
356
+ ) -> MetricResult:
357
+ # Own set when called outside a walk: one leaf, one line, never silence.
358
+ warned = set() if warned is None else warned
359
+ desc = registry_metric(leaf.metric)
360
+ quantity = leaf.params.get("quantity")
361
+ # A stored-scalar leaf reads its numbers from the run's statistics or
362
+ # diagnostics, so it resolves no variable of the result and has no
363
+ # expression to carry. Every other leaf names one, and carries its
364
+ # expression onto whatever comparison it returns — a leaf that fails
365
+ # before it ever scores (a lost baseline, a misconfigured metric) is
366
+ # exactly the one a person reads, so it must not fall back to the bare
367
+ # name (the Task 3 review). ONE definition of "resolves no variable",
368
+ # shared with the spec walk the expression soft-check reads.
369
+ scores_stored_scalar = not resolves_a_variable(leaf)
370
+ var_result = (
371
+ None if scores_stored_scalar else var_results_by_name.get(leaf.variable)
372
+ )
373
+ expression = var_result.expression if var_result is not None else ""
374
+
375
+ #: The baseline this leaf reads: its `against`, once its mode is built
376
+ #: and is one that reads a baseline. None until then, and for a mode
377
+ #: that scores from its own config.
378
+ reads: str | None = None
379
+
380
+ def _leaf(vc: VariableComparison) -> MetricResult:
381
+ vc.expression = expression
382
+ # The tolerance this leaf applies, on every return: one that fails
383
+ # before it scores (a variable the result lacks, a baseline the test
384
+ # does not have) reports the one it would have applied, never the
385
+ # comparison's 0.0 default.
386
+ vc.tolerance_used = tolerance
387
+ # A criterion whose config is an error read nothing, whenever the
388
+ # error was found: the flat path's sentinel says the same.
389
+ config_error = (vc.diagnostics or {}).get("config_error")
390
+ vc.against = None if config_error else reads
391
+ return leaf_from_variable(vc)
392
+
393
+ try:
394
+ tolerance = _leaf_tolerance(leaf, base_tolerance)
395
+ except ValueError as exc:
396
+ # A tolerance that is neither a number nor a lower/upper pair fails
397
+ # its leaf, not the run (D107); the base is the one it reports as
398
+ # applied.
399
+ tolerance = Sides(float(base_tolerance), float(base_tolerance))
400
+ return _leaf(_leaf_config_error_comparison(leaf, exc))
401
+
402
+ # A metric declares which dataset kinds it can score. Unscorable input
403
+ # is a hard fail with a diagnostic naming both sides, not a crash inside
404
+ # the mode. Unreachable for the builtins (all accept TIME_SERIES, which
405
+ # is all any backend produces) — it guards registered metrics. After the
406
+ # expression is resolved, like every other early return: this sentinel
407
+ # is one a person reads, and it named the variable twice as differently
408
+ # as the bare name and the label (the Task 4 review).
409
+ if desc is not None and dataset_kind not in desc.accepted_datasets:
410
+ return _leaf(_incompatible_dataset_comparison(leaf, desc, dataset_kind))
411
+
412
+ override = _leaf_override_dict(leaf)
413
+ try:
414
+ mode = resolve_mode(override, tolerance, default_points=False)
415
+ except ValueError as exc:
416
+ # A misconfigured leaf fails that leaf, like the flat
417
+ # variable_overrides path — one bad spec entry must not abort the
418
+ # whole run with an anonymous traceback (D107).
419
+ return _leaf(_leaf_config_error_comparison(leaf, exc))
420
+
421
+ # leaf.against picks the named baseline. Missing name →
422
+ # hard fail with a clear label (loud on user-facing reports). Unless
423
+ # the leaf is baseline-free (range / declared-events / declared-peaks)
424
+ # — then we don't need any baseline at all (idea #59 / D83).
425
+ baseline = baselines.get(leaf.against)
426
+ reads = None if mode.is_baseline_free() else leaf.against
427
+ if baseline is None and not mode.is_baseline_free():
428
+ return _leaf(_missing_baseline_comparison(leaf))
429
+
430
+ windowed = False
431
+ if scores_stored_scalar and isinstance(quantity, str):
432
+ # A string ``quantity`` reads a stored scalar instead of a tracked
433
+ # series — where the data comes from is the evaluator's call, the
434
+ # same seam the window below occupies (a future ``{"expr": ...}``
435
+ # quantity extends this spot). The window is skipped deliberately:
436
+ # a stored scalar is not a time series.
437
+ #
438
+ # The switch is gated on the metric's ``accepts_quantity`` flag, not
439
+ # on the param alone: a stray ``quantity`` on an nrmse/tube/points
440
+ # leaf must leave that leaf scoring its trajectory. Ungated, both
441
+ # sides collapsed to one identical sample and a 2x-off signal passed.
442
+ resolved = _scalar_path_arrays(
443
+ leaf, quantity, mode, baseline, scalars or ScalarSources()
444
+ )
445
+ if isinstance(resolved, VariableComparison):
446
+ return _leaf(resolved)
447
+ ref_time, ref_values, act_time, act_values = resolved
448
+ index = 0
449
+ else:
450
+ ref_var = baseline.ref_vars_by_name.get(leaf.variable) if baseline else None
451
+
452
+ if var_result is None:
453
+ # Missing actual result is always a hard fail — no mode can score
454
+ # without the actual trajectory.
455
+ return _leaf(_missing_variable_comparison(leaf.variable, var_result))
456
+
457
+ if ref_var is None:
458
+ if not mode.is_baseline_free():
459
+ # A baseline that stores this variable under its EXPRESSION
460
+ # is un-migrated, not missing: the same clause the flat path
461
+ # logs, from the same helper, on the leaf's own reason as
462
+ # well — the tree path shows no log line to the person
463
+ # reading the report. Logged only when it applies; a
464
+ # variable simply new to the baseline is re-accepted. The
465
+ # REASON rides on every leaf; the LOG LINE is said once per
466
+ # (variable, baseline) — `warned` is the test's set.
467
+ hint = migration_hint(
468
+ baseline.ref_vars_by_name if baseline else {},
469
+ leaf.variable,
470
+ expression,
471
+ )
472
+ key = (leaf.against, leaf.variable)
473
+ if hint and key not in warned:
474
+ warned.add(key)
475
+ logger.warning(
476
+ "%s: baseline %r holds no variable named %r%s",
477
+ model_id or "?",
478
+ leaf.against,
479
+ leaf.variable,
480
+ hint,
481
+ )
482
+ return _leaf(
483
+ _missing_variable_comparison(leaf.variable, var_result, hint=hint)
484
+ )
485
+ ref_time = np.array([])
486
+ ref_values = np.array([])
487
+ else:
488
+ if baseline is not None and baseline.shared_ref_time is not None:
489
+ ref_time = baseline.shared_ref_time
490
+ else:
491
+ ref_time = np.array(ref_var["time"])
492
+ ref_values = np.array(ref_var["values"])
493
+
494
+ act_time = var_result.time
495
+ act_values = var_result.values
496
+ index = var_result.index
497
+
498
+ # Apply the leaf window (idea #46) — slice both sides uniformly before
499
+ # handing to mode.compare. Mode configs stay untouched; slicing is a
500
+ # cross-cutting leaf property. Skip the ref slice when there's no
501
+ # reference at all (baseline-free path).
502
+ if leaf.window_start is not None or leaf.window_end is not None:
503
+ windowed = True
504
+ if len(ref_time) > 0:
505
+ ref_time, ref_values = _slice_window(
506
+ ref_time,
507
+ ref_values,
508
+ leaf.window_start,
509
+ leaf.window_end,
510
+ )
511
+ act_time, act_values = _slice_window(
512
+ act_time,
513
+ act_values,
514
+ leaf.window_start,
515
+ leaf.window_end,
516
+ )
517
+
518
+ try:
519
+ vc = mode.compare(ref_time, ref_values, act_time, act_values)
520
+ except ValueError as exc:
521
+ # Config errors a mode can only detect at compare time (a tube
522
+ # control point with no widths, an unknown width mode) — same
523
+ # leaf-scoped failure as the resolve_mode guard above (D107).
524
+ return _leaf(_leaf_config_error_comparison(leaf, exc))
525
+ vc.index = index
526
+ vc.name = leaf.variable
527
+ if windowed:
528
+ # The window is a leaf property, not a mode's: the evaluator owns it.
529
+ vc.geometry = vc.geometry + (
530
+ Window(id="window", start=leaf.window_start, end=leaf.window_end),
531
+ )
532
+ leaf_result = _leaf(vc)
533
+ if quantity is not None:
534
+ leaf_result.diagnostics["quantity"] = quantity
535
+ if windowed:
536
+ leaf_result.diagnostics["window"] = {
537
+ "start": leaf.window_start,
538
+ "end": leaf.window_end,
539
+ }
540
+ return leaf_result
541
+
542
+
543
+ def _scalar_path_arrays(
544
+ leaf: LeafSpec,
545
+ path: str,
546
+ mode,
547
+ baseline: BaselineView | None,
548
+ scalars: ScalarSources,
549
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray] | VariableComparison:
550
+ """Resolve a stats-path quantity on both sides into 1-sample arrays.
551
+
552
+ Returns ``(ref_time, ref_values, act_time, act_values)``, or a hard-fail
553
+ :class:`VariableComparison` naming the path and the side it is missing
554
+ from. The reference side is left empty when the leaf scores against
555
+ declared bounds (baseline-free) — there may be no baseline at all.
556
+ """
557
+ act = resolve_scalar_path(path, scalars.statistics, scalars.diagnostics)
558
+ if act is None:
559
+ return _missing_scalar_path_comparison(leaf, path, "current run")
560
+
561
+ empty = np.array([])
562
+ ref_time, ref_values = empty, empty
563
+ if baseline is not None:
564
+ ref = resolve_scalar_path(path, baseline.statistics, baseline.diagnostics)
565
+ if ref is None:
566
+ if not mode.is_baseline_free():
567
+ return _missing_scalar_path_comparison(
568
+ leaf, path, f"baseline {baseline.name!r}"
569
+ )
570
+ else:
571
+ ref_time, ref_values = np.array([0.0]), np.array([ref])
572
+ return ref_time, ref_values, np.array([0.0]), np.array([act])
573
+
574
+
575
+ def resolve_scalar_path(
576
+ path: str,
577
+ statistics: dict,
578
+ diagnostics: list,
579
+ ) -> float | None:
580
+ """Resolve a dotted quantity path to a number, or ``None`` if absent.
581
+
582
+ Two lookups, in order, applied identically to both sides:
583
+
584
+ 1. a dotted walk into ``statistics`` (``translation.scalar_unknowns``);
585
+ 2. the last segment read as a diagnostics-summary field —
586
+ ``"CPUtime.final"`` means diagnostic ``CPUtime``, field ``final``
587
+ (one of final / min / max).
588
+
589
+ Statistics win on a collision: ``EventCounter`` lives in both maps, and
590
+ the statistics entry is the one the structural-warning checks already
591
+ treat as authoritative.
592
+
593
+ Diagnostics come in three shapes: the baseline's stored summaries
594
+ (``{"name", "final", "min", "max"}``), pre-summary baselines carrying
595
+ the full ``{"name", "values"}`` array (old data — the reporter's
596
+ ``build_trajectories`` still back-reads it), and the current run's
597
+ ``VariableResult`` trajectories. The last two are summarized here; the
598
+ run's trajectories are deliberately not stored (reference_store:
599
+ CPUtime is nondeterministic).
600
+ """
601
+ value = _dotted_lookup(statistics, path)
602
+ if value is not None:
603
+ return value
604
+
605
+ name, _, field_name = path.rpartition(".")
606
+ if not name or field_name not in ("final", "min", "max"):
607
+ return None
608
+ for entry in diagnostics or []:
609
+ if isinstance(entry, VariableResult):
610
+ if entry.name != name:
611
+ continue
612
+ return _summarize_values(entry.values, field_name)
613
+ if isinstance(entry, dict) and entry.get("name") == name:
614
+ if field_name in entry:
615
+ return _as_float(entry.get(field_name))
616
+ # Pre-summary baseline: derive from the stored full array.
617
+ return _summarize_values(entry.get("values"), field_name)
618
+ return None
619
+
620
+
621
+ def _summarize_values(values, field_name: str) -> float | None:
622
+ """Return the ``final`` / ``min`` / ``max`` of a diagnostic's array."""
623
+ if values is None:
624
+ return None
625
+ array = np.asarray(values, dtype=float)
626
+ if array.size == 0:
627
+ return None
628
+ if field_name == "final":
629
+ return float(array[-1])
630
+ return float(array.min() if field_name == "min" else array.max())
631
+
632
+
633
+ def _dotted_lookup(statistics: dict, path: str) -> float | None:
634
+ """Walk ``path`` through nested dicts; ``None`` unless it lands on a number."""
635
+ node: object = statistics or {}
636
+ parts = path.split(".")
637
+ for i, part in enumerate(parts):
638
+ if not isinstance(node, dict):
639
+ return None
640
+ if part not in node:
641
+ # A summary a baseline written before the parser always wrote
642
+ # it: 0 where its list is present and empty (the zero rule).
643
+ return _as_float(read_summary(node, part)) if i == len(parts) - 1 else None
644
+ node = node[part]
645
+ return _as_float(node)
646
+
647
+
648
+ def _as_float(value: object) -> float | None:
649
+ if isinstance(value, bool) or not isinstance(value, (int, float, str)):
650
+ return None
651
+ try:
652
+ return float(value)
653
+ except (TypeError, ValueError):
654
+ return None
655
+
656
+
657
+ def _slice_window(
658
+ t: np.ndarray,
659
+ v: np.ndarray,
660
+ start: float | None,
661
+ end: float | None,
662
+ ) -> tuple[np.ndarray, np.ndarray]:
663
+ """Return ``(t, v)`` restricted to ``[start, end]`` (inclusive).
664
+
665
+ Open-ended on either side supported. A degenerate window returns empty
666
+ arrays — modes that can't handle empty data (NRMSE / tube) then fail
667
+ loudly downstream with a clear error, rather than being silently
668
+ widened back to the full trace here.
669
+ """
670
+ mask = np.ones(len(t), dtype=bool)
671
+ if start is not None:
672
+ mask &= t >= start
673
+ if end is not None:
674
+ mask &= t <= end
675
+ return t[mask], v[mask]
676
+
677
+
678
+ def build_leaf_mode(leaf: LeafSpec, base_tolerance: float) -> ComparisonMode:
679
+ """The mode ``leaf`` is scored with, built as :func:`_evaluate_leaf`
680
+ builds it. ``ValueError`` — the leaf's config error at compare time —
681
+ when the leaf can never be scored: a tolerance that is no number or
682
+ pair, a ``range`` with neither bound, a ``scalar`` with no quantity.
683
+ An update asks before it writes a file
684
+ (``discovery.spec_update.refuse_newly_unscorable``): a write that makes
685
+ such a leaf is refused."""
686
+ return resolve_mode(
687
+ _leaf_override_dict(leaf),
688
+ _leaf_tolerance(leaf, base_tolerance),
689
+ default_points=False,
690
+ )
691
+
692
+
693
+ def _leaf_tolerance(leaf: LeafSpec, base: float) -> Sides:
694
+ """The tolerance ``leaf`` applies: its own, else the test's ``base``.
695
+ One that is neither a number nor a lower/upper pair (``"abc"``,
696
+ ``null``, a pair missing a side) is a ``ValueError`` naming it, which
697
+ the caller turns into the leaf's config error (D107)."""
698
+ return parse_sides(leaf.params.get("tolerance", base), "tolerance")
699
+
700
+
701
+ def _leaf_override_dict(leaf: LeafSpec) -> dict:
702
+ """Translate a LeafSpec to the flat override dict resolve_mode expects.
703
+
704
+ Spec metric names and override ``mode`` strings are the same identifier,
705
+ so this is the identity mapping plus one rule: the default metric has no
706
+ ``mode`` key (its absence is what selects it).
707
+ """
708
+ override = dict(leaf.params)
709
+ if leaf.metric != DEFAULT_METRIC:
710
+ override["mode"] = leaf.metric
711
+ return override
712
+
713
+
714
+ def _incompatible_dataset_comparison(
715
+ leaf: LeafSpec,
716
+ desc: MetricDescriptor,
717
+ dataset_kind: DatasetType,
718
+ ) -> VariableComparison:
719
+ """Build a hard-fail sentinel for a metric that can't score this data.
720
+
721
+ Takes the already-resolved descriptor — the caller just checked its
722
+ ``accepted_datasets``, and a re-lookup here would need a dead None
723
+ branch (mypy union-attr).
724
+
725
+ The name stays the variable — the figure layer pairs leaves with plots
726
+ by it — and the reason rides in ``diagnostics["error"]``, where
727
+ ``registry.summarize``, the console and JUnit already read it.
728
+ """
729
+ accepted = ", ".join(sorted(d.value for d in desc.accepted_datasets))
730
+ return VariableComparison(
731
+ index=0,
732
+ name=leaf.variable,
733
+ passed=False,
734
+ nrmse=float("inf"),
735
+ rmse=float("inf"),
736
+ signal_range=0.0,
737
+ max_abs_error=float("inf"),
738
+ max_abs_error_time=0.0,
739
+ reference_final=float("nan"),
740
+ actual_final=float("nan"),
741
+ mode=leaf.metric,
742
+ diagnostics={
743
+ "error": (
744
+ f"metric {leaf.metric!r} accepts {accepted}; got {dataset_kind.value}"
745
+ )
746
+ },
747
+ )
748
+
749
+
750
+ def _leaf_config_error_comparison(
751
+ leaf: LeafSpec,
752
+ exc: ValueError,
753
+ ) -> VariableComparison:
754
+ """Hard-fail sentinel for a leaf whose metric config is invalid.
755
+
756
+ The name is the variable — the figure layer pairs leaves with plots by
757
+ it — and the reason (the metric that could not build, plus the raised
758
+ message, which names the correct key) rides in ``diagnostics["error"]``,
759
+ matching the flat-override path's shape.
760
+
761
+ ``config_error`` marks the sentinel as config-shaped, distinguishing
762
+ it from a data-shaped one like ``_no_data_failure``: an empty window
763
+ is data — the same spec scores once the samples are there — while a
764
+ metric config that never built is a defect in the spec itself.
765
+ """
766
+ return VariableComparison(
767
+ index=0,
768
+ name=leaf.variable,
769
+ passed=False,
770
+ nrmse=float("inf"),
771
+ rmse=float("inf"),
772
+ signal_range=0.0,
773
+ max_abs_error=float("inf"),
774
+ max_abs_error_time=0.0,
775
+ reference_final=float("nan"),
776
+ actual_final=float("nan"),
777
+ mode=leaf.metric,
778
+ diagnostics={
779
+ "error": f"metric {leaf.metric!r} misconfigured: {exc}",
780
+ "config_error": True,
781
+ },
782
+ )
783
+
784
+
785
+ def _missing_baseline_comparison(leaf: LeafSpec) -> VariableComparison:
786
+ """Build a hard-fail sentinel for a leaf referencing an unknown baseline.
787
+
788
+ The name stays the variable so the leaf still finds its plot; the
789
+ missing baseline name rides in ``diagnostics["error"]``.
790
+ """
791
+ return VariableComparison(
792
+ index=0,
793
+ name=leaf.variable,
794
+ passed=False,
795
+ nrmse=float("inf"),
796
+ rmse=float("inf"),
797
+ signal_range=0.0,
798
+ max_abs_error=float("inf"),
799
+ max_abs_error_time=0.0,
800
+ reference_final=float("nan"),
801
+ actual_final=float("nan"),
802
+ mode=leaf.metric,
803
+ diagnostics={"error": f"against={leaf.against!r}: baseline not found"},
804
+ )
805
+
806
+
807
+ def _missing_scalar_path_comparison(
808
+ leaf: LeafSpec,
809
+ path: str,
810
+ side: str,
811
+ ) -> VariableComparison:
812
+ """Hard-fail sentinel for a quantity path absent from one side.
813
+
814
+ The name stays the leaf's variable (the path itself when none was
815
+ authored) so the leaf keeps its place in the report; the diagnostic
816
+ names the path and the side (``"current run"`` / ``"baseline 'x'"``) —
817
+ a stats path that silently scored as 0 would be a green regression
818
+ test measuring nothing.
819
+ """
820
+ return VariableComparison(
821
+ index=0,
822
+ name=leaf.variable,
823
+ passed=False,
824
+ nrmse=float("inf"),
825
+ rmse=float("inf"),
826
+ signal_range=0.0,
827
+ max_abs_error=float("inf"),
828
+ max_abs_error_time=0.0,
829
+ reference_final=float("nan"),
830
+ actual_final=float("nan"),
831
+ mode=leaf.metric,
832
+ diagnostics={"error": f"quantity {path!r} not found in {side}"},
833
+ )
834
+
835
+
836
+ def _missing_variable_comparison(
837
+ name: str,
838
+ var_result: VariableResult | None,
839
+ hint: str = "",
840
+ ) -> VariableComparison:
841
+ actual_final = float("nan")
842
+ index = 0
843
+ if var_result is not None and len(var_result.values) > 0:
844
+ actual_final = float(var_result.values[-1])
845
+ index = var_result.index
846
+ return VariableComparison(
847
+ index=index,
848
+ name=name,
849
+ passed=False,
850
+ nrmse=float("inf"),
851
+ rmse=float("inf"),
852
+ signal_range=0.0,
853
+ max_abs_error=float("inf"),
854
+ max_abs_error_time=0.0,
855
+ reference_final=float("nan"),
856
+ actual_final=actual_final,
857
+ # The two call sites are told apart by what they had: no result for
858
+ # the variable at all, or a result the baseline does not carry. The
859
+ # second one carries the migration hint when the baseline holds the
860
+ # variable under its expression (``hint`` is "" otherwise).
861
+ diagnostics={
862
+ "error": (
863
+ f"variable {name!r} not found in the result"
864
+ if var_result is None
865
+ else f"variable {name!r} not found in the baseline{hint}"
866
+ )
867
+ },
868
+ )