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,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