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.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|