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,1189 @@
|
|
|
1
|
+
"""Compare simulation results against stored references — orchestration.
|
|
2
|
+
|
|
3
|
+
The comparison surface is split across three modules:
|
|
4
|
+
|
|
5
|
+
* :mod:`.types` — :class:`VariableComparison` / :class:`StructuralWarning`
|
|
6
|
+
/ :class:`TestComparison` dataclasses. Imported widely; lives
|
|
7
|
+
separately so the algorithm and orchestration modules can both
|
|
8
|
+
consume it without an import cycle.
|
|
9
|
+
* :mod:`.algorithms` — per-mode math (``_compare_trajectories`` /
|
|
10
|
+
``_compare_tube`` / ``_compare_points`` / ``_compare_range`` /
|
|
11
|
+
``_compare_scalar`` / ``_compare_event_timing`` /
|
|
12
|
+
``_compare_dominant_frequency`` plus
|
|
13
|
+
their FFT/event-detection helpers). Pure compute.
|
|
14
|
+
* THIS MODULE — :func:`compare_test` and :func:`compare_all`
|
|
15
|
+
orchestration: pulls the right baseline, dispatches to the right
|
|
16
|
+
algorithm via the :class:`MetricTree` evaluator, threads structural
|
|
17
|
+
warnings, derives the final :class:`TestComparison.passed` flag.
|
|
18
|
+
|
|
19
|
+
For historical-import compatibility, :class:`VariableComparison` /
|
|
20
|
+
:class:`StructuralWarning` / :class:`TestComparison` and the
|
|
21
|
+
``_compare_*`` algorithm functions are re-exported here. New code
|
|
22
|
+
should import them from :mod:`.types` and :mod:`.algorithms` directly.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
from collections.abc import Iterable, Mapping
|
|
27
|
+
from typing import TYPE_CHECKING, Any
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
from ..backends import TestResult
|
|
32
|
+
from ..backends.common.result_variables import tracked_results, tracks
|
|
33
|
+
from ..backends.types import DatasetType, VariableResult
|
|
34
|
+
from ..discovery.test_registry import TestModel
|
|
35
|
+
from ..discovery.unit_tests import unit_test_variables
|
|
36
|
+
from ..storage.baseline import PRIMARY_BASELINE
|
|
37
|
+
from ..storage.reference_store import ReferenceStore
|
|
38
|
+
|
|
39
|
+
# Re-export facade (see module docstring): modes/tree_eval/metric_tree and the
|
|
40
|
+
# test suite import the per-mode algorithm functions from here, not from
|
|
41
|
+
# .algorithms directly. comparator.py does not call them itself, so they read
|
|
42
|
+
# as unused — keep the noqa to preserve the public import surface.
|
|
43
|
+
from .algorithms import ( # noqa: F401
|
|
44
|
+
_compare_dominant_frequency,
|
|
45
|
+
_compare_event_timing,
|
|
46
|
+
_compare_points,
|
|
47
|
+
_compare_range,
|
|
48
|
+
_compare_scalar,
|
|
49
|
+
_compare_trajectories,
|
|
50
|
+
_compare_tube,
|
|
51
|
+
_compute_fft_spectrum,
|
|
52
|
+
_dedup_time_series,
|
|
53
|
+
_find_event_boundaries,
|
|
54
|
+
_find_strongest_peak_in_window,
|
|
55
|
+
_find_top_n_peaks,
|
|
56
|
+
_interpolate_tube_widths,
|
|
57
|
+
_min_delta_in_box,
|
|
58
|
+
_split_segments,
|
|
59
|
+
)
|
|
60
|
+
from .registry import metric as registry_metric
|
|
61
|
+
from .sides import base_tolerance_refused, is_base_tolerance, parse_sides
|
|
62
|
+
from .types import (
|
|
63
|
+
DEFAULT_TOLERANCE,
|
|
64
|
+
StructuralWarning,
|
|
65
|
+
TestComparison,
|
|
66
|
+
VariableComparison,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if TYPE_CHECKING:
|
|
70
|
+
# Annotation-only — runtime import is inside compare_test() to break the
|
|
71
|
+
# comparator <-> metric_tree cycle (metric_tree imports VariableComparison
|
|
72
|
+
# from .types).
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
logger = logging.getLogger(__name__)
|
|
76
|
+
|
|
77
|
+
#: How a refusal says WHERE the base tolerance it read is written, by the
|
|
78
|
+
#: source the resolution names.
|
|
79
|
+
_BASE_TOLERANCE_WHERE = {
|
|
80
|
+
"test": "test_spec.json comparison.tolerance",
|
|
81
|
+
"reference": "the baseline's comparison.tolerance",
|
|
82
|
+
"project": "the project's tolerance",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _base_tolerance(
|
|
87
|
+
test: TestModel, reference: dict, default_tolerance: object
|
|
88
|
+
) -> tuple[Any, str]:
|
|
89
|
+
"""The test's base tolerance and its source: per-test > reference >
|
|
90
|
+
project. An ``is not None`` chain, NOT ``or`` — an explicit tolerance of
|
|
91
|
+
0 (strictest possible) must be honored (review 2026-07-06, finding 7)."""
|
|
92
|
+
ref_comparison = reference.get("comparison", {})
|
|
93
|
+
if test.comparison_tolerance is not None:
|
|
94
|
+
return test.comparison_tolerance, "test"
|
|
95
|
+
if ref_comparison.get("tolerance") is not None:
|
|
96
|
+
return ref_comparison["tolerance"], "reference"
|
|
97
|
+
return default_tolerance, "project"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def refused_base_tolerance(
|
|
101
|
+
test: TestModel, reference: dict | None, default_tolerance: object
|
|
102
|
+
) -> TestComparison | None:
|
|
103
|
+
"""The refusal of a test whose base tolerance — the one in effect — is
|
|
104
|
+
not ONE finite number, or ``None``. nrmse, the default metric, has one
|
|
105
|
+
ceiling, and every leaf without a tolerance of its own would inherit the
|
|
106
|
+
pair; whatever else a spec or a reference JSON holds there (a string, a
|
|
107
|
+
list, a bool, NaN) goes with it: never scored at the next tolerance down.
|
|
108
|
+
|
|
109
|
+
THE one decision, and the first thing a comparison of a test that ran
|
|
110
|
+
makes (``compare_one``; ``compare_test`` for a direct caller). Decided
|
|
111
|
+
later, a test with no baseline read NO_REF — "accept it" — and one whose
|
|
112
|
+
every criterion scores without a baseline passed over it."""
|
|
113
|
+
value, source = _base_tolerance(test, reference or {}, default_tolerance)
|
|
114
|
+
if is_base_tolerance(value):
|
|
115
|
+
return None
|
|
116
|
+
return TestComparison(
|
|
117
|
+
model_id=test.model_id,
|
|
118
|
+
passed=False,
|
|
119
|
+
test_id=(reference or {}).get("test_id"),
|
|
120
|
+
has_reference=reference is not None,
|
|
121
|
+
error_message=base_tolerance_refused(_BASE_TOLERANCE_WHERE[source], value),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
#: How many of a test's tracked names — or of its override keys —
|
|
126
|
+
#: :func:`nothing_scored` spells out.
|
|
127
|
+
_NAMED_TRACKED = 4
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _named_few(names: list[str]) -> str:
|
|
131
|
+
"""Up to `_NAMED_TRACKED` of ``names``, then ``and N more``."""
|
|
132
|
+
named = ", ".join(names[:_NAMED_TRACKED])
|
|
133
|
+
if len(names) > _NAMED_TRACKED:
|
|
134
|
+
named += f" and {len(names) - _NAMED_TRACKED} more"
|
|
135
|
+
return named
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def nothing_scored(test: TestModel, result: TestResult) -> str | None:
|
|
139
|
+
"""Why a test with no tree scores NOTHING of *result*, or ``None``: the
|
|
140
|
+
result holds none of the variables the test tracks — its ``variables``
|
|
141
|
+
patterns match no name in it, its UnitTests variables are not there (a
|
|
142
|
+
model replaced by an empty class of the same name still simulates). The
|
|
143
|
+
flat path scores one criterion per tracked variable the result holds,
|
|
144
|
+
and the AND of none of them is true: such a test read PASS, with no
|
|
145
|
+
criterion and no word.
|
|
146
|
+
|
|
147
|
+
THE one decision: the comparison fails the test with this sentence
|
|
148
|
+
(``reporting.status.refusal`` puts it where a failing criterion would
|
|
149
|
+
be named), and an accept skips it with the same one — the baseline it
|
|
150
|
+
would write holds nothing the test asks about. A test with a tree is
|
|
151
|
+
not asked: each of its leaves fails by itself when its variable is not
|
|
152
|
+
in the result. A simulate-only test is not asked either — one a
|
|
153
|
+
recognizer marked, or one that declares nothing to score
|
|
154
|
+
(``TestModel.is_simulate_only``): that it ran is its criterion.
|
|
155
|
+
|
|
156
|
+
A test that tracks NOTHING and holds a ``variable_overrides`` entry is
|
|
157
|
+
asked, and has a sentence of its own: there is no tracked variable for
|
|
158
|
+
the result to hold, and what is wrong is the override, which names a
|
|
159
|
+
variable the test does not track. It is the scored path's log line for
|
|
160
|
+
such a key (``_warn_about_overrides_that_name_nothing``) where no other
|
|
161
|
+
variable is scored beside it."""
|
|
162
|
+
if test.is_simulate_only or test.metric_tree_spec is not None:
|
|
163
|
+
return None
|
|
164
|
+
if not result.success or tracked_results(test, result):
|
|
165
|
+
return None
|
|
166
|
+
tracked = [v.name for v in unit_test_variables(test)] + list(test.variable_patterns)
|
|
167
|
+
# A reason, as every refusal is: a page leads it with "Not scored: ".
|
|
168
|
+
if not tracked:
|
|
169
|
+
return (
|
|
170
|
+
"variable_overrides key(s) name no variable this test tracks "
|
|
171
|
+
f"({_named_few(list(test.variable_overrides))}) — an override changes "
|
|
172
|
+
'how a tracked variable is scored, and "variables" (or a "metrics" '
|
|
173
|
+
"leaf) is what tracks one; nothing was scored"
|
|
174
|
+
)
|
|
175
|
+
return (
|
|
176
|
+
"the result holds none of the variables this test tracks "
|
|
177
|
+
f"({_named_few(tracked)}) — nothing was scored"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def stored_override_problem(
|
|
182
|
+
reference: dict, store: ReferenceStore, model_id: str
|
|
183
|
+
) -> str | None:
|
|
184
|
+
"""Why a baseline's stored ``comparison.variable_overrides`` cannot be
|
|
185
|
+
read, as a sentence naming the reference file, or ``None``: an override
|
|
186
|
+
that is no object, or a key its mode does not read — the check a spec's
|
|
187
|
+
override goes through (``strict_keys.unread_override_key``).
|
|
188
|
+
|
|
189
|
+
A baseline keeps the comparison settings it was accepted with, and they
|
|
190
|
+
are merged under the spec's at compare. A stored ``{"mode": "range",
|
|
191
|
+
"min_value": -1, "max_val": 0.5}`` scored as a range with no upper
|
|
192
|
+
bound, and passed with the value at 10 — the spec file's own hole, one
|
|
193
|
+
file over. Every stored override is checked, read by this comparison or
|
|
194
|
+
not (a ``metrics`` tree reads none, the spec's own replaces one): the
|
|
195
|
+
file holds it either way, and an accept keeps it.
|
|
196
|
+
|
|
197
|
+
THE one decision, with two readers. A command that compares many tests
|
|
198
|
+
stops on it before it does anything else (:func:`refuse_stored_overrides`:
|
|
199
|
+
``run`` before it simulates, ``compare`` before it scores). A comparison
|
|
200
|
+
of ONE test refuses that test with it (:func:`compare_one`): the tool
|
|
201
|
+
opens a project test by test, and a file to repair under one of them is
|
|
202
|
+
no reason to score none."""
|
|
203
|
+
from .sides import _as_json
|
|
204
|
+
from .strict_keys import unread_override_key
|
|
205
|
+
|
|
206
|
+
comparison = reference.get("comparison")
|
|
207
|
+
overrides = (
|
|
208
|
+
comparison.get("variable_overrides") if isinstance(comparison, dict) else None
|
|
209
|
+
)
|
|
210
|
+
if overrides is None:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
def said(place: str, problem: str) -> str:
|
|
214
|
+
# The file is asked for only here: a refusal names it.
|
|
215
|
+
ref_file = store.reference_file(model_id)
|
|
216
|
+
return f"{ref_file}: comparison.variable_overrides{place}: {problem}"
|
|
217
|
+
|
|
218
|
+
if not isinstance(overrides, dict):
|
|
219
|
+
return said("", f"must be an object, got {_as_json(overrides)}")
|
|
220
|
+
for variable, override in overrides.items():
|
|
221
|
+
if not isinstance(override, dict):
|
|
222
|
+
return said(f"[{variable}]", f"must be an object, got {_as_json(override)}")
|
|
223
|
+
found = unread_override_key(override)
|
|
224
|
+
if found is not None:
|
|
225
|
+
mode, problem = found
|
|
226
|
+
return said(f"[{variable}]", f"{mode} · {variable}: {problem}")
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def refuse_stored_overrides(tests: Iterable[TestModel], store: ReferenceStore) -> None:
|
|
231
|
+
"""``ConfigError`` for the first of *tests* whose stored baseline holds
|
|
232
|
+
an override nothing reads (:func:`stored_override_problem`). What a
|
|
233
|
+
command calls with the tests it is about to compare, before it simulates
|
|
234
|
+
or scores any of them: the reference files are there at discovery, and a
|
|
235
|
+
run that found one at its compare had simulated everything for no
|
|
236
|
+
verdict."""
|
|
237
|
+
from ..config import ConfigError
|
|
238
|
+
|
|
239
|
+
for test in tests:
|
|
240
|
+
reference = store.get_reference(test.model_id)
|
|
241
|
+
if reference is None:
|
|
242
|
+
continue
|
|
243
|
+
problem = stored_override_problem(reference, store, test.model_id)
|
|
244
|
+
if problem is not None:
|
|
245
|
+
raise ConfigError(problem)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _check_structural_changes(
|
|
249
|
+
reference: dict,
|
|
250
|
+
result: TestResult,
|
|
251
|
+
) -> list[StructuralWarning]:
|
|
252
|
+
"""Compare structural statistics between reference and current run."""
|
|
253
|
+
warnings = []
|
|
254
|
+
|
|
255
|
+
ref_stats = reference.get("statistics", {})
|
|
256
|
+
cur_stats = result.statistics or {}
|
|
257
|
+
|
|
258
|
+
checks = [
|
|
259
|
+
("translation.continuous_time_states", "Continuous states"),
|
|
260
|
+
("translation.nonlinear_count", "Nonlinear system count"),
|
|
261
|
+
(
|
|
262
|
+
"translation.nonlinear_after_manipulation_max",
|
|
263
|
+
"Nonlinear max size (after manipulation)",
|
|
264
|
+
),
|
|
265
|
+
("translation.linear_count", "Linear system count"),
|
|
266
|
+
(
|
|
267
|
+
"translation.linear_after_manipulation_max",
|
|
268
|
+
"Linear max size (after manipulation)",
|
|
269
|
+
),
|
|
270
|
+
("translation.scalar_unknowns", "Scalar unknowns"),
|
|
271
|
+
("translation.scalar_equations", "Scalar equations"),
|
|
272
|
+
("translation.numerical_jacobians", "Numerical Jacobians"),
|
|
273
|
+
("translation.init_nonlinear_count", "Init nonlinear system count"),
|
|
274
|
+
("translation.init_numerical_jacobians", "Init numerical Jacobians"),
|
|
275
|
+
("EventCounter", "Event count"),
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
for dotted_key, label in checks:
|
|
279
|
+
parts = dotted_key.split(".")
|
|
280
|
+
ref_val: object = ref_stats
|
|
281
|
+
cur_val: object = cur_stats
|
|
282
|
+
for p in parts:
|
|
283
|
+
# .get(p) — a missing key must yield None (skip), not {}. With
|
|
284
|
+
# {} the None-guard below never fired and every baseline-free
|
|
285
|
+
# run produced phantom "{} → 5" warnings (review 2026-07-06).
|
|
286
|
+
ref_val = ref_val.get(p) if isinstance(ref_val, dict) else None
|
|
287
|
+
cur_val = cur_val.get(p) if isinstance(cur_val, dict) else None
|
|
288
|
+
|
|
289
|
+
if ref_val is None or cur_val is None:
|
|
290
|
+
continue
|
|
291
|
+
if str(ref_val) != str(cur_val):
|
|
292
|
+
warnings.append(
|
|
293
|
+
StructuralWarning(
|
|
294
|
+
field=label,
|
|
295
|
+
reference_value=str(ref_val),
|
|
296
|
+
current_value=str(cur_val),
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
return warnings
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def migration_hint(
|
|
304
|
+
baseline_entries: Mapping[str, dict], name: str, expression: str
|
|
305
|
+
) -> str:
|
|
306
|
+
"""The clause that says which name a baseline entry must be stored
|
|
307
|
+
under, or ``""``.
|
|
308
|
+
|
|
309
|
+
A variable whose baseline entry is missing is un-migrated exactly when
|
|
310
|
+
the baseline holds an UN-MIGRATED entry under the text the variable's
|
|
311
|
+
EXPRESSION spells — the name every UnitTests baseline was stored under
|
|
312
|
+
before the identity change. Both scoring paths say it, from here, with
|
|
313
|
+
``name``: the variable's result-file name, which is what that entry
|
|
314
|
+
has to be stored under. A hint on every missing baseline would tell a
|
|
315
|
+
whole suite of brand-new variables to rename an entry no baseline
|
|
316
|
+
holds (the Task 3 review).
|
|
317
|
+
|
|
318
|
+
Two things the raw texts do not decide, so this reads the ENTRIES:
|
|
319
|
+
|
|
320
|
+
* The stored name is compared NORMALIZED
|
|
321
|
+
(:func:`~dstf.discovery.mo_parser.normalize_expression`). A baseline
|
|
322
|
+
written from a ``.mo`` source that wrapped the line holds
|
|
323
|
+
``heatTransfer.alphas[\\n 1, 1]``, and the expression beside
|
|
324
|
+
the result variable is normalized; compared raw, the most common
|
|
325
|
+
un-migrated shape in a real library got no hint at all.
|
|
326
|
+
* An entry that already carries ``"expression"`` was written after the
|
|
327
|
+
identity change and is not un-migrated. A migrated baseline may hold
|
|
328
|
+
a spec-tracked ``pipe.T[1]`` beside a NEW ``unitTests.x[1]`` whose
|
|
329
|
+
expression happens to be ``pipe.T[1]``; a hint there would name a
|
|
330
|
+
rename that must not happen.
|
|
331
|
+
"""
|
|
332
|
+
from ..discovery.mo_parser import normalize_expression
|
|
333
|
+
|
|
334
|
+
if not expression:
|
|
335
|
+
return ""
|
|
336
|
+
if not any(
|
|
337
|
+
normalize_expression(stored) == expression and "expression" not in entry
|
|
338
|
+
for stored, entry in baseline_entries.items()
|
|
339
|
+
):
|
|
340
|
+
return ""
|
|
341
|
+
return (
|
|
342
|
+
f" — the baseline stores it under its expression {expression!r}; "
|
|
343
|
+
f"the stored name must be the result-file name {name!r}"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _check_expressions(
|
|
348
|
+
variables: list[dict],
|
|
349
|
+
result: TestResult,
|
|
350
|
+
*,
|
|
351
|
+
against: str = "",
|
|
352
|
+
only: Iterable[str] | None = None,
|
|
353
|
+
) -> list[StructuralWarning]:
|
|
354
|
+
"""A UnitTests variable is keyed by its position (``unitTests.x[2]``), so
|
|
355
|
+
an edit to the model's ``x={...}`` can put a different quantity under the
|
|
356
|
+
same name. The baseline records the expression it was accepted with; a
|
|
357
|
+
run measuring another one says so. A warning, never a failure — and
|
|
358
|
+
silent when either side recorded nothing (a pattern-tracked variable, a
|
|
359
|
+
backend that writes no expression).
|
|
360
|
+
|
|
361
|
+
``variables`` is the entry list of ONE baseline and ``against`` its name
|
|
362
|
+
(empty for the primary, which needs no saying). Pairing is by name, and
|
|
363
|
+
a name stored twice is read from the FIRST entry — the one the scoring
|
|
364
|
+
pairs with; reading the last reported a change no leaf ever scored.
|
|
365
|
+
|
|
366
|
+
``only`` narrows the result variables checked. The primary passes None
|
|
367
|
+
— every variable is compared against it — while a NAMED baseline is
|
|
368
|
+
given the variables some leaf actually scores against it: an
|
|
369
|
+
``against: fmpy`` soft-check holding the whole result's variables
|
|
370
|
+
otherwise warned about quantities that baseline is never compared with.
|
|
371
|
+
"""
|
|
372
|
+
scope = None if only is None else set(only)
|
|
373
|
+
recorded: dict[str, str] = {}
|
|
374
|
+
for entry in variables:
|
|
375
|
+
name = entry.get("name") or ""
|
|
376
|
+
if name and name not in recorded:
|
|
377
|
+
recorded[name] = entry.get("expression") or ""
|
|
378
|
+
where = f" (against {against})" if against else ""
|
|
379
|
+
return [
|
|
380
|
+
StructuralWarning(
|
|
381
|
+
field=f"Expression of {var.name}{where}",
|
|
382
|
+
reference_value=recorded[var.name],
|
|
383
|
+
current_value=var.expression,
|
|
384
|
+
)
|
|
385
|
+
for var in result.variables
|
|
386
|
+
if var.expression
|
|
387
|
+
and (scope is None or var.name in scope)
|
|
388
|
+
and recorded.get(var.name)
|
|
389
|
+
and recorded[var.name] != var.expression
|
|
390
|
+
]
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _named_baseline_expression_warnings(
|
|
394
|
+
spec,
|
|
395
|
+
baselines: dict,
|
|
396
|
+
result: TestResult,
|
|
397
|
+
) -> list[StructuralWarning]:
|
|
398
|
+
"""The expression check on every NON-primary baseline the tree scores
|
|
399
|
+
against (an ``against: fmpy`` leaf, a soft_check import).
|
|
400
|
+
|
|
401
|
+
A named baseline records its own expressions, and the primary's check
|
|
402
|
+
never reads them — a soft_check accepted against ``a.Q`` while the model
|
|
403
|
+
now measures ``b.Q`` compares two different quantities in silence. The
|
|
404
|
+
check runs once per (variable, baseline) PAIR the tree scores, not once
|
|
405
|
+
per leaf naming it: two leaves on one variable say it once, and a
|
|
406
|
+
variable no leaf scores against this baseline is not checked against it
|
|
407
|
+
at all — nothing compares those two numbers, so a difference between
|
|
408
|
+
them is not a finding about this run.
|
|
409
|
+
"""
|
|
410
|
+
from .tree_spec import collect_variables_by_baseline
|
|
411
|
+
|
|
412
|
+
out: list[StructuralWarning] = []
|
|
413
|
+
for name, variables in collect_variables_by_baseline(spec).items():
|
|
414
|
+
if name == PRIMARY_BASELINE:
|
|
415
|
+
continue
|
|
416
|
+
baseline = baselines.get(name)
|
|
417
|
+
if baseline is not None:
|
|
418
|
+
out += _check_expressions(
|
|
419
|
+
baseline.variables, result, against=name, only=variables
|
|
420
|
+
)
|
|
421
|
+
return out
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
#: Where an override key was read from, for the message below. The two
|
|
425
|
+
#: tiers are merged before scoring, so the key alone does not say which
|
|
426
|
+
#: file to open.
|
|
427
|
+
_SPEC_TIER = "test_spec.json"
|
|
428
|
+
_REF_TIER = "the reference JSON"
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _warn_about_overrides_that_name_nothing(
|
|
432
|
+
model_id: str,
|
|
433
|
+
spec_overrides: dict[str, dict],
|
|
434
|
+
ref_overrides: dict[str, dict],
|
|
435
|
+
result: TestResult,
|
|
436
|
+
) -> None:
|
|
437
|
+
"""An override key that names no variable of this result does nothing.
|
|
438
|
+
|
|
439
|
+
It scored nothing before either — the flat path has always looked its
|
|
440
|
+
keys up by the result variable's name — but it was silent about it. A key
|
|
441
|
+
that names a variable's EXPRESSION is the shape a spec written before the
|
|
442
|
+
identity change has (``pipe.T[1]`` where the result file says
|
|
443
|
+
``unitTests.x[1]``): the message says which name it meant, and that the
|
|
444
|
+
key must be that name. One line per test; no status changes.
|
|
445
|
+
|
|
446
|
+
It also says WHICH FILE holds each key. Overrides come from two tiers —
|
|
447
|
+
the test's entry in ``test_spec.json`` and the ``comparison`` section of
|
|
448
|
+
the baseline the run scored against — which are merged before anything
|
|
449
|
+
is scored; told only the key, a user opens the wrong one. A key present
|
|
450
|
+
in both is named for both.
|
|
451
|
+
|
|
452
|
+
When one expression belongs to several variables (``x=cat(1, y, y)``
|
|
453
|
+
spells every element the same), the message names all of them: which one
|
|
454
|
+
the key meant is the user's call, not a guess this makes for them.
|
|
455
|
+
"""
|
|
456
|
+
names = {v.name for v in result.variables if v.name}
|
|
457
|
+
unknown = [key for key in {**ref_overrides, **spec_overrides} if key not in names]
|
|
458
|
+
if not unknown:
|
|
459
|
+
return
|
|
460
|
+
by_expression: dict[str, list[str]] = {}
|
|
461
|
+
for v in result.variables:
|
|
462
|
+
if v.expression and v.name:
|
|
463
|
+
by_expression.setdefault(v.expression, []).append(v.name)
|
|
464
|
+
parts = []
|
|
465
|
+
for key in unknown:
|
|
466
|
+
tiers = [
|
|
467
|
+
tier
|
|
468
|
+
for tier, keys in ((_SPEC_TIER, spec_overrides), (_REF_TIER, ref_overrides))
|
|
469
|
+
if key in keys
|
|
470
|
+
]
|
|
471
|
+
where = f" in {' and '.join(tiers)}"
|
|
472
|
+
owners = by_expression.get(key)
|
|
473
|
+
of = (
|
|
474
|
+
f" (the expression of {', '.join(repr(n) for n in owners)})"
|
|
475
|
+
if owners
|
|
476
|
+
else ""
|
|
477
|
+
)
|
|
478
|
+
parts.append(f"{key!r}{where}{of}")
|
|
479
|
+
hint = (
|
|
480
|
+
" — a key that names an expression must be the result-file name of "
|
|
481
|
+
"the variable it means, named beside it"
|
|
482
|
+
if any(key in by_expression for key in unknown)
|
|
483
|
+
else ""
|
|
484
|
+
)
|
|
485
|
+
logger.warning(
|
|
486
|
+
"%s: variable_overrides key(s) name no variable of this result and "
|
|
487
|
+
"are ignored: %s%s",
|
|
488
|
+
model_id,
|
|
489
|
+
", ".join(parts),
|
|
490
|
+
hint,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _tracked_but_not_scored(
|
|
495
|
+
test: TestModel, result: TestResult
|
|
496
|
+
) -> StructuralWarning | None:
|
|
497
|
+
"""A tree scores only what its leaves name; a tracked variable no leaf
|
|
498
|
+
covers is stored and plotted but never judged, and CI stays green while
|
|
499
|
+
it drifts. Say so on every surface that shows warnings (D126). The flat
|
|
500
|
+
path has no such gap — it scores every tracked variable."""
|
|
501
|
+
from .tree_spec import collect_variables
|
|
502
|
+
|
|
503
|
+
assert test.metric_tree_spec is not None
|
|
504
|
+
covered = set(collect_variables(test.metric_tree_spec))
|
|
505
|
+
# What the test TRACKS, not whatever the result holds: a variable a
|
|
506
|
+
# discarded criterion had re-read is no tracked variable of it.
|
|
507
|
+
unscored = [
|
|
508
|
+
v.name
|
|
509
|
+
for v in tracked_results(test, result)
|
|
510
|
+
if v.name and v.name not in covered
|
|
511
|
+
]
|
|
512
|
+
if not unscored:
|
|
513
|
+
return None
|
|
514
|
+
return StructuralWarning(
|
|
515
|
+
field="Tracked but not scored by any criterion",
|
|
516
|
+
statement=", ".join(unscored),
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def compare_test(
|
|
521
|
+
test: TestModel,
|
|
522
|
+
result: TestResult,
|
|
523
|
+
reference: dict,
|
|
524
|
+
default_tolerance: float = DEFAULT_TOLERANCE,
|
|
525
|
+
default_points: bool = False,
|
|
526
|
+
store=None, # Optional[ReferenceStore] — if provided, soft_checks from the new subdir layout are merged in
|
|
527
|
+
dataset_kind: DatasetType = DatasetType.TIME_SERIES,
|
|
528
|
+
) -> TestComparison:
|
|
529
|
+
"""Compare a test's simulation results against its reference.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
test: Test specification with model ID, variable overrides, etc.
|
|
533
|
+
result: Simulation output (variables with time series).
|
|
534
|
+
reference: Stored reference data dict.
|
|
535
|
+
default_tolerance: Fallback tolerance when no per-test or per-ref
|
|
536
|
+
tolerance is set.
|
|
537
|
+
default_points: When True, variables without an explicit ``mode``
|
|
538
|
+
override use points-mode (with empty points list = final-value
|
|
539
|
+
comparison) instead of full NRMSE. Variables with
|
|
540
|
+
``mode: "tube"`` are *not* affected.
|
|
541
|
+
dataset_kind: The :class:`~dstf.backends.types.DatasetType` the
|
|
542
|
+
backend that produced *result* declares it emits. Threaded into
|
|
543
|
+
the MetricTree evaluator so a leaf whose metric doesn't accept
|
|
544
|
+
the kind hard-fails with a diagnostic naming both. Defaults to
|
|
545
|
+
``TIME_SERIES`` — the only kind any backend produces today.
|
|
546
|
+
"""
|
|
547
|
+
from .metric_tree import implicit_and_tree
|
|
548
|
+
from .modes import override_metric, resolve_mode
|
|
549
|
+
from .tree_eval import collect_leaf_variables, evaluate_spec
|
|
550
|
+
|
|
551
|
+
ref_test_id = reference.get("test_id")
|
|
552
|
+
|
|
553
|
+
if not result.success:
|
|
554
|
+
return TestComparison(
|
|
555
|
+
model_id=test.model_id,
|
|
556
|
+
passed=False,
|
|
557
|
+
test_id=ref_test_id,
|
|
558
|
+
sim_success=False,
|
|
559
|
+
error_message=result.error_message or "Simulation failed",
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
refused = refused_base_tolerance(test, reference, default_tolerance)
|
|
563
|
+
if refused is not None:
|
|
564
|
+
return refused
|
|
565
|
+
|
|
566
|
+
# PTA.5 — simulate_only short-circuit: when the recognizer marked this
|
|
567
|
+
# test as "just check that it simulates", or it declares nothing to
|
|
568
|
+
# score, skip per-variable comparison and the whole baseline machinery.
|
|
569
|
+
# The sim already succeeded above.
|
|
570
|
+
if test.is_simulate_only:
|
|
571
|
+
from .metric_tree import MetricResult
|
|
572
|
+
|
|
573
|
+
leaf = MetricResult(
|
|
574
|
+
passed=True,
|
|
575
|
+
score=None,
|
|
576
|
+
label="simulate-only",
|
|
577
|
+
diagnostics={"note": "no comparison performed; simulation succeeded"},
|
|
578
|
+
)
|
|
579
|
+
return TestComparison(
|
|
580
|
+
model_id=test.model_id,
|
|
581
|
+
passed=True,
|
|
582
|
+
test_id=ref_test_id,
|
|
583
|
+
variables=[],
|
|
584
|
+
metric_tree=leaf,
|
|
585
|
+
simulate_only=True,
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
nothing = nothing_scored(test, result)
|
|
589
|
+
if nothing is not None:
|
|
590
|
+
# Not a PASS: no criterion was scored, so there is no verdict about
|
|
591
|
+
# the model at all. FAIL, the status a tree's leaf gives a variable
|
|
592
|
+
# the result does not hold, with the reason where the failing
|
|
593
|
+
# criterion would be named.
|
|
594
|
+
return TestComparison(
|
|
595
|
+
model_id=test.model_id,
|
|
596
|
+
passed=False,
|
|
597
|
+
test_id=ref_test_id,
|
|
598
|
+
error_message=nothing,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
structural_warnings = _check_structural_changes(
|
|
602
|
+
reference, result
|
|
603
|
+
) + _check_expressions(reference.get("variables", []), result)
|
|
604
|
+
|
|
605
|
+
# Pairing is by name. Pairing by numeric index silently scored variables
|
|
606
|
+
# against the wrong baseline trajectory after any spec reorder (review
|
|
607
|
+
# 2026-07-06, finding 8); a first-entry-wins map is also what the
|
|
608
|
+
# expression check reads.
|
|
609
|
+
ref_vars_by_name = {}
|
|
610
|
+
for v in reference.get("variables", []):
|
|
611
|
+
rn = v.get("name", "")
|
|
612
|
+
if rn and rn not in ref_vars_by_name:
|
|
613
|
+
ref_vars_by_name[rn] = v
|
|
614
|
+
shared_ref_time = reference.get("time")
|
|
615
|
+
if shared_ref_time is not None:
|
|
616
|
+
shared_ref_time = np.array(shared_ref_time)
|
|
617
|
+
comparisons = []
|
|
618
|
+
|
|
619
|
+
ref_comparison = reference.get("comparison", {})
|
|
620
|
+
base_tolerance, tolerance_source = _base_tolerance(
|
|
621
|
+
test, reference, default_tolerance
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
# Phase 3.3: when the spec provides an explicit MetricTree, it fully
|
|
625
|
+
# replaces the implicit flat-AND + per-variable overrides. The user's
|
|
626
|
+
# tree declares *which* variables participate and *how* each is scored,
|
|
627
|
+
# so the legacy ``comparison.variable_overrides`` is ignored on this
|
|
628
|
+
# path (documented — same fields move into each leaf's params).
|
|
629
|
+
if test.metric_tree_spec is not None:
|
|
630
|
+
from ..storage.reference_store import _extract_baselines
|
|
631
|
+
from .tree_eval import BaselineView, ScalarSources
|
|
632
|
+
|
|
633
|
+
var_results_by_name = {v.name: v for v in result.variables if v.name}
|
|
634
|
+
# Load primary from the flat ref file; merge in soft_checks from the
|
|
635
|
+
# `soft_checks/ref_NNNN/` subdir when a store is supplied (D66). Leaves
|
|
636
|
+
# pick which baseline to score against via `leaf.against` (defaults
|
|
637
|
+
# to primary). With no store there are no named baselines, so an
|
|
638
|
+
# `against: <name>` leaf gets the missing-baseline sentinel.
|
|
639
|
+
all_baselines = _extract_baselines(reference)
|
|
640
|
+
if store is not None:
|
|
641
|
+
all_baselines.update(store.get_soft_checks(test.model_id))
|
|
642
|
+
baselines: dict[str, BaselineView] = {}
|
|
643
|
+
for name, bl in all_baselines.items():
|
|
644
|
+
refs_by_name: dict[str, dict] = {}
|
|
645
|
+
for rv in bl.variables:
|
|
646
|
+
rn = rv.get("name", "")
|
|
647
|
+
if rn and rn not in refs_by_name:
|
|
648
|
+
refs_by_name[rn] = rv
|
|
649
|
+
bl_time = np.array(bl.time) if bl.time else None
|
|
650
|
+
baselines[name] = BaselineView(
|
|
651
|
+
name=name,
|
|
652
|
+
ref_vars_by_name=refs_by_name,
|
|
653
|
+
shared_ref_time=bl_time,
|
|
654
|
+
statistics=bl.statistics,
|
|
655
|
+
diagnostics=bl.diagnostics,
|
|
656
|
+
)
|
|
657
|
+
structural_warnings += _named_baseline_expression_warnings(
|
|
658
|
+
test.metric_tree_spec, all_baselines, result
|
|
659
|
+
)
|
|
660
|
+
tree = evaluate_spec(
|
|
661
|
+
test.metric_tree_spec,
|
|
662
|
+
var_results_by_name,
|
|
663
|
+
baselines,
|
|
664
|
+
base_tolerance,
|
|
665
|
+
dataset_kind,
|
|
666
|
+
# Stored scalars for `scalar` leaves reading a statistics /
|
|
667
|
+
# diagnostics path instead of a tracked series.
|
|
668
|
+
ScalarSources(
|
|
669
|
+
statistics=result.statistics or {},
|
|
670
|
+
diagnostics=result.diagnostics,
|
|
671
|
+
),
|
|
672
|
+
model_id=test.model_id,
|
|
673
|
+
)
|
|
674
|
+
unscored = _tracked_but_not_scored(test, result)
|
|
675
|
+
return TestComparison(
|
|
676
|
+
model_id=test.model_id,
|
|
677
|
+
passed=tree.passed,
|
|
678
|
+
test_id=ref_test_id,
|
|
679
|
+
variables=collect_leaf_variables(tree),
|
|
680
|
+
warnings=structural_warnings + ([unscored] if unscored else []),
|
|
681
|
+
metric_tree=tree,
|
|
682
|
+
tolerance_source=tolerance_source,
|
|
683
|
+
base_tolerance=base_tolerance,
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
# Merge variable overrides: spec overrides take precedence over reference
|
|
687
|
+
ref_var_overrides = ref_comparison.get("variable_overrides", {})
|
|
688
|
+
merged_overrides = {**ref_var_overrides, **test.variable_overrides}
|
|
689
|
+
_warn_about_overrides_that_name_nothing(
|
|
690
|
+
test.model_id, test.variable_overrides, ref_var_overrides, result
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
tracked = tracked_results(test, result)
|
|
694
|
+
#: Per tracked variable, the baseline its criterion reads (None: none).
|
|
695
|
+
baselines_read: list[str | None] = []
|
|
696
|
+
for var_result in tracked:
|
|
697
|
+
# A variable is paired with the baseline entry carrying its name.
|
|
698
|
+
# There is no index fallback: a result variable always has the name
|
|
699
|
+
# its result file gives it (a nameless one is a backend bug), and an
|
|
700
|
+
# entry that merely sits at the same index is a different quantity
|
|
701
|
+
# after any reorder. A baseline that has none says so once per
|
|
702
|
+
# variable, with the name to store it under when it is the
|
|
703
|
+
# un-migrated shape rather than a variable the baseline never held.
|
|
704
|
+
ref_var = ref_vars_by_name.get(var_result.name)
|
|
705
|
+
if ref_var is None and reference.get("variables"):
|
|
706
|
+
logger.warning(
|
|
707
|
+
"%s: the baseline holds no variable named %r — treating as "
|
|
708
|
+
"missing baseline (re-accept to refresh)%s",
|
|
709
|
+
test.model_id,
|
|
710
|
+
var_result.name,
|
|
711
|
+
migration_hint(
|
|
712
|
+
ref_vars_by_name, var_result.name, var_result.expression
|
|
713
|
+
),
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
# A variable is scored and overridden under the name its result file
|
|
717
|
+
# gives it; the expression beside it is a label and no key of any
|
|
718
|
+
# kind. Resolved BEFORE the ref-missing check so baseline-free modes
|
|
719
|
+
# still find their overrides.
|
|
720
|
+
name = var_result.name
|
|
721
|
+
|
|
722
|
+
var_override = merged_overrides.get(name, {})
|
|
723
|
+
# The metric this criterion is scored by — or, where its config is
|
|
724
|
+
# an error, could not be: what a sentinel below is named by.
|
|
725
|
+
metric_name = override_metric(var_override, default_points)
|
|
726
|
+
# The baseline it reads: none until its mode is built, and none for
|
|
727
|
+
# a mode that scores from its own config.
|
|
728
|
+
baselines_read.append(None)
|
|
729
|
+
try:
|
|
730
|
+
tolerance = parse_sides(
|
|
731
|
+
var_override.get("tolerance", base_tolerance), "tolerance"
|
|
732
|
+
)
|
|
733
|
+
mode = resolve_mode(var_override, tolerance, default_points=default_points)
|
|
734
|
+
except ValueError as exc:
|
|
735
|
+
comparisons.append(_config_error_comparison(var_result, exc, metric_name))
|
|
736
|
+
continue
|
|
737
|
+
if not mode.is_baseline_free():
|
|
738
|
+
baselines_read[-1] = PRIMARY_BASELINE
|
|
739
|
+
|
|
740
|
+
# A stats-path quantity is resolved by the tree evaluator from the
|
|
741
|
+
# run's statistics/diagnostics; this flat path only has the
|
|
742
|
+
# variable's own trajectory, and ScalarMode would silently reduce
|
|
743
|
+
# THAT instead (review finding, D106).
|
|
744
|
+
desc = registry_metric(mode.name)
|
|
745
|
+
if (
|
|
746
|
+
desc is not None
|
|
747
|
+
and desc.accepts_quantity
|
|
748
|
+
and isinstance(var_override.get("quantity"), str)
|
|
749
|
+
):
|
|
750
|
+
comparisons.append(
|
|
751
|
+
VariableComparison(
|
|
752
|
+
index=var_result.index,
|
|
753
|
+
name=name,
|
|
754
|
+
passed=False,
|
|
755
|
+
nrmse=float("inf"),
|
|
756
|
+
rmse=float("inf"),
|
|
757
|
+
signal_range=0.0,
|
|
758
|
+
max_abs_error=float("inf"),
|
|
759
|
+
max_abs_error_time=0.0,
|
|
760
|
+
reference_final=float("nan"),
|
|
761
|
+
actual_final=float("nan"),
|
|
762
|
+
mode=mode.name,
|
|
763
|
+
# As on the no-baseline comparison below: the one it
|
|
764
|
+
# would have applied, not the default 0.0.
|
|
765
|
+
tolerance_used=tolerance,
|
|
766
|
+
diagnostics={
|
|
767
|
+
"error": (
|
|
768
|
+
f"quantity {var_override['quantity']!r}: a stats-path "
|
|
769
|
+
"quantity needs a `metrics` tree; variable_overrides "
|
|
770
|
+
"can only reduce the variable's own trajectory"
|
|
771
|
+
)
|
|
772
|
+
},
|
|
773
|
+
)
|
|
774
|
+
)
|
|
775
|
+
continue
|
|
776
|
+
|
|
777
|
+
if ref_var is None:
|
|
778
|
+
# Baseline-free modes (range; event-timing with declared events;
|
|
779
|
+
# dominant-frequency with declared peaks) don't consult the
|
|
780
|
+
# reference, so a missing ref_var is fine — run the comparison
|
|
781
|
+
# with empty ref arrays. Other modes still hard-fail here
|
|
782
|
+
# because they can't score without a baseline.
|
|
783
|
+
if mode.is_baseline_free():
|
|
784
|
+
empty = np.array([])
|
|
785
|
+
try:
|
|
786
|
+
vc = mode.compare(empty, empty, var_result.time, var_result.values)
|
|
787
|
+
except ValueError as exc:
|
|
788
|
+
comparisons.append(
|
|
789
|
+
_config_error_comparison(var_result, exc, metric_name)
|
|
790
|
+
)
|
|
791
|
+
continue
|
|
792
|
+
vc.index = var_result.index
|
|
793
|
+
vc.name = name
|
|
794
|
+
vc.tolerance_used = tolerance
|
|
795
|
+
comparisons.append(vc)
|
|
796
|
+
continue
|
|
797
|
+
comparisons.append(
|
|
798
|
+
VariableComparison(
|
|
799
|
+
index=var_result.index,
|
|
800
|
+
name=name,
|
|
801
|
+
passed=False,
|
|
802
|
+
nrmse=float("inf"),
|
|
803
|
+
rmse=float("inf"),
|
|
804
|
+
signal_range=0.0,
|
|
805
|
+
max_abs_error=float("inf"),
|
|
806
|
+
max_abs_error_time=0.0,
|
|
807
|
+
reference_final=float("nan"),
|
|
808
|
+
actual_final=float(var_result.values[-1])
|
|
809
|
+
if len(var_result.values) > 0
|
|
810
|
+
else float("nan"),
|
|
811
|
+
# The tolerance it would have applied, not the
|
|
812
|
+
# default 0.0.
|
|
813
|
+
tolerance_used=tolerance,
|
|
814
|
+
)
|
|
815
|
+
)
|
|
816
|
+
continue
|
|
817
|
+
|
|
818
|
+
if shared_ref_time is not None:
|
|
819
|
+
ref_time = shared_ref_time
|
|
820
|
+
else:
|
|
821
|
+
ref_time = np.array(ref_var["time"])
|
|
822
|
+
ref_values = np.array(ref_var["values"])
|
|
823
|
+
|
|
824
|
+
try:
|
|
825
|
+
vc = mode.compare(ref_time, ref_values, var_result.time, var_result.values)
|
|
826
|
+
except ValueError as exc:
|
|
827
|
+
comparisons.append(_config_error_comparison(var_result, exc, metric_name))
|
|
828
|
+
# A criterion whose config is an error read nothing, whenever
|
|
829
|
+
# the error was found.
|
|
830
|
+
baselines_read[-1] = None
|
|
831
|
+
continue
|
|
832
|
+
vc.index = var_result.index
|
|
833
|
+
vc.name = name
|
|
834
|
+
vc.tolerance_used = tolerance
|
|
835
|
+
comparisons.append(vc)
|
|
836
|
+
|
|
837
|
+
# Phase 3.1: pass/fail flows from the MetricTree root, not a separate
|
|
838
|
+
# accumulator. Today the tree is the implicit flat-AND of per-variable
|
|
839
|
+
# leaves — same semantics as before. Phase 3.3+ swaps in user-authored
|
|
840
|
+
# trees from test_spec.json.
|
|
841
|
+
#
|
|
842
|
+
# The loop appends exactly one comparison per tracked variable, in
|
|
843
|
+
# order, so the expression — and the baseline the criterion read —
|
|
844
|
+
# rides onto each of them here rather than at all five of the loop's
|
|
845
|
+
# append sites.
|
|
846
|
+
for vc, var_result, read in zip(comparisons, tracked, baselines_read, strict=True):
|
|
847
|
+
vc.expression = var_result.expression
|
|
848
|
+
vc.against = read
|
|
849
|
+
comparisons += _lost_variable_comparisons(
|
|
850
|
+
test,
|
|
851
|
+
result,
|
|
852
|
+
ref_vars_by_name,
|
|
853
|
+
merged_overrides,
|
|
854
|
+
base_tolerance,
|
|
855
|
+
default_points,
|
|
856
|
+
)
|
|
857
|
+
tree = implicit_and_tree(comparisons)
|
|
858
|
+
|
|
859
|
+
return TestComparison(
|
|
860
|
+
model_id=test.model_id,
|
|
861
|
+
passed=tree.passed,
|
|
862
|
+
test_id=ref_test_id,
|
|
863
|
+
variables=comparisons,
|
|
864
|
+
warnings=structural_warnings,
|
|
865
|
+
metric_tree=tree,
|
|
866
|
+
tolerance_source=tolerance_source,
|
|
867
|
+
base_tolerance=base_tolerance,
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _lost_variable_comparisons(
|
|
872
|
+
test: TestModel,
|
|
873
|
+
result: TestResult,
|
|
874
|
+
ref_vars_by_name: Mapping[str, dict],
|
|
875
|
+
overrides: Mapping[str, dict],
|
|
876
|
+
base_tolerance: float,
|
|
877
|
+
default_points: bool,
|
|
878
|
+
) -> list[VariableComparison]:
|
|
879
|
+
"""The flat path's criteria that could not be scored because the RUN
|
|
880
|
+
lost their variable: one per variable the test tracks
|
|
881
|
+
(``result_variables.tracks``) that the baseline holds and the result
|
|
882
|
+
does not, in the baseline's order — the sentinel a tree's leaf gives it
|
|
883
|
+
(``variable 'y' not found in the result``), and the test fails.
|
|
884
|
+
|
|
885
|
+
The flat path scores one criterion per tracked variable the result
|
|
886
|
+
holds, so a run that lost ``y`` of {x, y} was scored on ``x`` alone:
|
|
887
|
+
PASS, Criteria 1/1 and no word, where the same loss under a ``metrics``
|
|
888
|
+
tree fails — and an accept then wrote a baseline without ``y``.
|
|
889
|
+
|
|
890
|
+
Only what the BASELINE holds: it is what says the variable was there.
|
|
891
|
+
A pattern that matches nothing on either side stays the backend's log
|
|
892
|
+
line, and a baseline variable the test no longer tracks is not asked
|
|
893
|
+
about. The sentinel carries what its criterion would have: the
|
|
894
|
+
override's metric and tolerance, the baseline's expression (the run
|
|
895
|
+
has none to give) and the baseline it would have read."""
|
|
896
|
+
from .modes import override_metric, resolve_mode
|
|
897
|
+
from .tree_eval import _missing_variable_comparison
|
|
898
|
+
|
|
899
|
+
held = {v.name for v in result.variables}
|
|
900
|
+
tracked = tracks(test)
|
|
901
|
+
lost: list[VariableComparison] = []
|
|
902
|
+
for name, entry in ref_vars_by_name.items():
|
|
903
|
+
if name in held or not tracked(name):
|
|
904
|
+
continue
|
|
905
|
+
vc = _missing_variable_comparison(name, None)
|
|
906
|
+
vc.expression = entry.get("expression") or ""
|
|
907
|
+
override = overrides.get(name, {})
|
|
908
|
+
metric_name = override_metric(override, default_points)
|
|
909
|
+
try:
|
|
910
|
+
tolerance = parse_sides(
|
|
911
|
+
override.get("tolerance", base_tolerance), "tolerance"
|
|
912
|
+
)
|
|
913
|
+
mode = resolve_mode(override, tolerance, default_points=default_points)
|
|
914
|
+
except ValueError:
|
|
915
|
+
# Its config is an error too; the loss is what is said.
|
|
916
|
+
vc.mode = metric_name if registry_metric(metric_name) is not None else ""
|
|
917
|
+
else:
|
|
918
|
+
vc.mode = mode.name
|
|
919
|
+
vc.tolerance_used = tolerance
|
|
920
|
+
vc.against = None if mode.is_baseline_free() else PRIMARY_BASELINE
|
|
921
|
+
lost.append(vc)
|
|
922
|
+
return lost
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _config_error_comparison(
|
|
926
|
+
var_result: VariableResult, exc: ValueError, metric_name: str
|
|
927
|
+
) -> VariableComparison:
|
|
928
|
+
"""The flat path's sentinel for a variable whose override is a config
|
|
929
|
+
error: the variable fails, the others are scored — as a leaf's does on
|
|
930
|
+
the tree path (D107), where one bad spec entry must not abort the run.
|
|
931
|
+
|
|
932
|
+
A ``ValueError`` is that error wherever it is raised: building the mode
|
|
933
|
+
(a tolerance that is neither a number nor a lower/upper pair; an
|
|
934
|
+
unrecognized mode string, which must fail loudly — silently falling
|
|
935
|
+
back to NRMSE changed the scoring algorithm on a typo, review
|
|
936
|
+
2026-07-06) or scoring with it, for what a mode can only detect then (a
|
|
937
|
+
tube's unknown width mode, `abs` without control points).
|
|
938
|
+
|
|
939
|
+
``mode`` is ``metric_name`` — the metric the override is scored by
|
|
940
|
+
(``modes.override_metric``) — as the tree path's sentinel carries its
|
|
941
|
+
leaf's: a ``{"mode": "range"}`` that could not be built is a `range`
|
|
942
|
+
criterion on every surface, and was `x: nrmse` on the console beside
|
|
943
|
+
``"metric": "range"`` in verdicts.json. A name nothing is registered
|
|
944
|
+
under is no metric: that one sentinel has ``mode=""`` (never the
|
|
945
|
+
"nrmse" field default, which it was not scored by — and never the
|
|
946
|
+
offending string, which would go to every lookup by mode); what was
|
|
947
|
+
written rides in the error text."""
|
|
948
|
+
return VariableComparison(
|
|
949
|
+
index=var_result.index,
|
|
950
|
+
name=var_result.name,
|
|
951
|
+
passed=False,
|
|
952
|
+
nrmse=float("inf"),
|
|
953
|
+
rmse=float("inf"),
|
|
954
|
+
signal_range=0.0,
|
|
955
|
+
max_abs_error=float("inf"),
|
|
956
|
+
max_abs_error_time=0.0,
|
|
957
|
+
reference_final=float("nan"),
|
|
958
|
+
actual_final=float("nan"),
|
|
959
|
+
mode=metric_name if registry_metric(metric_name) is not None else "",
|
|
960
|
+
diagnostics={"error": str(exc), "config_error": True},
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _test_is_baseline_free(
|
|
965
|
+
test: TestModel,
|
|
966
|
+
default_tolerance: float,
|
|
967
|
+
) -> bool:
|
|
968
|
+
"""Does every leaf in ``test``'s metric tree score without a reference?
|
|
969
|
+
|
|
970
|
+
Baseline-free modes (``range``; ``event-timing`` with declared events;
|
|
971
|
+
``dominant-frequency`` with declared peaks) compute pass/fail using
|
|
972
|
+
only the actual simulation data plus config declared in the leaf
|
|
973
|
+
itself. When every leaf is baseline-free, ``compare_all`` can skip
|
|
974
|
+
the NO_REF short-circuit and still produce meaningful pass/fail on a
|
|
975
|
+
fresh test with no saved baseline (idea #59 / D83).
|
|
976
|
+
|
|
977
|
+
Mixed trees — any leaf that consults a reference — fall back to the
|
|
978
|
+
legacy NO_REF behavior. There's no partial-run support.
|
|
979
|
+
"""
|
|
980
|
+
from .modes import resolve_mode
|
|
981
|
+
from .tree_eval import _leaf_override_dict
|
|
982
|
+
from .tree_spec import LeafSpec
|
|
983
|
+
|
|
984
|
+
# User-authored spec tree: walk every leaf and resolve its mode.
|
|
985
|
+
if test.metric_tree_spec is not None:
|
|
986
|
+
leaves: list[LeafSpec] = []
|
|
987
|
+
|
|
988
|
+
def _collect(node):
|
|
989
|
+
if isinstance(node, LeafSpec):
|
|
990
|
+
leaves.append(node)
|
|
991
|
+
return
|
|
992
|
+
for c in node.children:
|
|
993
|
+
_collect(c)
|
|
994
|
+
|
|
995
|
+
_collect(test.metric_tree_spec)
|
|
996
|
+
if not leaves:
|
|
997
|
+
return False
|
|
998
|
+
for leaf in leaves:
|
|
999
|
+
override = _leaf_override_dict(leaf)
|
|
1000
|
+
try:
|
|
1001
|
+
tol = parse_sides(
|
|
1002
|
+
leaf.params.get("tolerance", default_tolerance), "tolerance"
|
|
1003
|
+
)
|
|
1004
|
+
mode = resolve_mode(override, tol, default_points=False)
|
|
1005
|
+
except Exception:
|
|
1006
|
+
# A leaf whose config doesn't resolve to a valid mode — a
|
|
1007
|
+
# malformed tolerance included — can't be baseline-free by
|
|
1008
|
+
# our check — fall back to NO_REF.
|
|
1009
|
+
return False
|
|
1010
|
+
if not mode.is_baseline_free():
|
|
1011
|
+
return False
|
|
1012
|
+
return True
|
|
1013
|
+
|
|
1014
|
+
# Implicit-AND path: every tracked variable becomes a leaf. A test
|
|
1015
|
+
# without any per-variable override defaults to NRMSE, which needs a
|
|
1016
|
+
# reference — only overrides that select a baseline-free mode
|
|
1017
|
+
# qualify. An empty override dict means "no leaves that can qualify",
|
|
1018
|
+
# so treat the test as not baseline-free (stay with NO_REF).
|
|
1019
|
+
if not test.variable_overrides:
|
|
1020
|
+
return False
|
|
1021
|
+
# Mixed tests: any tracked variable WITHOUT an override becomes an
|
|
1022
|
+
# implicit NRMSE leaf, which needs a baseline — so the test as a
|
|
1023
|
+
# whole is not baseline-free (review 2026-07-06, finding 10; matches
|
|
1024
|
+
# the docstring's "mixed trees fall back to NO_REF"). Glob patterns
|
|
1025
|
+
# can't be proven covered, so they also disqualify.
|
|
1026
|
+
#
|
|
1027
|
+
# Both sources are tracked, not one or the other: a ``both`` test whose
|
|
1028
|
+
# spec patterns are all covered still has the component's variables
|
|
1029
|
+
# scoring nrmse against a baseline.
|
|
1030
|
+
tracked = list(test.variable_patterns) + [v.name for v in unit_test_variables(test)]
|
|
1031
|
+
if tracked and set(tracked) - set(test.variable_overrides):
|
|
1032
|
+
return False
|
|
1033
|
+
base_tolerance = (
|
|
1034
|
+
test.comparison_tolerance
|
|
1035
|
+
if test.comparison_tolerance is not None
|
|
1036
|
+
else default_tolerance
|
|
1037
|
+
)
|
|
1038
|
+
for override in test.variable_overrides.values():
|
|
1039
|
+
try:
|
|
1040
|
+
tol = parse_sides(override.get("tolerance", base_tolerance), "tolerance")
|
|
1041
|
+
mode = resolve_mode(override, tol, default_points=False)
|
|
1042
|
+
except Exception:
|
|
1043
|
+
return False
|
|
1044
|
+
if not mode.is_baseline_free():
|
|
1045
|
+
return False
|
|
1046
|
+
return True
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def compare_one(
|
|
1050
|
+
test: TestModel,
|
|
1051
|
+
result: TestResult | None,
|
|
1052
|
+
store: ReferenceStore,
|
|
1053
|
+
default_tolerance: float = DEFAULT_TOLERANCE,
|
|
1054
|
+
default_points: bool = False,
|
|
1055
|
+
dataset_kind: DatasetType = DatasetType.TIME_SERIES,
|
|
1056
|
+
) -> TestComparison:
|
|
1057
|
+
"""One test's comparison, short-circuits included: no result is NOT RUN
|
|
1058
|
+
(``ran=False``, D145) and a failed simulation SIM_FAIL; no baseline is
|
|
1059
|
+
NO_REF unless the test is simulate-only or every leaf scores without a
|
|
1060
|
+
reference. ``compare_all`` is this per test; the authoring Session's
|
|
1061
|
+
rescore is this for one."""
|
|
1062
|
+
if result is None:
|
|
1063
|
+
# No result exists: the reader hands back none for a test with no
|
|
1064
|
+
# manifest entry or one its last run never finished
|
|
1065
|
+
# (`Backend.read_results`). Nothing ran, so nothing failed.
|
|
1066
|
+
return TestComparison(
|
|
1067
|
+
model_id=test.model_id,
|
|
1068
|
+
passed=False,
|
|
1069
|
+
sim_success=False,
|
|
1070
|
+
ran=False,
|
|
1071
|
+
error_message="No simulation results found",
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
# A simulation that FAILED is SIM_FAIL regardless of whether a baseline
|
|
1075
|
+
# exists — this check MUST precede the no-baseline short-circuit below.
|
|
1076
|
+
# A backend signals a failed run with a success=False TestResult: the
|
|
1077
|
+
# reader builds one from a failed run result, and OpenModelica's
|
|
1078
|
+
# read_result returns one for a missing .mat. Before this check it
|
|
1079
|
+
# fell through to the no-baseline branch and was
|
|
1080
|
+
# mislabeled NO_REF with passed=True. Surfaced 2026-07-07 across 91
|
|
1081
|
+
# OM/TRANSFORM compile/solver failures on models that never got OM
|
|
1082
|
+
# baselines (mirrors compare_test's own `not result.success` guard).
|
|
1083
|
+
if not result.success:
|
|
1084
|
+
return TestComparison(
|
|
1085
|
+
model_id=test.model_id,
|
|
1086
|
+
passed=False,
|
|
1087
|
+
sim_success=False,
|
|
1088
|
+
error_message=result.error_message or "Simulation failed",
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
reference = store.get_reference(test.model_id)
|
|
1092
|
+
if reference is not None:
|
|
1093
|
+
# A stored override nothing reads: this test is refused, with the
|
|
1094
|
+
# sentence naming its reference file. A command has stopped on it
|
|
1095
|
+
# before it got here (`refuse_stored_overrides`).
|
|
1096
|
+
unread = stored_override_problem(reference, store, test.model_id)
|
|
1097
|
+
if unread is not None:
|
|
1098
|
+
return TestComparison(
|
|
1099
|
+
model_id=test.model_id,
|
|
1100
|
+
passed=False,
|
|
1101
|
+
test_id=reference.get("test_id"),
|
|
1102
|
+
has_reference=True,
|
|
1103
|
+
error_message=unread,
|
|
1104
|
+
)
|
|
1105
|
+
# The base tolerance is judged HERE, before the test is anything else: a
|
|
1106
|
+
# test with no baseline is not NO_REF over a base no accept may store.
|
|
1107
|
+
refused = refused_base_tolerance(test, reference, default_tolerance)
|
|
1108
|
+
if refused is not None:
|
|
1109
|
+
return refused
|
|
1110
|
+
# Nor is it NO_REF — "accept it" — when its result holds nothing it
|
|
1111
|
+
# tracks: no accept can give such a test a criterion.
|
|
1112
|
+
nothing = nothing_scored(test, result)
|
|
1113
|
+
if nothing is not None:
|
|
1114
|
+
return TestComparison(
|
|
1115
|
+
model_id=test.model_id,
|
|
1116
|
+
passed=False,
|
|
1117
|
+
test_id=(reference or {}).get("test_id"),
|
|
1118
|
+
has_reference=reference is not None,
|
|
1119
|
+
error_message=nothing,
|
|
1120
|
+
)
|
|
1121
|
+
# simulate_only tests don't need a baseline — their pass criterion
|
|
1122
|
+
# is "did it simulate successfully?". Dispatch to compare_test with
|
|
1123
|
+
# an empty dict reference so its simulate_only short-circuit runs
|
|
1124
|
+
# (returning passed=True + metric_tree with label="simulate-only")
|
|
1125
|
+
# instead of collapsing to the generic NO_REF state.
|
|
1126
|
+
#
|
|
1127
|
+
# Baseline-free tests (every leaf is range / declared-events /
|
|
1128
|
+
# declared-peaks) follow the same pattern: skip the NO_REF guard
|
|
1129
|
+
# so the scorers can produce real pass/fail from config alone.
|
|
1130
|
+
# Mixed trees still short-circuit — partial runs aren't supported
|
|
1131
|
+
# by this fix (idea #59 / D83).
|
|
1132
|
+
if (
|
|
1133
|
+
reference is None
|
|
1134
|
+
and not test.is_simulate_only
|
|
1135
|
+
and not _test_is_baseline_free(test, default_tolerance)
|
|
1136
|
+
):
|
|
1137
|
+
return TestComparison(
|
|
1138
|
+
model_id=test.model_id,
|
|
1139
|
+
passed=True,
|
|
1140
|
+
has_reference=False,
|
|
1141
|
+
error_message="No reference baseline stored",
|
|
1142
|
+
)
|
|
1143
|
+
|
|
1144
|
+
comp = compare_test(
|
|
1145
|
+
test,
|
|
1146
|
+
result,
|
|
1147
|
+
reference if reference is not None else {},
|
|
1148
|
+
default_tolerance=default_tolerance,
|
|
1149
|
+
default_points=default_points,
|
|
1150
|
+
store=store,
|
|
1151
|
+
dataset_kind=dataset_kind,
|
|
1152
|
+
)
|
|
1153
|
+
if reference is None:
|
|
1154
|
+
# Record that no baseline was stored even though the
|
|
1155
|
+
# simulate_only / baseline-free path produced a result.
|
|
1156
|
+
# Downstream renderers use this together with the metric
|
|
1157
|
+
# tree's pass/fail to decide how to render the badge.
|
|
1158
|
+
comp.has_reference = False
|
|
1159
|
+
return comp
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def compare_all(
|
|
1163
|
+
tests: list[TestModel],
|
|
1164
|
+
results: dict[str, TestResult],
|
|
1165
|
+
store: ReferenceStore,
|
|
1166
|
+
default_tolerance: float = DEFAULT_TOLERANCE,
|
|
1167
|
+
default_points: bool = False,
|
|
1168
|
+
dataset_kind: DatasetType = DatasetType.TIME_SERIES,
|
|
1169
|
+
) -> list[TestComparison]:
|
|
1170
|
+
"""Compare all test results against stored references.
|
|
1171
|
+
|
|
1172
|
+
``dataset_kind`` is what the backend that produced *results* declares it
|
|
1173
|
+
emits (see :func:`dstf.backends.dataset_kind_for`); it is forwarded to every
|
|
1174
|
+
:func:`compare_test`.
|
|
1175
|
+
"""
|
|
1176
|
+
import sys as _sys
|
|
1177
|
+
|
|
1178
|
+
print(f"Comparing {len(tests)} tests against references...", file=_sys.stderr)
|
|
1179
|
+
return [
|
|
1180
|
+
compare_one(
|
|
1181
|
+
test,
|
|
1182
|
+
results.get(test.model_id),
|
|
1183
|
+
store,
|
|
1184
|
+
default_tolerance=default_tolerance,
|
|
1185
|
+
default_points=default_points,
|
|
1186
|
+
dataset_kind=dataset_kind,
|
|
1187
|
+
)
|
|
1188
|
+
for test in tests
|
|
1189
|
+
]
|