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,493 @@
1
+ """Applying a validated patch set to ``test_spec.json``.
2
+
3
+ The one read-modify-write both ``dstf spec-update`` and the authoring
4
+ Session's ``save`` go through: materialize the implicit tree where a
5
+ per-leaf op needs one, apply each model's ops to its entry, refuse a base
6
+ tolerance the patch made no number, refuse a key nothing reads, run the
7
+ D66 validator on every touched tree, refuse a criterion the patch left
8
+ unscorable (:func:`refuse_newly_unscorable` — the delta rule), then write
9
+ once. Nothing is written unless every group passes.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import copy
15
+ import json
16
+ from collections import Counter
17
+ from collections.abc import Sequence
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING
21
+
22
+ from ..backends.types import is_glob_pattern
23
+ from ..config import Config
24
+ from ..storage.ref_index import _atomic_write_text
25
+ from .patch_apply import PatchError, apply_patch_to_document, load_spec_document
26
+
27
+ if TYPE_CHECKING:
28
+ from ..storage.reference_store import ReferenceStore
29
+
30
+
31
+ def spec_entry(data: dict, model_id: str) -> dict | None:
32
+ """The first entry for ``model_id`` — first wins, as ``apply_patch`` and
33
+ discovery agree — or ``None``."""
34
+ for e in data.get("tests", []):
35
+ if isinstance(e, dict) and e.get("model") == model_id:
36
+ return e
37
+ return None
38
+
39
+
40
+ def is_literal_variable(pattern: str) -> bool:
41
+ """A tracked-variable entry that names one variable. The resolver's rule:
42
+ ``*`` and ``?`` are wildcards, a Modelica array index ``[1]`` is not."""
43
+ return not is_glob_pattern(pattern)
44
+
45
+
46
+ def unknown_override_modes(entry: dict) -> dict[str, str]:
47
+ """``{variable: mode}`` for every flat override naming a ``mode`` no
48
+ metric is registered under. The flat path fails such a variable loudly
49
+ (a typo must not change the algorithm); ``synthesize_implicit_tree``
50
+ would fall back to nrmse, so a tree must not be written for it."""
51
+ from ..comparison.registry import metric
52
+
53
+ overrides = (entry.get("comparison") or {}).get("variable_overrides") or {}
54
+ return {
55
+ var: str(ov["mode"])
56
+ for var, ov in overrides.items()
57
+ if isinstance(ov, dict) and ov.get("mode") and metric(str(ov["mode"])) is None
58
+ }
59
+
60
+
61
+ def implicit_tree_op(entry: dict, *, variables: Sequence[str] | None = None) -> dict:
62
+ """The ``add /metrics`` op that materializes the implicit tree a
63
+ flat-override entry scores with: one leaf per tracked variable, params
64
+ from ``comparison.variable_overrides``, wrapped in ``and`` (see
65
+ ``synthesize_implicit_tree``). ``variables`` replaces the entry's own
66
+ list: the authoring Session passes the names the flat path actually
67
+ scored, which for a test tracked by its ``.mo`` annotation are not in
68
+ the spec at all (``spec-update``, with no result in hand, can only use
69
+ the entry's). Raises :class:`PatchError` when there are no variables to
70
+ build leaves from."""
71
+ from ..comparison.tree_spec import spec_to_raw, synthesize_implicit_tree
72
+
73
+ variables = (
74
+ list(variables) if variables is not None else entry.get("variables") or []
75
+ )
76
+ if not variables:
77
+ raise PatchError(
78
+ f"cannot materialize an implicit metrics tree for "
79
+ f"{entry.get('model')!r}: the entry declares no tracked variables "
80
+ "to build leaves from"
81
+ )
82
+ globs = [v for v in variables if not is_literal_variable(v)]
83
+ if globs:
84
+ raise PatchError(
85
+ f"cannot materialize an implicit metrics tree for "
86
+ f"{entry.get('model')!r}: {globs!r} are patterns, and a leaf per "
87
+ "pattern would score a sentinel where the flat path scored the "
88
+ "variables the result resolved"
89
+ )
90
+ bad_modes = unknown_override_modes(entry)
91
+ if bad_modes:
92
+ raise PatchError(
93
+ f"cannot materialize an implicit metrics tree for "
94
+ f"{entry.get('model')!r}: {bad_modes!r} name a mode no metric is "
95
+ "registered under; the flat path fails them, a tree would score nrmse"
96
+ )
97
+ overrides = (entry.get("comparison") or {}).get("variable_overrides") or {}
98
+ tree = synthesize_implicit_tree(variables, variable_overrides=overrides)
99
+ # A copy: a leaf's params are its override's own values (a `points`
100
+ # table, a tolerance pair), and an op that shared them with the entry it
101
+ # was read from would change when a later op edits that override.
102
+ return {"op": "add", "path": "/metrics", "value": copy.deepcopy(spec_to_raw(tree))}
103
+
104
+
105
+ def materialize_implicit_metrics(
106
+ entry: dict | None, patch: Sequence[dict]
107
+ ) -> list[dict]:
108
+ """``patch`` with :func:`implicit_tree_op` prepended when some op edits
109
+ under ``/metrics/`` and neither the entry nor the patch itself supplies
110
+ the tree.
111
+
112
+ review 2026-07-06 finding 41: per-leaf ops target
113
+ ``/metrics/children/N/...``, but a flat-override entry has no ``metrics``
114
+ block and ``patch_apply`` refuses to auto-create an array parent. A
115
+ missing entry is left to ``apply_patch`` (which creates it and then
116
+ reports the real failure)."""
117
+ ops = list(patch)
118
+ if not needs_materialization(entry, ops):
119
+ return ops
120
+ assert entry is not None
121
+ return [implicit_tree_op(entry), *ops]
122
+
123
+
124
+ def has_metrics(entry: dict | None) -> bool:
125
+ """Whether ``entry`` has a ``metrics`` tree of its own. ``"metrics":
126
+ null`` is none — and is no entry a file may hold: the parser refuses it,
127
+ and so does every write (:func:`validate_entry_metric_tree`). Every "has
128
+ it a tree" question — here and in the authoring Session — asks this,
129
+ and none tests the key: it is asked of a patched entry before that
130
+ entry is checked."""
131
+ return entry is not None and entry.get("metrics") is not None
132
+
133
+
134
+ def needs_materialization(entry: dict | None, patch: Sequence[dict]) -> bool:
135
+ """Some op edits under ``/metrics/`` and neither the entry nor the patch
136
+ itself supplies the tree (a missing entry is left to ``apply_patch``)."""
137
+ if entry is None or has_metrics(entry):
138
+ return False
139
+ edits_under = any(
140
+ isinstance(op, dict) and str(op.get("path", "")).startswith("/metrics/")
141
+ for op in patch
142
+ )
143
+ supplies_tree = any(
144
+ isinstance(op, dict) and op.get("path") == "/metrics" for op in patch
145
+ )
146
+ return edits_under and not supplies_tree
147
+
148
+
149
+ def validate_entry_metric_tree(
150
+ data: dict, model_id: str, config: Config, store: ReferenceStore | None = None
151
+ ) -> None:
152
+ """When the patched entry has a ``metrics`` key, parse it as the spec
153
+ parser does — a ``null`` there is refused, like any other value that is
154
+ no tree — and run the D66 validator against the reference store's
155
+ baseline roles. Skipped when the entry has no such key (implicit AND).
156
+ ``store`` saves the caller that already holds one (the Session) a
157
+ rescan of the whole partition per call."""
158
+ entry = spec_entry(data, model_id)
159
+ if entry is None or "metrics" not in entry:
160
+ return
161
+
162
+ from ..comparison.tree_spec import MetricSpecError, parse_metric_tree
163
+ from ..comparison.validator import role_lookup_from_store, validate_tree
164
+ from ..storage.reference_store import ReferenceStore
165
+
166
+ try:
167
+ tree = parse_metric_tree(entry["metrics"])
168
+ except MetricSpecError as e:
169
+ # The reason drops the path ("metrics.window: The window must end
170
+ # after ..."): the tool shows the reason beside the row it names.
171
+ raise PatchError(
172
+ f"patched metric tree is invalid: {e}", reason=e.message
173
+ ) from e
174
+ except Exception as e:
175
+ raise PatchError(f"patched metric tree is invalid: {e}", reason=str(e)) from e
176
+
177
+ if store is None:
178
+ store = ReferenceStore(config)
179
+ lookup = role_lookup_from_store(store, model_id)
180
+ errors = validate_tree(tree, lookup)
181
+ if errors:
182
+ msg = "; ".join(str(e) for e in errors)
183
+ raise PatchError(
184
+ f"validator rejected the patched tree: {msg}",
185
+ # Each message is a sentence that ends in its own full stop.
186
+ reason=" ".join(e.message for e in errors),
187
+ )
188
+
189
+
190
+ class UnscorableCriterion(PatchError):
191
+ """A write refused by :func:`refuse_newly_unscorable`. ``reason`` is the
192
+ criterion and what it lacks — ``range · pipe.T[1]: needs at least one of
193
+ min_value / max_value`` — and ``str()`` leads with where it is."""
194
+
195
+
196
+ @dataclass(frozen=True)
197
+ class Unscorable:
198
+ """One criterion of an entry that can never be scored."""
199
+
200
+ #: What it IS — its metric, variable, baseline, window and params as
201
+ #: sorted JSON — and nothing of where: the same text for a flat
202
+ #: override and for the leaf its entry's implicit tree gives it.
203
+ content: str
204
+ #: Where it is, as a parse error says it: ``metrics.children[1]``.
205
+ place: str
206
+ #: ``range · pipe.T[1]: needs at least one of min_value / max_value``.
207
+ reason: str
208
+
209
+
210
+ def _dotted(pointer: str) -> str:
211
+ """A leaf's JSON-pointer path as the spec parser names a node:
212
+ ``/metrics/children/3/children/0`` → ``metrics.children[3].children[0]``."""
213
+ parts = pointer.strip("/").split("/")
214
+ out = parts[0]
215
+ for part in parts[1:]:
216
+ out += f"[{part}]" if part.isdigit() else f".{part}"
217
+ return out
218
+
219
+
220
+ def unscorable_criteria(entry: dict | None) -> list[Unscorable]:
221
+ """Every criterion of ``entry`` whose mode cannot be built, as the
222
+ comparison builds it: a ``range`` with neither bound, a ``scalar`` with
223
+ no quantity, a tolerance that is no number or pair. Each parses, and
224
+ then fails at every compare.
225
+
226
+ What SCORES is what is looked at. With a ``metrics`` tree that is its
227
+ leaves, and the flat ``comparison.variable_overrides`` are not checked
228
+ at all: under a tree they score nothing (``comparator``: the tree
229
+ "fully replaces" them), so one that could not be built there fails
230
+ nothing — the tool itself leaves the overrides beside the tree it
231
+ writes out. Without a tree it is the overrides. A tree that does not
232
+ parse and an override naming a mode nothing is registered under are
233
+ said by their own checks, not here."""
234
+ from ..comparison.modes import resolve_mode
235
+ from ..comparison.registry import DEFAULT_METRIC
236
+ from ..comparison.tree_eval import build_leaf_mode
237
+ from ..comparison.tree_spec import (
238
+ LeafSpec,
239
+ MetricSpecError,
240
+ _walk_leaves_with_paths,
241
+ parse_metric_tree,
242
+ spec_to_raw,
243
+ )
244
+ from ..comparison.types import DEFAULT_TOLERANCE
245
+
246
+ def found(leaf: LeafSpec, place: str, why: Exception) -> Unscorable:
247
+ return Unscorable(
248
+ content=json.dumps(spec_to_raw(leaf), sort_keys=True, default=str),
249
+ place=place,
250
+ reason=f"{leaf.metric} · {leaf.variable}: {why}",
251
+ )
252
+
253
+ if entry is None:
254
+ return []
255
+ out: list[Unscorable] = []
256
+ if has_metrics(entry):
257
+ try:
258
+ tree = parse_metric_tree(entry["metrics"])
259
+ except (MetricSpecError, ValueError, TypeError, AttributeError):
260
+ return []
261
+ for leaf, path in _walk_leaves_with_paths(tree, "/metrics"):
262
+ try:
263
+ build_leaf_mode(leaf, DEFAULT_TOLERANCE)
264
+ except ValueError as e:
265
+ out.append(found(leaf, _dotted(path), e))
266
+ return out
267
+ comparison = entry.get("comparison")
268
+ overrides = (
269
+ comparison.get("variable_overrides") if isinstance(comparison, dict) else None
270
+ )
271
+ if not isinstance(overrides, dict):
272
+ return []
273
+ unknown = unknown_override_modes(entry)
274
+ for variable, override in overrides.items():
275
+ if not isinstance(override, dict) or variable in unknown:
276
+ continue # a mode nothing is registered under fails at compare
277
+ try:
278
+ resolve_mode(override, override.get("tolerance", DEFAULT_TOLERANCE))
279
+ except ValueError as e:
280
+ # As the leaf `synthesize_implicit_tree` makes of it, so that a
281
+ # patch which writes the entry's tree out changes nothing here.
282
+ params = {k: v for k, v in override.items() if k != "mode"}
283
+ leaf = LeafSpec(
284
+ str(override.get("mode") or DEFAULT_METRIC), variable, params
285
+ )
286
+ place = f"comparison.variable_overrides[{variable}]"
287
+ out.append(found(leaf, place, e))
288
+ return out
289
+
290
+
291
+ #: The key a leaf of the entry AS IT WAS is marked with, on a copy the
292
+ #: patch is replayed over (:func:`_places_after`): its value is the place it
293
+ #: had. No spec key (a NUL), and never in a document that is kept.
294
+ _WAS = "\0was"
295
+
296
+
297
+ def _leaf_dicts(entry: dict | None) -> dict[str, dict]:
298
+ """``{place: the raw dict}`` of the leaves of the entry's ``metrics``
299
+ tree, places spelled as :func:`unscorable_criteria` spells them
300
+ (``metrics.children[1]``); none for an entry with no tree. Whatever is
301
+ no object is passed over: it is no criterion."""
302
+ out: dict[str, dict] = {}
303
+ if not isinstance(entry, dict):
304
+ return out
305
+
306
+ def walk(node: object, pointer: str) -> None:
307
+ if not isinstance(node, dict):
308
+ return
309
+ children = node.get("children")
310
+ if "combinator" in node and isinstance(children, list):
311
+ for i, child in enumerate(children):
312
+ walk(child, f"{pointer}/children/{i}")
313
+ elif "metric" in node:
314
+ out[_dotted(pointer)] = node
315
+
316
+ walk(entry.get("metrics"), "/metrics")
317
+ return out
318
+
319
+
320
+ def _places_after(
321
+ before: dict | None, ops: Sequence[dict], model_id: str
322
+ ) -> dict[str, str]:
323
+ """``{place after the patch: the place it had before}`` for every leaf
324
+ of ``before``'s tree that is still a leaf of the entry's tree once
325
+ ``ops`` are applied: the same object, wherever it is now and whatever
326
+ was added to it or taken from it. A leaf an op removed, or wrote over
327
+ whole, is in no place after; one the patch added had no place before.
328
+
329
+ Found by replaying the patch over a marked copy: an op names a path,
330
+ so a mark inside a leaf rides with it through every op that does not
331
+ replace that leaf.
332
+
333
+ A flat override is not followed. It cannot move: its place is its
334
+ variable, and its content names that variable, so an equal one can
335
+ only stand where it stood — kept or written over, the count in
336
+ :func:`refuse_newly_unscorable` says the same of it."""
337
+ if before is None:
338
+ return {}
339
+ marked = copy.deepcopy(before)
340
+ for place, leaf in _leaf_dicts(marked).items():
341
+ leaf[_WAS] = place
342
+ document = {"tests": [marked]}
343
+ apply_patch_to_document(document, model_id, list(ops))
344
+ after = spec_entry(document, model_id)
345
+ return {
346
+ place: leaf[_WAS] for place, leaf in _leaf_dicts(after).items() if _WAS in leaf
347
+ }
348
+
349
+
350
+ def refuse_newly_unscorable(
351
+ before: dict | None, ops: Sequence[dict], after: dict | None, model_id: str
352
+ ) -> None:
353
+ """THE rule for a criterion that can never be scored, applied where a
354
+ FILE is written (``spec-update`` and the tool's Save, both through
355
+ :func:`apply_validated_patches`): the write is refused only for a
356
+ criterion that is unscorable in ``after`` — ``before`` with ``ops``
357
+ applied — and was not already there, with the same content, in
358
+ ``before``.
359
+
360
+ So an entry that already holds one — written by hand, or by an earlier
361
+ version of the tool — takes every other edit, the edit that fills it
362
+ and the edit that removes it; two of them do not block each other. A
363
+ patch that adds one, or edits one and leaves it unscorable, is refused:
364
+ that is the write that could know better.
365
+
366
+ "Already there" is said of the criterion, not of a count. One that is
367
+ still the object it was (:func:`_places_after`) with the content it had
368
+ is the one that was there, wherever a removed sibling has shifted it.
369
+ Any other unscorable criterion of ``after`` — one the patch added, or
370
+ wrote over — is the one that was there only if an equal one of
371
+ ``before`` is GONE from the criteria that score: a move (a remove and
372
+ an add), a leaf wrapped in a group, the entry's implicit tree written
373
+ out over its flat override. Counted alone — which this replaces — a
374
+ patch that filled the bare ``range`` an entry held and added an equal
375
+ bare one beside it was written: one before, one after.
376
+
377
+ Refusing every unscorable criterion of the patched entry — the rule
378
+ before that — refused the patch that fixed one of two, and every edit
379
+ of an entry that held one it did not touch."""
380
+ found = unscorable_criteria(after)
381
+ if not found:
382
+ return
383
+ was = {item.place: item.content for item in unscorable_criteria(before)}
384
+ came_from = _places_after(before, ops, model_id)
385
+ # The leaves of `before` that are still leaves of `after`. Every other
386
+ # unscorable criterion of `before` stands for one equal one of `after`:
387
+ # a leaf that is gone, and each flat override — its content names its
388
+ # variable, so the equal one can only be the one in its place.
389
+ still_there = set(came_from.values())
390
+ gone = Counter(
391
+ content for place, content in was.items() if place not in still_there
392
+ )
393
+ for item in found:
394
+ if was.get(came_from.get(item.place, "")) == item.content:
395
+ continue # untouched, or only shifted
396
+ if gone[item.content] > 0:
397
+ gone[item.content] -= 1
398
+ continue # taken out and put back: moved, wrapped, written out
399
+ raise UnscorableCriterion(
400
+ f"patched entry is invalid: tests[{model_id}].{item.place}: {item.reason}",
401
+ reason=item.reason,
402
+ )
403
+
404
+
405
+ def validate_entry_keys(data: dict, model_id: str) -> None:
406
+ """The patched entry holds no key nothing reads (``check_entry_keys``,
407
+ the spec parser's own check)."""
408
+ from .spec_parser import EntryKeyError, check_entry_keys
409
+
410
+ entry = spec_entry(data, model_id)
411
+ if entry is None:
412
+ return
413
+ try:
414
+ check_entry_keys(entry, model_id)
415
+ except EntryKeyError as e:
416
+ raise PatchError(f"patched entry is invalid: {e}", reason=e.message) from e
417
+
418
+
419
+ def _base_tolerance(entry: dict | None) -> object:
420
+ """The entry's ``comparison.tolerance`` as written, or ``None``."""
421
+ comparison = (entry or {}).get("comparison")
422
+ return comparison.get("tolerance") if isinstance(comparison, dict) else None
423
+
424
+
425
+ def _same_base(before: object, after: object) -> bool:
426
+ """Whether the patch left the base as it was: the same JSON text, keys
427
+ sorted. Not ``==``: Python's ``True == 1`` at any depth (``[1] → [true]``
428
+ is a change), and a NaN is not equal to itself (writing the NaN already
429
+ there is none) — as text, a bool is ``true`` and a NaN is ``NaN``."""
430
+
431
+ def text(value: object) -> str:
432
+ return json.dumps(value, sort_keys=True, allow_nan=True)
433
+
434
+ return text(before) == text(after)
435
+
436
+
437
+ def validate_entry_base_tolerance(data: dict, model_id: str, before: object) -> None:
438
+ """The patched entry's base tolerance is ONE finite number, or unset —
439
+ when the patch changed it from ``before``. The comparison refuses a test
440
+ whose base is anything else; a patch must not write one. One already
441
+ there is the comparison's to refuse, not this patch's: refusing it here
442
+ would leave no patch able to edit the entry, or remove it."""
443
+ from ..comparison.sides import base_tolerance_refused, is_base_tolerance
444
+
445
+ after = _base_tolerance(spec_entry(data, model_id))
446
+ if after is None or is_base_tolerance(after) or _same_base(before, after):
447
+ return
448
+ text = base_tolerance_refused("test_spec.json comparison.tolerance", after)
449
+ raise PatchError(text, reason=text)
450
+
451
+
452
+ def validate_entry_plots(data: dict, model_id: str) -> None:
453
+ """The patched entry's ``plots`` extras, when it has the key, parse."""
454
+ from .plots import PlotsSpecError, parse_plots
455
+
456
+ entry = spec_entry(data, model_id)
457
+ if entry is None or "plots" not in entry:
458
+ return
459
+ try:
460
+ parse_plots(entry["plots"], path=f"tests[{model_id}].plots")
461
+ except PlotsSpecError as e:
462
+ raise PatchError(f"patched plots are invalid: {e}", reason=str(e)) from e
463
+
464
+
465
+ def apply_validated_patches(
466
+ spec_path: Path,
467
+ groups: Sequence[tuple[str, Sequence[dict]]],
468
+ config: Config,
469
+ store: ReferenceStore | None = None,
470
+ ) -> list[tuple[str, list[dict]]]:
471
+ """Apply every group's ops to its model's entry in one read-modify-write.
472
+
473
+ Per group: materialize, apply, validate. The file is written once,
474
+ atomically and LF (D122), only after every group passed. Returns the ops
475
+ actually applied per group — materialization included — so a caller can
476
+ say what it did."""
477
+ if not groups:
478
+ return [] # nothing to apply; the file is not rewritten
479
+ data = load_spec_document(spec_path)
480
+ applied: list[tuple[str, list[dict]]] = []
481
+ for model_id, ops in groups:
482
+ full = materialize_implicit_metrics(spec_entry(data, model_id), ops)
483
+ was = copy.deepcopy(spec_entry(data, model_id))
484
+ apply_patch_to_document(data, model_id, full)
485
+ validate_entry_base_tolerance(data, model_id, _base_tolerance(was))
486
+ validate_entry_keys(data, model_id)
487
+ validate_entry_metric_tree(data, model_id, config, store)
488
+ refuse_newly_unscorable(was, full, spec_entry(data, model_id), model_id)
489
+ validate_entry_plots(data, model_id)
490
+ applied.append((model_id, full))
491
+ spec_path.parent.mkdir(parents=True, exist_ok=True)
492
+ _atomic_write_text(spec_path, json.dumps(data, indent=2) + "\n")
493
+ return applied