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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+ ]