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,678 @@
1
+ """Declarative spec form for user-authored MetricTrees from test_spec.json.
2
+
3
+ Parses the JSON shape into typed dataclasses without evaluating anything:
4
+
5
+ {
6
+ "combinator": "and",
7
+ "children": [
8
+ {"metric": "nrmse", "variable": "h", "tolerance": 0.01},
9
+ {"metric": "tube", "variable": "v", "tube_rel": 0.05}
10
+ ]
11
+ }
12
+
13
+ A spec tree is the static description of *what to score*. Phase 3.3 will
14
+ walk it together with simulation results to produce evaluated
15
+ ``MetricResult`` trees (see ``metric_tree.py``). Phase 3.2 stops at the
16
+ parse step so users can write the schema and we can unit-test the validation
17
+ without changing pipeline behavior.
18
+
19
+ Validation is hand-rolled (jsonschema would be heavier than warranted).
20
+ Errors carry the JSON path where the problem was found so users can
21
+ locate the offending entry quickly in a large spec.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Iterable, Mapping
27
+ from dataclasses import dataclass, field
28
+ from typing import Any
29
+
30
+ from .registry import DEFAULT_METRIC, combinator_names, metric, metric_names
31
+ from .sides import _as_json, _is_number
32
+ from .strict_keys import unknown_field, unread_param
33
+
34
+ #: The keys the parser itself reads off a leaf; every other key of a leaf
35
+ #: is one of its metric's params.
36
+ _LEAF_KEYS = ("metric", "variable", "against", "window")
37
+
38
+ #: A window's keys.
39
+ _WINDOW_KEYS = ("start", "end")
40
+
41
+ #: The keys every combinator has, and what each kind reads beyond them
42
+ #: (``_parse_combinator``). A kind not named here reads nothing more.
43
+ _COMBINATOR_KEYS = ("combinator", "children")
44
+ COMBINATOR_KIND_KEYS: dict[str, tuple[str, ...]] = {
45
+ "k-of-n": ("k",),
46
+ "weighted": ("weights", "threshold", "direction"),
47
+ }
48
+
49
+
50
+ class MetricSpecError(ValueError):
51
+ """Raised on malformed MetricTree spec input: ``path`` says where,
52
+ ``message`` what is wrong. ``str()`` is ``"path: message"`` for the CLI,
53
+ as a validator's ``ValidationError`` reads; the tool shows ``message``
54
+ alone, beside the row it came from."""
55
+
56
+ def __init__(self, path: str, message: str) -> None:
57
+ self.path = path
58
+ self.message = message
59
+ super().__init__(f"{path}: {message}" if path else message)
60
+
61
+ def __reduce__(self):
62
+ # The default rebuilds from ``args`` — the one formatted string —
63
+ # and ``__init__`` takes two: pickle and copy raised TypeError.
64
+ return (type(self), (self.path, self.message))
65
+
66
+
67
+ @dataclass
68
+ class LeafSpec:
69
+ """A single per-variable metric evaluation.
70
+
71
+ ``params`` carries metric-specific knobs (tolerance, tube_rel, tube_points,
72
+ tube_width_mode, ...). The shape mirrors the existing
73
+ ``variable_overrides`` payload in test_spec.json so the same tube/NRMSE
74
+ parameter names work in both contexts.
75
+
76
+ ``against`` selects which named baseline the leaf scores against (Phase
77
+ 4.A.2). Defaults to ``"primary"`` — the baseline stored at the flat top
78
+ level of the reference file. Non-primary names (``"experiment"``,
79
+ ``"analytical"``, ...) reference entries under the reference's
80
+ ``baselines`` map. Unknown names are rejected at evaluation time.
81
+
82
+ ``window_start`` / ``window_end`` (idea #46, Phase 6.1.1) scope the
83
+ leaf to a sub-interval of the trajectory. Both optional; when set, the
84
+ leaf evaluates only against samples with ``window_start <= t <= window_end``.
85
+ Uniform across every metric — mode configs stay untouched, slicing
86
+ happens in :func:`tree_eval._evaluate_leaf` before ``mode.compare``.
87
+ """
88
+
89
+ metric: str
90
+ variable: str
91
+ params: dict = field(default_factory=dict)
92
+ against: str = "primary"
93
+ window_start: float | None = None
94
+ window_end: float | None = None
95
+
96
+
97
+ @dataclass
98
+ class CombinatorSpec:
99
+ """A combinator node: and/or/k-of-n/warn/weighted over child specs.
100
+
101
+ ``k`` is required iff ``combinator == "k-of-n"``. ``warn`` requires
102
+ exactly one child; the others require at least one. ``weighted`` (4.E)
103
+ requires ``weights`` (list parallel to children), ``threshold``, and an
104
+ optional ``direction`` (``"less"`` default, or ``"greater"``).
105
+ """
106
+
107
+ combinator: str
108
+ children: list[LeafSpec | CombinatorSpec]
109
+ k: int = 0 # only meaningful for k-of-n
110
+ # Weighted-only fields (empty/zero for other combinators).
111
+ weights: list[float] = field(default_factory=list)
112
+ threshold: float = 0.0
113
+ direction: str = "less"
114
+
115
+ def __post_init__(self):
116
+ if self.combinator == "k-of-n" and self.k <= 0:
117
+ raise MetricSpecError(
118
+ "", f"k-of-n combinator requires positive 'k'; got {self.k}"
119
+ )
120
+ if self.combinator == "weighted":
121
+ if len(self.weights) != len(self.children):
122
+ raise MetricSpecError(
123
+ "",
124
+ f"weighted combinator: weights ({len(self.weights)}) "
125
+ f"must match children ({len(self.children)})",
126
+ )
127
+ if self.direction not in ("less", "greater"):
128
+ raise MetricSpecError(
129
+ "",
130
+ f"weighted combinator: direction must be 'less' or 'greater', "
131
+ f"got {self.direction!r}",
132
+ )
133
+
134
+
135
+ SpecNode = LeafSpec | CombinatorSpec
136
+
137
+
138
+ def collect_leaf_paths(spec: SpecNode, *, root: str = "/metrics") -> list[str]:
139
+ """Return RFC 6901 JSON-Pointer paths for each leaf in tree order.
140
+
141
+ Order matches :func:`tree_eval.collect_leaf_variables` (pre-order walk)
142
+ and the paths the evaluator stamps on results. Paths are entry-relative
143
+ — an RFC 6902 patch op uses them directly (the whitelist in
144
+ :mod:`patch_apply` covers ``/metrics``).
145
+
146
+ Example: a two-leaf AND under the root yields
147
+ ``["/metrics/children/0", "/metrics/children/1"]``; a single-leaf root
148
+ yields ``["/metrics"]``.
149
+ """
150
+ out: list[str] = []
151
+ _collect_leaf_paths(spec, root, out)
152
+ return out
153
+
154
+
155
+ def _collect_leaf_paths(node: SpecNode, path: str, out: list[str]) -> None:
156
+ if isinstance(node, LeafSpec):
157
+ out.append(path)
158
+ return
159
+ for i, child in enumerate(node.children):
160
+ _collect_leaf_paths(child, f"{path}/children/{i}", out)
161
+
162
+
163
+ def collect_variables(spec: SpecNode) -> list[str]:
164
+ """Return unique variable names referenced by the tree, in tree order.
165
+
166
+ Drives the reporter's per-variable plot grouping: one plot per unique
167
+ variable, with the variable's leaves rendered as the plot's interactive
168
+ nodes. First-occurrence ordering matches the JSON spec layout users see.
169
+ """
170
+ seen: set[str] = set()
171
+ out: list[str] = []
172
+ for leaf, _ in _walk_leaves_with_paths(spec, "/metrics"):
173
+ if leaf.variable not in seen:
174
+ seen.add(leaf.variable)
175
+ out.append(leaf.variable)
176
+ return out
177
+
178
+
179
+ def resolves_a_variable(leaf: LeafSpec) -> bool:
180
+ """Does this leaf score a variable of the RESULT?
181
+
182
+ False for a stored-scalar leaf — a metric that accepts ``quantity``
183
+ given a string one reads its numbers from the run's statistics or
184
+ diagnostics, and its ``variable`` is free text naming that leaf, not a
185
+ series anything resolves. Treated as a variable it registered
186
+ ``CPUtime`` for the expression soft-check, which then compared a
187
+ baseline's recorded expression against a quantity nothing measured.
188
+ """
189
+ return not reads_a_statistic(leaf.metric, leaf.params)
190
+
191
+
192
+ def reads_a_statistic(metric_name: str, params: Mapping[str, Any]) -> bool:
193
+ """The rule under :func:`resolves_a_variable`, over a metric's name and
194
+ its params — what a leaf and a leaf's `spec_to_view` dict both carry."""
195
+ desc = metric(metric_name)
196
+ return (
197
+ desc is not None
198
+ and desc.accepts_quantity
199
+ and isinstance(params.get("quantity"), str)
200
+ )
201
+
202
+
203
+ def tree_variables(spec: SpecNode | None, diagnostics: Iterable[str] = ()) -> list[str]:
204
+ """The variables a tree makes a run track: every leaf's ``variable``
205
+ that resolves one (:func:`resolves_a_variable`), unique, in tree order,
206
+ without the ``diagnostics`` — a diagnostic is scored through a quantity
207
+ path over its stored summary, never extracted as a trajectory. A backend
208
+ APPENDS these after the UnitTests variables and the ``variables``
209
+ patterns, so a criterion added later renumbers neither of those two.
210
+ ``[]`` for no tree (the implicit tree scores only what is tracked)."""
211
+ if spec is None:
212
+ return []
213
+ skip = set(diagnostics)
214
+ out: list[str] = []
215
+ for leaf, _ in _walk_leaves_with_paths(spec, "/metrics"):
216
+ if not resolves_a_variable(leaf):
217
+ continue
218
+ if leaf.variable in skip or leaf.variable in out:
219
+ continue
220
+ out.append(leaf.variable)
221
+ return out
222
+
223
+
224
+ def statistic_leaves(spec: SpecNode | None) -> list[tuple[LeafSpec, str]]:
225
+ """The tree's statistics leaves (:func:`reads_a_statistic`) with their
226
+ paths, in tree order: which Statistics rows the tree scores. ``[]``
227
+ for no tree."""
228
+ if spec is None:
229
+ return []
230
+ return [
231
+ (leaf, path)
232
+ for leaf, path in _walk_leaves_with_paths(spec, "/metrics")
233
+ if reads_a_statistic(leaf.metric, leaf.params)
234
+ ]
235
+
236
+
237
+ def statistic_paths(spec: SpecNode | None) -> list[str]:
238
+ """The quantity paths the tree's statistics leaves read, unique, in
239
+ tree order: what a Statistics row must exist for. ``[]`` for no tree."""
240
+ out: list[str] = []
241
+ for leaf, _ in statistic_leaves(spec):
242
+ path = str(leaf.params["quantity"])
243
+ if path not in out:
244
+ out.append(path)
245
+ return out
246
+
247
+
248
+ def collect_variables_by_baseline(spec: SpecNode) -> dict[str, list[str]]:
249
+ """``{baseline name: the variables its leaves score}``, in tree order.
250
+
251
+ Unique on both sides, and ``"primary"`` is a key whenever a leaf takes
252
+ the default. The comparator reads it to run the expression soft-check
253
+ on every baseline a tree actually uses — once per PAIR, not per leaf
254
+ and not over every variable of the result: a baseline records the
255
+ expressions of the variables it holds, and a variable no leaf scores
256
+ against it is not being compared with it at all. A leaf that resolves
257
+ no variable (:func:`resolves_a_variable`) contributes none.
258
+ """
259
+ out: dict[str, list[str]] = {}
260
+ for leaf, _ in _walk_leaves_with_paths(spec, "/metrics"):
261
+ if not resolves_a_variable(leaf):
262
+ continue
263
+ names = out.setdefault(leaf.against, [])
264
+ if leaf.variable not in names:
265
+ names.append(leaf.variable)
266
+ return out
267
+
268
+
269
+ def leaves_for_variable(
270
+ spec: SpecNode,
271
+ variable: str,
272
+ *,
273
+ root: str = "/metrics",
274
+ ) -> list[tuple[LeafSpec, str]]:
275
+ """Return ``(leaf, json_pointer)`` pairs for every leaf targeting ``variable``.
276
+
277
+ Pre-order walk; same ordering as :func:`collect_leaf_paths`. Used by
278
+ the per-variable plot section to render only the leaves whose subtree
279
+ membership touches that variable.
280
+ """
281
+ return [
282
+ (leaf, path)
283
+ for leaf, path in _walk_leaves_with_paths(spec, root)
284
+ if leaf.variable == variable
285
+ ]
286
+
287
+
288
+ def _walk_leaves_with_paths(
289
+ node: SpecNode,
290
+ path: str,
291
+ ):
292
+ if isinstance(node, LeafSpec):
293
+ yield node, path
294
+ return
295
+ for i, child in enumerate(node.children):
296
+ yield from _walk_leaves_with_paths(child, f"{path}/children/{i}")
297
+
298
+
299
+ def spec_to_view(
300
+ node: SpecNode,
301
+ *,
302
+ root: str = "/metrics",
303
+ evaluation_by_path: dict[str, dict] | None = None,
304
+ ) -> dict:
305
+ """Serialize a ``SpecNode`` to a JSON-safe dict with paths + evaluation.
306
+
307
+ The reporter's Stage-2 recursive UI component consumes this as its
308
+ single source of truth. Each node carries:
309
+
310
+ * ``kind`` — "leaf" | "combinator"
311
+ * ``path`` — RFC 6901 JSON-Pointer (e.g. ``/metrics/children/0``)
312
+ * ``combinator`` / ``k`` / ``weights`` / ``threshold`` / ``direction``
313
+ — combinator-only fields
314
+ * ``metric`` / ``variable`` / ``params`` / ``against`` / ``window``
315
+ — leaf-only fields
316
+ * ``children`` — recursive list (empty for leaves)
317
+ * ``passed`` / ``score`` / ``label`` — merged from ``evaluation_by_path``
318
+ when supplied; omitted otherwise
319
+
320
+ ``evaluation_by_path`` is expected to be a flat dict keyed by JSON-Pointer,
321
+ produced by :func:`flatten_evaluation`. Missing keys are tolerated — a
322
+ spec view without an evaluation renders as read-only structure.
323
+ """
324
+ eval_by_path = evaluation_by_path or {}
325
+ return _spec_to_view_recursive(node, root, eval_by_path)
326
+
327
+
328
+ def _spec_to_view_recursive(
329
+ node: SpecNode,
330
+ path: str,
331
+ eval_by_path: dict[str, dict],
332
+ ) -> dict:
333
+ view: dict = {"path": path}
334
+ if isinstance(node, LeafSpec):
335
+ view.update(
336
+ {
337
+ "kind": "leaf",
338
+ "metric": node.metric,
339
+ "variable": node.variable,
340
+ "params": dict(node.params),
341
+ "against": node.against,
342
+ "window": _window_view(node),
343
+ "children": [],
344
+ }
345
+ )
346
+ else:
347
+ view.update(
348
+ {
349
+ "kind": "combinator",
350
+ "combinator": node.combinator,
351
+ "children": [
352
+ _spec_to_view_recursive(c, f"{path}/children/{i}", eval_by_path)
353
+ for i, c in enumerate(node.children)
354
+ ],
355
+ }
356
+ )
357
+ if node.combinator == "k-of-n":
358
+ view["k"] = node.k
359
+ if node.combinator == "weighted":
360
+ view["weights"] = list(node.weights)
361
+ view["threshold"] = node.threshold
362
+ view["direction"] = node.direction
363
+
364
+ eval_entry = eval_by_path.get(path)
365
+ if eval_entry:
366
+ for key in ("passed", "score", "label"):
367
+ if key in eval_entry:
368
+ view[key] = eval_entry[key]
369
+ return view
370
+
371
+
372
+ def _window_view(leaf: LeafSpec) -> dict:
373
+ """Return the leaf's window as a JSON-ready dict (``{}`` when unset)."""
374
+ out: dict = {}
375
+ if leaf.window_start is not None:
376
+ out["start"] = float(leaf.window_start)
377
+ if leaf.window_end is not None:
378
+ out["end"] = float(leaf.window_end)
379
+ return out
380
+
381
+
382
+ def spec_to_raw(node: SpecNode) -> dict:
383
+ """Serialize a ``SpecNode`` back to the raw ``metrics`` JSON shape.
384
+
385
+ Inverse of :func:`parse_metric_tree` — the output round-trips through
386
+ it. Added for review 2026-07-06 finding 41: ``cmd_spec_update`` uses
387
+ this to MATERIALIZE the implicit tree (from
388
+ :func:`synthesize_implicit_tree`) into a flat-override test entry
389
+ before applying reporter-emitted per-leaf patch ops like
390
+ ``/metrics/children/0/tolerance`` — without it, no scalar edit on a
391
+ flat-override test could round-trip.
392
+ """
393
+ if isinstance(node, LeafSpec):
394
+ out: dict = {"metric": node.metric, "variable": node.variable}
395
+ out.update(node.params)
396
+ if node.against != "primary":
397
+ out["against"] = node.against
398
+ window = _window_view(node)
399
+ if window:
400
+ out["window"] = window
401
+ return out
402
+ raw: dict = {
403
+ "combinator": node.combinator,
404
+ "children": [spec_to_raw(c) for c in node.children],
405
+ }
406
+ if node.combinator == "k-of-n":
407
+ raw["k"] = node.k
408
+ if node.combinator == "weighted":
409
+ raw["weights"] = list(node.weights)
410
+ raw["threshold"] = node.threshold
411
+ if node.direction != "less":
412
+ raw["direction"] = node.direction
413
+ return raw
414
+
415
+
416
+ def synthesize_implicit_tree(
417
+ variables: list[str],
418
+ *,
419
+ variable_overrides: dict[str, dict] | None = None,
420
+ base_tolerance: float | None = None,
421
+ ) -> SpecNode:
422
+ """Build a render-only ``SpecNode`` for tests that don't author a tree.
423
+
424
+ The flat-override path (no ``metrics`` block in test_spec.json) scores
425
+ via the implicit AND of per-variable comparisons. The reporter's
426
+ recursive UI works against ``SpecNode`` trees, so we synthesize one
427
+ here. The synthesized tree is **render-only** — it's not written back
428
+ to the spec until the user makes a structural edit (Stage 4 / option
429
+ ii: no silent spec rewrites).
430
+
431
+ Each variable gets one leaf in the order given. The leaf metric is
432
+ derived from ``variable_overrides[var].mode`` (defaults to ``"nrmse"``);
433
+ other override keys flow into the leaf's ``params`` so the controls
434
+ pre-fill correctly. Always wraps in an ``and`` combinator — matches
435
+ :func:`metric_tree.implicit_and_tree`'s shape so evaluation results
436
+ can be merged into the view by JSON-Pointer path without special-
437
+ casing the singleton.
438
+ """
439
+ overrides = variable_overrides or {}
440
+ leaves: list[LeafSpec] = []
441
+ for var in variables:
442
+ ov = dict(overrides.get(var, {}))
443
+ # The override-dict ``mode`` value and the spec ``metric``
444
+ # discriminator are the same identifier — no translation table, just
445
+ # a registry membership check. Absent or unrecognized falls back to
446
+ # the default metric, matching the flat-override scoring path.
447
+ mode_key = ov.pop("mode", None)
448
+ metric_name = mode_key if mode_key and metric(mode_key) else DEFAULT_METRIC
449
+ if base_tolerance is not None and "tolerance" not in ov:
450
+ ov["tolerance"] = float(base_tolerance)
451
+ leaves.append(LeafSpec(metric=metric_name, variable=var, params=ov))
452
+ return CombinatorSpec(combinator="and", children=list(leaves))
453
+
454
+
455
+ def parse_metric_tree(raw: dict, _path: str = "metrics") -> SpecNode:
456
+ """Parse a MetricTree spec dict into the typed form.
457
+
458
+ Raises ``MetricSpecError`` with a path-bearing message on any structural
459
+ or semantic problem (unknown combinator, missing field, wrong type).
460
+ """
461
+ if not isinstance(raw, dict):
462
+ # `null` too: an absent `metrics` is the only "no tree".
463
+ raise MetricSpecError(_path, f"must be an object, got {_as_json(raw)}")
464
+
465
+ has_combinator = "combinator" in raw
466
+ has_metric = "metric" in raw
467
+ if has_combinator and has_metric:
468
+ raise MetricSpecError(
469
+ _path, "node has both 'combinator' and 'metric' keys; pick one"
470
+ )
471
+ if not has_combinator and not has_metric:
472
+ raise MetricSpecError(
473
+ _path,
474
+ "node must have either 'combinator' (internal) or 'metric' (leaf)",
475
+ )
476
+
477
+ if has_combinator:
478
+ return _parse_combinator(raw, _path)
479
+ return _parse_leaf(raw, _path)
480
+
481
+
482
+ def _parse_combinator(raw: dict, path: str) -> CombinatorSpec:
483
+ name = raw["combinator"]
484
+ if not isinstance(name, str) or name not in combinator_names():
485
+ raise MetricSpecError(
486
+ f"{path}.combinator",
487
+ f"unknown combinator {name!r}; valid: {sorted(combinator_names())}",
488
+ )
489
+
490
+ # A key this kind does not read is an error, not a note (`strict_keys`):
491
+ # `k` on an `and` says something the node will never do.
492
+ problem = unknown_field(
493
+ raw, (*_COMBINATOR_KEYS, *COMBINATOR_KIND_KEYS.get(name, ()))
494
+ )
495
+ if problem is not None:
496
+ raise MetricSpecError(path, f"{name} group: {problem}")
497
+
498
+ children_raw = raw.get("children")
499
+ if not isinstance(children_raw, list) or not children_raw:
500
+ raise MetricSpecError(
501
+ f"{path}.children", "combinator must have a non-empty 'children' list"
502
+ )
503
+
504
+ if name == "warn" and len(children_raw) != 1:
505
+ raise MetricSpecError(
506
+ f"{path}.children",
507
+ f"'warn' combinator requires exactly 1 child, got {len(children_raw)}",
508
+ )
509
+
510
+ children = [
511
+ parse_metric_tree(child, f"{path}.children[{i}]")
512
+ for i, child in enumerate(children_raw)
513
+ ]
514
+
515
+ k = 0
516
+ if name == "k-of-n":
517
+ if "k" not in raw:
518
+ raise MetricSpecError(f"{path}.k", "'k-of-n' combinator requires 'k'")
519
+ # Sentences with their subject: the tool shows them without the
520
+ # path, beside the node, and a cleared field is not a typed 0.
521
+ if raw["k"] is None:
522
+ raise MetricSpecError(
523
+ f"{path}.k", "k is empty; it must be a whole number of at least 1."
524
+ )
525
+ if not isinstance(raw["k"], int) or raw["k"] <= 0:
526
+ raise MetricSpecError(
527
+ f"{path}.k",
528
+ f"k must be a whole number of at least 1; got {raw['k']!r}.",
529
+ )
530
+ k = raw["k"]
531
+ if k > len(children):
532
+ n = len(children)
533
+ has = "1 criterion" if n == 1 else f"{n} criteria"
534
+ raise MetricSpecError(
535
+ f"{path}.k", f"k is {k}, but this k-of-n has only {has}."
536
+ )
537
+
538
+ weights: list[float] = []
539
+ threshold = 0.0
540
+ direction = "less"
541
+ if name == "weighted":
542
+ weights_raw = raw.get("weights")
543
+ if not isinstance(weights_raw, list):
544
+ raise MetricSpecError(
545
+ f"{path}.weights", "'weighted' combinator requires a list of weights"
546
+ )
547
+ if len(weights_raw) != len(children):
548
+ n_w, n_c = len(weights_raw), len(children)
549
+ raise MetricSpecError(
550
+ f"{path}.weights",
551
+ f"{n_w} weight{'' if n_w == 1 else 's'} for {n_c} "
552
+ f"{'criterion' if n_c == 1 else 'criteria'}: a weighted node "
553
+ f"takes one weight per criterion.",
554
+ )
555
+ try:
556
+ weights = [float(w) for w in weights_raw]
557
+ except (TypeError, ValueError, OverflowError) as exc:
558
+ raise MetricSpecError(
559
+ f"{path}.weights", f"all weights must be numeric ({exc})"
560
+ ) from exc
561
+ if "threshold" not in raw:
562
+ raise MetricSpecError(
563
+ f"{path}.threshold", "'weighted' combinator requires 'threshold'"
564
+ )
565
+ try:
566
+ threshold = float(raw["threshold"])
567
+ except (TypeError, ValueError, OverflowError) as exc:
568
+ raise MetricSpecError(
569
+ f"{path}.threshold", f"must be numeric, got {raw['threshold']!r}"
570
+ ) from exc
571
+ direction = raw.get("direction", "less")
572
+ if direction not in ("less", "greater"):
573
+ raise MetricSpecError(
574
+ f"{path}.direction", f"must be 'less' or 'greater', got {direction!r}"
575
+ )
576
+
577
+ return CombinatorSpec(
578
+ combinator=name,
579
+ children=children,
580
+ k=k,
581
+ weights=weights,
582
+ threshold=threshold,
583
+ direction=direction,
584
+ )
585
+
586
+
587
+ def _parse_leaf(raw: dict, path: str) -> LeafSpec:
588
+ metric_name = raw["metric"]
589
+ if not isinstance(metric_name, str) or metric_name not in metric_names():
590
+ raise MetricSpecError(
591
+ f"{path}.metric",
592
+ f"unknown metric {metric_name!r}; valid: {sorted(metric_names())}",
593
+ )
594
+
595
+ variable = raw.get("variable")
596
+ quantity = raw.get("quantity")
597
+ desc = metric(metric_name)
598
+ if (
599
+ variable is None
600
+ and desc is not None
601
+ and desc.accepts_quantity
602
+ and isinstance(quantity, str)
603
+ and quantity
604
+ ):
605
+ # For a metric that declares ``accepts_quantity``, a string quantity
606
+ # names a stored scalar (statistics path / diagnostics summary), so
607
+ # the leaf has no tracked series to name — the path doubles as the
608
+ # display label. An explicit ``variable`` alongside it stays as the
609
+ # label the user chose. Every other metric scores a trajectory and
610
+ # still requires ``variable``.
611
+ variable = quantity
612
+ if not isinstance(variable, str) or not variable:
613
+ raise MetricSpecError(f"{path}.variable", "required string field")
614
+
615
+ # ``against`` picks which named baseline the leaf scores against.
616
+ # Defaults to "primary"; must be a non-empty string if present.
617
+ against = raw.get("against", "primary")
618
+ if not isinstance(against, str) or not against:
619
+ raise MetricSpecError(
620
+ f"{path}.against", f"must be a non-empty string, got {against!r}"
621
+ )
622
+
623
+ # ``window`` scopes the leaf to a sub-interval [start, end] (idea #46).
624
+ # Both endpoints optional; open-ended on either side supported. The
625
+ # comparator slices before calling mode.compare so every metric
626
+ # composes uniformly with the window.
627
+ window_start: float | None = None
628
+ window_end: float | None = None
629
+ # Absent is the only "no window": `"window": null` was taken for one,
630
+ # and `"start": null` was a TypeError out of the parser — the exported
631
+ # schema refuses both.
632
+ if "window" in raw:
633
+ window_raw = raw["window"]
634
+
635
+ def refused(problem: str) -> MetricSpecError:
636
+ return MetricSpecError(
637
+ f"{path}.window", f"{metric_name} · {variable}: window: {problem}"
638
+ )
639
+
640
+ if not isinstance(window_raw, dict):
641
+ raise refused(
642
+ f"must be an object with start / end, got {_as_json(window_raw)}"
643
+ )
644
+ problem = unknown_field(window_raw, _WINDOW_KEYS)
645
+ if problem is not None:
646
+ raise refused(problem)
647
+ ends: dict[str, float] = {}
648
+ for end, value in window_raw.items():
649
+ if not _is_number(value):
650
+ raise refused(f"{end} must be a number, got {_as_json(value)}")
651
+ ends[end] = float(value)
652
+ window_start, window_end = ends.get("start"), ends.get("end")
653
+ if (
654
+ window_start is not None
655
+ and window_end is not None
656
+ and window_end <= window_start
657
+ ):
658
+ raise MetricSpecError(
659
+ f"{path}.window",
660
+ f"The window must end after it starts: it starts at {window_start} "
661
+ f"and ends at {window_end}.",
662
+ )
663
+
664
+ # Everything else is metric-specific params (tolerance, tube_rel, ...) —
665
+ # and each has to be one the metric reads. A misspelled one used to ride
666
+ # along unread: `"max_val": 0.5` on a range scored with no upper bound.
667
+ params = {k: v for k, v in raw.items() if k not in _LEAF_KEYS}
668
+ problem = unread_param(metric_name, params, beside=_LEAF_KEYS)
669
+ if problem is not None:
670
+ raise MetricSpecError(path, f"{metric_name} · {variable}: {problem}")
671
+ return LeafSpec(
672
+ metric=metric_name,
673
+ variable=variable,
674
+ params=params,
675
+ against=against,
676
+ window_start=window_start,
677
+ window_end=window_end,
678
+ )