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
dstf/comparison/types.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Comparison-result dataclasses + shared constants.
|
|
2
|
+
|
|
3
|
+
Lives separately from :mod:`.comparator` (orchestration) and
|
|
4
|
+
:mod:`.algorithms` (per-mode math) so both can import the result types
|
|
5
|
+
without an import cycle. Re-exported from :mod:`.comparator` for
|
|
6
|
+
historical-import compatibility, but new code should prefer
|
|
7
|
+
``from dstf.comparison.types import VariableComparison``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from .geometry import Primitive
|
|
18
|
+
from .sides import Sides, parse_sides
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
# Annotation-only — runtime import is inside compare_test() in
|
|
22
|
+
# comparator.py to break the comparator <-> metric_tree cycle.
|
|
23
|
+
from .metric_tree import MetricResult
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DEFAULT_TOLERANCE = 1e-4
|
|
27
|
+
|
|
28
|
+
# Machine-epsilon guard — signals with range below this are treated as
|
|
29
|
+
# constant by the NRMSE path.
|
|
30
|
+
_EPS = 100 * np.finfo(np.float64).eps
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class VariableComparison:
|
|
35
|
+
"""Comparison result for a single tracked variable.
|
|
36
|
+
|
|
37
|
+
Conforms to the ``MetricResult`` contract in docs/extensibility.md:
|
|
38
|
+
carries a ``passed`` flag, a numeric score (``nrmse`` for NRMSE mode,
|
|
39
|
+
``tube_points_inside`` for tube mode), and a structured ``diagnostics``
|
|
40
|
+
bag for metric-specific extras (e.g. event-timing deltas, spectral
|
|
41
|
+
peaks) that future metrics may attach without widening the schema.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
index: int
|
|
45
|
+
name: str
|
|
46
|
+
passed: bool
|
|
47
|
+
nrmse: float # Normalized RMSE (pass/fail metric)
|
|
48
|
+
rmse: float # Raw RMSE
|
|
49
|
+
signal_range: float # max(ref) - min(ref), for context
|
|
50
|
+
max_abs_error: float # Largest absolute deviation
|
|
51
|
+
max_abs_error_time: float # Time at which it occurs
|
|
52
|
+
reference_final: float
|
|
53
|
+
actual_final: float
|
|
54
|
+
is_constant: bool = False # True if reference signal has zero range
|
|
55
|
+
# The tolerance applied for this variable, per side (a number is both).
|
|
56
|
+
tolerance_used: Sides = Sides(0.0, 0.0)
|
|
57
|
+
#: The metric this criterion was scored by — or, a config-error
|
|
58
|
+
#: sentinel's, could not be. THE one source of a criterion's metric name:
|
|
59
|
+
#: the console and JUnit blocks, the tree view and `verdicts.json` read
|
|
60
|
+
#: it and none substitutes a default. ``""`` only for a flat override
|
|
61
|
+
#: naming a mode nothing is registered under.
|
|
62
|
+
mode: str = "nrmse"
|
|
63
|
+
# Tube-specific metrics (populated when mode="tube")
|
|
64
|
+
tube_points_inside: float | None = None # Fraction of points inside tube (0-1)
|
|
65
|
+
tube_worst_violation: float | None = None # Largest violation (absolute)
|
|
66
|
+
tube_worst_violation_time: float | None = None # Time of worst violation
|
|
67
|
+
# Open-ended structured extras — future metrics attach here instead of
|
|
68
|
+
# growing this dataclass (event-timing, spectral, domain-specific scores).
|
|
69
|
+
diagnostics: dict = field(default_factory=dict)
|
|
70
|
+
#: The baseline this criterion READ — ``"primary"``, a soft check's name
|
|
71
|
+
#: — or ``None`` for one that reads none: a ``range``, a criterion with
|
|
72
|
+
#: declared events / peaks / values, one whose config could not be
|
|
73
|
+
#: built. Stamped by the comparator and the tree evaluator from the mode
|
|
74
|
+
#: they built (``is_baseline_free``); THE one source of the baseline a
|
|
75
|
+
#: surface names beside a criterion (the console's and JUnit's ``·
|
|
76
|
+
#: against primary``, the tree view's ``against``), on both paths.
|
|
77
|
+
against: str | None = None
|
|
78
|
+
# The scored variable's expression (see ``VariableResult.expression``): a
|
|
79
|
+
# label, never a key. Stamped by the comparator and the tree evaluator
|
|
80
|
+
# from the result variable the leaf scored; "" when there is none.
|
|
81
|
+
expression: str = ""
|
|
82
|
+
# What the mode scored against, as drawable data (see geometry.py).
|
|
83
|
+
# Ids are local until the evaluator prefixes them with the leaf path.
|
|
84
|
+
geometry: tuple[Primitive, ...] = ()
|
|
85
|
+
|
|
86
|
+
def __post_init__(self) -> None:
|
|
87
|
+
# A number handed in (a fixture, a third-party metric) is both sides.
|
|
88
|
+
self.tolerance_used = parse_sides(self.tolerance_used, "tolerance_used")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class ScoreSummary:
|
|
93
|
+
"""One leaf's verdict in words: the pill text and the criterion sentence.
|
|
94
|
+
|
|
95
|
+
Produced per mode by :mod:`.summaries` and dispatched through the
|
|
96
|
+
metric descriptor, so the report's tree view and the terminal review
|
|
97
|
+
loop read the same mode-aware text.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
display: str
|
|
101
|
+
criterion: str
|
|
102
|
+
score: float | None = None
|
|
103
|
+
score_label: str = ""
|
|
104
|
+
#: The mode's own key/value pairs behind the verdict — the largest error
|
|
105
|
+
#: and where, the finals, the worst violation, the failed checkpoints.
|
|
106
|
+
#: Each mode names only quantities that mean something for it: the
|
|
107
|
+
#: shared numeric fields are overloaded on some modes (a range leaf's
|
|
108
|
+
#: ``nrmse`` holds its worst violation), so no consumer prints them
|
|
109
|
+
#: under a generic label.
|
|
110
|
+
details: tuple[tuple[str, str], ...] = ()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class StructuralWarning:
|
|
115
|
+
"""A warning beside a test's verdict, in one of two forms.
|
|
116
|
+
|
|
117
|
+
A CHANGE between the baseline and this run: ``field`` with
|
|
118
|
+
``reference_value`` and ``current_value`` — ``Event count: 3 -> 5``.
|
|
119
|
+
|
|
120
|
+
A STATEMENT about this run alone: ``field`` and ``statement`` —
|
|
121
|
+
``Tracked but not scored by any criterion: unitTests.x[2]``. It has no
|
|
122
|
+
baseline side; laid out as a change (``1 scored by the tree ->
|
|
123
|
+
unitTests.x[2]``) it read as one between two runs, and backwards.
|
|
124
|
+
|
|
125
|
+
A surface prints :attr:`text`, or asks :attr:`is_change` before it
|
|
126
|
+
lays the two values out in columns.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
field: str
|
|
130
|
+
reference_value: str = ""
|
|
131
|
+
current_value: str = ""
|
|
132
|
+
statement: str = ""
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def is_change(self) -> bool:
|
|
136
|
+
return not self.statement
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def text(self) -> str:
|
|
140
|
+
if self.statement:
|
|
141
|
+
return f"{self.field}: {self.statement}"
|
|
142
|
+
return f"{self.field}: {self.reference_value} -> {self.current_value}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class TestComparison:
|
|
147
|
+
"""Comparison result for a full test model."""
|
|
148
|
+
|
|
149
|
+
model_id: str
|
|
150
|
+
passed: bool
|
|
151
|
+
test_id: str | None = None # ref file ID (e.g., "0001")
|
|
152
|
+
variables: list[VariableComparison] = field(default_factory=list)
|
|
153
|
+
warnings: list[StructuralWarning] = field(default_factory=list)
|
|
154
|
+
error_message: str | None = None
|
|
155
|
+
sim_success: bool = True
|
|
156
|
+
has_reference: bool = True
|
|
157
|
+
# PTA.5 — this test's pass criterion is "the simulation ran"; no
|
|
158
|
+
# baseline is consulted and ``variables`` is empty by construction.
|
|
159
|
+
# Carried on the comparison (not just on the TestModel) so every
|
|
160
|
+
# report surface can classify it from the comparison alone —
|
|
161
|
+
# reporting.status is the one classifier, and this is the input it
|
|
162
|
+
# used to be missing (D117).
|
|
163
|
+
simulate_only: bool = False
|
|
164
|
+
#: False when there is no result to compare (D145): the last run never
|
|
165
|
+
#: ran this test, or never finished it. ``reporting.status`` reads it
|
|
166
|
+
#: first — NOT RUN, which is not a failure. ``sim_success`` is False
|
|
167
|
+
#: beside it: nothing succeeded.
|
|
168
|
+
ran: bool = True
|
|
169
|
+
# Phase 3.1: MetricTree root for this test. Today populated as the
|
|
170
|
+
# implicit flat-AND over per-variable comparisons (matches `passed`
|
|
171
|
+
# exactly). Phase 3.2+ replaces with user-authored trees from
|
|
172
|
+
# test_spec.json. None when no comparison ran (sim failure, no baseline).
|
|
173
|
+
metric_tree: MetricResult | None = None
|
|
174
|
+
#: Where the tolerance a leaf without its own was scored with came from:
|
|
175
|
+
#: ``"test"`` (the spec's ``comparison.tolerance``), ``"reference"`` (the
|
|
176
|
+
#: baseline JSON's) or ``"project"`` (``Config.tolerance``: the CLI's,
|
|
177
|
+
#: else testing.json's, else 1e-4 — the comparator is handed one number
|
|
178
|
+
#: and cannot tell those apart). None where nothing was scored.
|
|
179
|
+
tolerance_source: str | None = None
|
|
180
|
+
#: That base tolerance: what a leaf without its own is scored with.
|
|
181
|
+
base_tolerance: float | None = None
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def evaluated(self) -> bool:
|
|
185
|
+
"""True when a comparison actually ran for this test.
|
|
186
|
+
|
|
187
|
+
Distinguishes a baseline-free or simulate-only test (which has a
|
|
188
|
+
metric tree / variables and a real verdict despite
|
|
189
|
+
``has_reference=False``) from the NO_REF short-circuit where
|
|
190
|
+
nothing was scored. Report surfaces must classify ``not passed``
|
|
191
|
+
as FAILED whenever ``evaluated`` is true — review 2026-07-06,
|
|
192
|
+
finding 5 (failing baseline-free tests exited 0 as "NO_REF").
|
|
193
|
+
"""
|
|
194
|
+
return self.metric_tree is not None or bool(self.variables)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Metric-tree validator enforcing D66 baseline-role rules.
|
|
2
|
+
|
|
3
|
+
The comparator's MetricTree distinguishes three baseline roles (D66):
|
|
4
|
+
**primary** = regression anchor; **soft_check** = advisory cross-check;
|
|
5
|
+
**companion** = plot-only overlay. This module enforces that trees
|
|
6
|
+
respect those semantics at spec-load time:
|
|
7
|
+
|
|
8
|
+
1. Every tree has at least one leaf targeting ``primary`` outside
|
|
9
|
+
``warn`` — guarantees a real regression anchor.
|
|
10
|
+
2. Leaves with ``against: "<soft_check>"`` must sit under a ``warn``
|
|
11
|
+
ancestor — soft_checks never hard-fail.
|
|
12
|
+
3. Leaves must never target companion names — companions are not
|
|
13
|
+
scorable, only displayable.
|
|
14
|
+
4. Leaves must not target unknown baseline names.
|
|
15
|
+
|
|
16
|
+
Callers supply a ``BaselineRegistry`` (a mapping of known names to
|
|
17
|
+
their role) so validation is context-aware per test.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Callable, Sequence
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from enum import Enum
|
|
25
|
+
|
|
26
|
+
from .tree_spec import CombinatorSpec, LeafSpec, SpecNode
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BaselineRole(Enum):
|
|
30
|
+
PRIMARY = "primary"
|
|
31
|
+
SOFT_CHECK = "soft_check"
|
|
32
|
+
COMPANION = "companion"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ValidationError:
|
|
37
|
+
path: str
|
|
38
|
+
message: str
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
return f"{self.path}: {self.message}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
BaselineRoleLookup = Callable[[str], BaselineRole | None]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_tree(
|
|
48
|
+
tree: SpecNode,
|
|
49
|
+
role_of: BaselineRoleLookup,
|
|
50
|
+
_path: str = "metrics",
|
|
51
|
+
) -> list[ValidationError]:
|
|
52
|
+
"""Return a list of validation errors for ``tree`` against D66 role rules.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
tree: Parsed MetricTree spec (root node).
|
|
56
|
+
role_of: callable returning :class:`BaselineRole` for a known
|
|
57
|
+
baseline name, or ``None`` for an unknown name. Typical
|
|
58
|
+
caller builds this from a :class:`ReferenceStore` query.
|
|
59
|
+
"""
|
|
60
|
+
errors: list[ValidationError] = []
|
|
61
|
+
_walk(tree, role_of, _path, in_warn=False, errors=errors)
|
|
62
|
+
|
|
63
|
+
# Tree-level rule: at least one primary leaf outside warn.
|
|
64
|
+
if not _has_primary_outside_warn(tree, role_of):
|
|
65
|
+
errors.append(
|
|
66
|
+
ValidationError(
|
|
67
|
+
_path,
|
|
68
|
+
"Every tree needs at least one criterion scored against the "
|
|
69
|
+
"primary baseline outside a 'warn': a tree of warnings alone "
|
|
70
|
+
"can never fail.",
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
return errors
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _walk(
|
|
77
|
+
node: SpecNode,
|
|
78
|
+
role_of: BaselineRoleLookup,
|
|
79
|
+
path: str,
|
|
80
|
+
in_warn: bool,
|
|
81
|
+
errors: list[ValidationError],
|
|
82
|
+
) -> None:
|
|
83
|
+
if isinstance(node, LeafSpec):
|
|
84
|
+
_check_leaf(node, role_of, path, in_warn, errors)
|
|
85
|
+
return
|
|
86
|
+
if isinstance(node, CombinatorSpec):
|
|
87
|
+
if node.combinator == "weighted":
|
|
88
|
+
_check_weighted_directions(node, path, errors)
|
|
89
|
+
child_in_warn = in_warn or node.combinator == "warn"
|
|
90
|
+
for i, child in enumerate(node.children):
|
|
91
|
+
_walk(child, role_of, f"{path}.children[{i}]", child_in_warn, errors)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _check_weighted_directions(
|
|
95
|
+
node: CombinatorSpec,
|
|
96
|
+
path: str,
|
|
97
|
+
errors: list[ValidationError],
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Reject weighted nodes whose leaf children score in opposite directions.
|
|
100
|
+
|
|
101
|
+
``weighted`` compares one summed score against one threshold with one
|
|
102
|
+
comparison direction, so summing a lower-is-better score (NRMSE-like
|
|
103
|
+
error) with a higher-is-better one (tube's in-band fraction) is
|
|
104
|
+
meaningless — the sum can move toward the threshold for opposite
|
|
105
|
+
reasons. The metric registry's ``score_direction`` is the authority
|
|
106
|
+
(D101).
|
|
107
|
+
"""
|
|
108
|
+
from .registry import metric as registry_metric
|
|
109
|
+
|
|
110
|
+
directions: dict[str, list[str]] = {}
|
|
111
|
+
for child in node.children:
|
|
112
|
+
if not isinstance(child, LeafSpec):
|
|
113
|
+
continue
|
|
114
|
+
desc = registry_metric(child.metric)
|
|
115
|
+
if desc is None:
|
|
116
|
+
continue # unknown metric fails loudly at parse/eval, not here
|
|
117
|
+
directions.setdefault(desc.score_direction, []).append(child.metric)
|
|
118
|
+
if len(directions) > 1:
|
|
119
|
+
parts = "; ".join(
|
|
120
|
+
f"{d}: {', '.join(sorted(set(ms)))}" for d, ms in sorted(directions.items())
|
|
121
|
+
)
|
|
122
|
+
errors.append(
|
|
123
|
+
ValidationError(
|
|
124
|
+
path,
|
|
125
|
+
f"A 'weighted' node cannot mix lower-is-better and "
|
|
126
|
+
f"higher-is-better scores ({parts}); split them into separate "
|
|
127
|
+
f"weighted nodes.",
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _check_leaf(
|
|
133
|
+
leaf: LeafSpec,
|
|
134
|
+
role_of: BaselineRoleLookup,
|
|
135
|
+
path: str,
|
|
136
|
+
in_warn: bool,
|
|
137
|
+
errors: list[ValidationError],
|
|
138
|
+
) -> None:
|
|
139
|
+
role = role_of(leaf.against)
|
|
140
|
+
if role is None:
|
|
141
|
+
errors.append(
|
|
142
|
+
ValidationError(
|
|
143
|
+
path,
|
|
144
|
+
f"This criterion scores against {leaf.against!r}, which is not "
|
|
145
|
+
f"a baseline of this test (the primary or a soft check); check "
|
|
146
|
+
f"the name.",
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
return
|
|
150
|
+
if role is BaselineRole.COMPANION:
|
|
151
|
+
errors.append(
|
|
152
|
+
ValidationError(
|
|
153
|
+
path,
|
|
154
|
+
f"This criterion scores against the companion {leaf.against!r}, "
|
|
155
|
+
f"and companions are only drawn; import it as a soft check "
|
|
156
|
+
f"(dstf import-baseline) to score against it.",
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return
|
|
160
|
+
if role is BaselineRole.SOFT_CHECK and not in_warn:
|
|
161
|
+
errors.append(
|
|
162
|
+
ValidationError(
|
|
163
|
+
path,
|
|
164
|
+
f"This criterion scores against the soft check {leaf.against!r}, "
|
|
165
|
+
f"which must sit under a 'warn' so it can only warn.",
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _has_primary_outside_warn(
|
|
172
|
+
node: SpecNode,
|
|
173
|
+
role_of: BaselineRoleLookup,
|
|
174
|
+
in_warn: bool = False,
|
|
175
|
+
) -> bool:
|
|
176
|
+
if isinstance(node, LeafSpec):
|
|
177
|
+
if in_warn:
|
|
178
|
+
return False
|
|
179
|
+
return role_of(node.against) is BaselineRole.PRIMARY
|
|
180
|
+
if isinstance(node, CombinatorSpec):
|
|
181
|
+
child_in_warn = in_warn or node.combinator == "warn"
|
|
182
|
+
return any(
|
|
183
|
+
_has_primary_outside_warn(c, role_of, child_in_warn) for c in node.children
|
|
184
|
+
)
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Convenience role-lookup factories
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def role_lookup_from_store(store, model_id: str) -> BaselineRoleLookup:
|
|
194
|
+
"""Build a role-lookup for ``store``'s view of ``model_id``.
|
|
195
|
+
|
|
196
|
+
Primary is always ``"primary"``. Soft_checks come from
|
|
197
|
+
:meth:`ReferenceStore.get_soft_checks`. Companions from
|
|
198
|
+
:meth:`ReferenceStore.get_companions`.
|
|
199
|
+
"""
|
|
200
|
+
soft_check_names = set(store.get_soft_checks(model_id).keys())
|
|
201
|
+
companion_names = set(store.get_companions(model_id).keys())
|
|
202
|
+
|
|
203
|
+
def lookup(name: str) -> BaselineRole | None:
|
|
204
|
+
if name == "primary":
|
|
205
|
+
return BaselineRole.PRIMARY
|
|
206
|
+
if name in soft_check_names:
|
|
207
|
+
return BaselineRole.SOFT_CHECK
|
|
208
|
+
if name in companion_names:
|
|
209
|
+
return BaselineRole.COMPANION
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
return lookup
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def role_lookup_from_names(
|
|
216
|
+
soft_checks: Sequence[str] = (),
|
|
217
|
+
companions: Sequence[str] = (),
|
|
218
|
+
) -> BaselineRoleLookup:
|
|
219
|
+
"""Build a role-lookup from explicit name lists (testing-friendly)."""
|
|
220
|
+
sc = set(soft_checks)
|
|
221
|
+
co = set(companions)
|
|
222
|
+
|
|
223
|
+
def lookup(name: str) -> BaselineRole | None:
|
|
224
|
+
if name == "primary":
|
|
225
|
+
return BaselineRole.PRIMARY
|
|
226
|
+
if name in sc:
|
|
227
|
+
return BaselineRole.SOFT_CHECK
|
|
228
|
+
if name in co:
|
|
229
|
+
return BaselineRole.COMPANION
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
return lookup
|