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,308 @@
1
+ """RFC 6902 JSON-Patch applier with whitelist + unknown-key preservation.
2
+
3
+ Used by ``dstf spec-update`` (through :mod:`spec_update`) and the authoring
4
+ Session to apply small, scoped edits to ``test_spec.json`` without
5
+ clobbering hand-authored fields. The ops arrive in the envelope
6
+ :mod:`patch_envelope` reads; one model's ops look like::
7
+
8
+ [
9
+ {"op": "replace", "path": "/comparison/tolerance", "value": 1e-3},
10
+ {"op": "add", "path": "/comparison/variable_overrides/v/tube_rel",
11
+ "value": 0.02}
12
+ ]
13
+
14
+ Paths are RFC 6901 JSON-Pointers **relative to the matched test entry**
15
+ (so `/comparison/...`, `/metrics/...` and `/plots/...`, not `/tests/3/...`). A path
16
+ whitelist constrains which subtrees the applier will touch — an editor
17
+ is not allowed to mutate arbitrary structure.
18
+
19
+ This module is self-contained: no external JSON-Patch / JSON-Pointer
20
+ libraries. The surface is small (replace/add/remove on a single path
21
+ each), so the RFC 6901 unescape rules fit in ~10 lines.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import copy
27
+ import json
28
+ import logging
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from ..storage.ref_index import _atomic_write_text
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class PatchError(Exception):
38
+ """Raised when a patch cannot be applied cleanly."""
39
+
40
+ def __init__(
41
+ self, message: str, path: str | None = None, *, reason: str | None = None
42
+ ):
43
+ self.path = path
44
+ #: The plain sentence a person reads: ``str()`` keeps the op and the
45
+ #: pointer for the CLI, the tool shows this beside the edit it came
46
+ #: from (`authoring.errors.user_text`).
47
+ self.reason = reason if reason is not None else message
48
+ super().__init__(f"{path}: {message}" if path else message)
49
+
50
+
51
+ _SUPPORTED_OPS = {"replace", "add", "remove"}
52
+
53
+ #: The subtrees of a test entry an editor may touch. One constant, shared by
54
+ #: both entry points below, so the two cannot drift.
55
+ DEFAULT_WHITELIST: tuple[str, ...] = ("/comparison", "/metrics", "/plots")
56
+
57
+
58
+ def apply_patch_to_document(
59
+ data: dict,
60
+ model_id: str,
61
+ patch_ops: list[dict],
62
+ *,
63
+ whitelist: tuple[str, ...] = DEFAULT_WHITELIST,
64
+ ) -> dict:
65
+ """Apply ``patch_ops`` to the entry in the parsed document ``data`` that
66
+ matches ``model_id`` (created if absent), in place. Returns ``data``.
67
+ Raises :class:`PatchError` naming the failing op. The authoring
68
+ Session's in-memory document goes through here; the disk path is
69
+ :func:`apply_patch`."""
70
+ entry = _find_or_create_entry(data, model_id)
71
+ for i, op in enumerate(patch_ops):
72
+ try:
73
+ _apply_one_op(entry, op, whitelist)
74
+ except PatchError as e:
75
+ raise PatchError(
76
+ f"patch_ops[{i}] ({op!r}): {e}",
77
+ path=e.path,
78
+ reason=e.reason,
79
+ ) from None
80
+ return data
81
+
82
+
83
+ def apply_patch(
84
+ spec_path: Path,
85
+ model_id: str,
86
+ patch_ops: list[dict],
87
+ *,
88
+ whitelist: tuple[str, ...] = DEFAULT_WHITELIST,
89
+ write: bool = True,
90
+ ) -> dict:
91
+ """Apply ``patch_ops`` to the entry in ``spec_path`` matching ``model_id``.
92
+
93
+ Returns the (mutated) spec dict. When ``write=True`` (default), also
94
+ persists it back to disk preserving key order. Raise :class:`PatchError`
95
+ on any issue; the caller (CLI) surfaces the message and aborts without
96
+ writing if the dry-run path signals invalidity.
97
+ """
98
+ data = apply_patch_to_document(
99
+ load_spec_document(spec_path), model_id, patch_ops, whitelist=whitelist
100
+ )
101
+ if write:
102
+ spec_path.parent.mkdir(parents=True, exist_ok=True)
103
+ # review 2026-07-06 (finding 31): atomic — an interrupted patch must
104
+ # not leave the user's spec truncated.
105
+ _atomic_write_text(spec_path, json.dumps(data, indent=2) + "\n")
106
+ return data
107
+
108
+
109
+ def load_spec_document(spec_path: Path) -> dict:
110
+ """The spec document a patch is applied to: the file's
111
+ (``spec_parser.read_spec_document`` — the one reader, a
112
+ :class:`~dstf.config.ConfigError` for a file that is no spec), or the
113
+ empty document when there is no file yet."""
114
+ from .spec_parser import read_spec_document
115
+
116
+ data = read_spec_document(spec_path)
117
+ return {"tests": []} if data is None else data
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Internals
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def _find_or_create_entry(data: dict, model_id: str) -> dict:
126
+ matches = [
127
+ entry
128
+ for entry in data["tests"]
129
+ if isinstance(entry, dict) and entry.get("model") == model_id
130
+ ]
131
+ if len(matches) > 1:
132
+ # review 2026-07-06 finding 51: duplicates — first entry wins
133
+ # everywhere (discovery + edit helpers use the same rule); warn so
134
+ # the user notices the dead entries.
135
+ logger.warning(
136
+ "test_spec has %d duplicate entries for model '%s'; "
137
+ "patching the first — remove the duplicates",
138
+ len(matches),
139
+ model_id,
140
+ )
141
+ if matches:
142
+ return matches[0]
143
+ entry = {"model": model_id}
144
+ data["tests"].append(entry)
145
+ return entry
146
+
147
+
148
+ def _apply_one_op(entry: dict, op: dict, whitelist: tuple[str, ...]) -> None:
149
+ op_name = op.get("op")
150
+ path = op.get("path")
151
+ if op_name not in _SUPPORTED_OPS:
152
+ raise PatchError(
153
+ f"unsupported op {op_name!r} (supported: {sorted(_SUPPORTED_OPS)})",
154
+ path=path,
155
+ )
156
+ if not isinstance(path, str) or not path.startswith("/"):
157
+ raise PatchError(
158
+ "'path' must be an RFC 6901 JSON-Pointer starting with /", path=path
159
+ )
160
+ if not any(path == w or path.startswith(w + "/") for w in whitelist):
161
+ raise PatchError(
162
+ f"path not in whitelist {whitelist}",
163
+ path=path,
164
+ )
165
+ tokens = _split_pointer(path)
166
+ if op_name == "remove":
167
+ _remove_at(entry, tokens, path)
168
+ return
169
+ if "value" not in op:
170
+ raise PatchError(f"{op_name!r} requires 'value'", path=path)
171
+ # A copy: the document never shares an object with the op that put it
172
+ # there. Written in by reference, a later op of the same patch edits the
173
+ # earlier op's own value — and the list a caller still holds is then no
174
+ # longer the patch it gave: applied again (a replay, a second document)
175
+ # it does those later ops twice.
176
+ value = copy.deepcopy(op["value"])
177
+ if op_name == "replace":
178
+ _set_at(entry, tokens, value, path, must_exist=True)
179
+ elif op_name == "add":
180
+ _set_at(entry, tokens, value, path, must_exist=False)
181
+
182
+
183
+ def _split_pointer(pointer: str) -> list[str]:
184
+ """RFC 6901: split on '/' after a leading slash; unescape ~1 → / and ~0 → ~."""
185
+ if pointer == "":
186
+ return []
187
+ if not pointer.startswith("/"):
188
+ raise PatchError("JSON-Pointer must start with /", path=pointer)
189
+ raw = pointer[1:].split("/")
190
+ # Order matters: ~1 before ~0 per RFC 6901.
191
+ return [seg.replace("~1", "/").replace("~0", "~") for seg in raw]
192
+
193
+
194
+ def _set_at(
195
+ obj: Any, tokens: list[str], value: Any, path: str, *, must_exist: bool
196
+ ) -> None:
197
+ if not tokens:
198
+ raise PatchError("empty path points at the entry root; refuse", path=path)
199
+ *parents, last = tokens
200
+ cur = obj
201
+ for i, tok in enumerate(parents):
202
+ next_token = parents[i + 1] if i + 1 < len(parents) else last
203
+ cur = _descend(
204
+ cur, tok, path, create_missing=not must_exist, next_token=next_token
205
+ )
206
+ if isinstance(cur, list):
207
+ idx = _list_index(cur, last, path, allow_append=not must_exist)
208
+ if idx > len(cur) or (must_exist and idx >= len(cur)):
209
+ raise PatchError(
210
+ f"list index {idx} out of range (len {len(cur)})", path=path
211
+ )
212
+ if must_exist:
213
+ cur[idx] = value
214
+ else:
215
+ # review 2026-07-06 finding 49: RFC 6902 `add` on an existing
216
+ # array index INSERTS before it (it used to replace, silently
217
+ # destroying a sibling leaf); idx == len(cur) appends, matching
218
+ # the '-' token.
219
+ cur.insert(idx, value)
220
+ elif isinstance(cur, dict):
221
+ if must_exist and last not in cur:
222
+ raise PatchError(
223
+ f"key {last!r} not present (use 'add' for new keys)", path=path
224
+ )
225
+ cur[last] = value
226
+ else:
227
+ raise PatchError(
228
+ f"cannot set on non-container {type(cur).__name__}",
229
+ path=path,
230
+ )
231
+
232
+
233
+ def _remove_at(obj: Any, tokens: list[str], path: str) -> None:
234
+ if not tokens:
235
+ raise PatchError("empty path points at the entry root; refuse", path=path)
236
+ *parents, last = tokens
237
+ cur = obj
238
+ for tok in parents:
239
+ cur = _descend(cur, tok, path, create_missing=False)
240
+ if isinstance(cur, list):
241
+ idx = _list_index(cur, last, path, allow_append=False)
242
+ if idx >= len(cur):
243
+ raise PatchError(f"list index {idx} out of range", path=path)
244
+ del cur[idx]
245
+ elif isinstance(cur, dict):
246
+ if last not in cur:
247
+ raise PatchError(f"key {last!r} not present", path=path)
248
+ del cur[last]
249
+ else:
250
+ raise PatchError(
251
+ f"cannot remove from non-container {type(cur).__name__}",
252
+ path=path,
253
+ )
254
+
255
+
256
+ def _descend(
257
+ cur: Any,
258
+ tok: str,
259
+ path: str,
260
+ *,
261
+ create_missing: bool,
262
+ next_token: str | None = None,
263
+ ) -> Any:
264
+ if isinstance(cur, list):
265
+ idx = _list_index(cur, tok, path, allow_append=False)
266
+ if idx >= len(cur):
267
+ raise PatchError(f"list index {idx} out of range", path=path)
268
+ return cur[idx]
269
+ if isinstance(cur, dict):
270
+ if tok not in cur:
271
+ if not create_missing:
272
+ raise PatchError(
273
+ f"key {tok!r} not present (use 'add' for new keys)",
274
+ path=path,
275
+ )
276
+ # review 2026-07-06 finding 49: never auto-create a dict where
277
+ # the path expects an ARRAY — a per-leaf op like
278
+ # /metrics/children/0/... against a spec without a metrics tree
279
+ # used to silently create {"children": {"0": ...}}, which the
280
+ # tree validator then rejects. Fail loudly instead.
281
+ if next_token is not None and (next_token == "-" or next_token.isdigit()):
282
+ raise PatchError(
283
+ f"key {tok!r} not present and the next token "
284
+ f"{next_token!r} is an array index — refusing to "
285
+ f"auto-create a dict in place of an array (the test "
286
+ f"entry likely has no explicit 'metrics' tree yet)",
287
+ path=path,
288
+ )
289
+ cur[tok] = {}
290
+ return cur[tok]
291
+ raise PatchError(
292
+ f"cannot descend into {type(cur).__name__}",
293
+ path=path,
294
+ )
295
+
296
+
297
+ def _list_index(cur: list, tok: str, path: str, *, allow_append: bool) -> int:
298
+ if tok == "-":
299
+ if not allow_append:
300
+ raise PatchError("'-' (append) not allowed on non-add op", path=path)
301
+ return len(cur)
302
+ try:
303
+ idx = int(tok)
304
+ except ValueError:
305
+ raise PatchError(f"list index {tok!r} must be an integer", path=path) from None
306
+ if idx < 0:
307
+ raise PatchError(f"list index {idx} must be non-negative", path=path)
308
+ return idx
@@ -0,0 +1,63 @@
1
+ """The JSON-Patch envelope ``dstf spec-update`` reads and the authoring
2
+ Session's ``export`` writes.
3
+
4
+ Two shapes are read — ``{"model": ..., "patch": [...]}`` because files in
5
+ that shape exist (the reporter's export panel wrote them until D125), and
6
+ ``{"patches": [{"model": ..., "patch": [...]}, ...]}`` because a tool that
7
+ spans tests needs one. Only the second is written. Old data, not old code.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass
14
+
15
+ from ..config import ConfigError
16
+
17
+ _SHAPES = (
18
+ '{"model": "<model id>", "patch": [<RFC 6902 op>, ...]} or '
19
+ '{"patches": [{"model": ..., "patch": [...]}, ...]}'
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ModelPatch:
25
+ """One model's ops, in order. Paths are relative to the model's entry."""
26
+
27
+ model_id: str
28
+ ops: list[dict]
29
+
30
+
31
+ def parse_envelope(data: object, *, label: str) -> list[ModelPatch]:
32
+ """Read either envelope form into ``ModelPatch`` groups, in file order.
33
+ Anything else raises :class:`ConfigError` naming both forms."""
34
+ if isinstance(data, dict) and "patches" in data and "model" in data:
35
+ raise _bad(label, "both a 'model' and a 'patches' key — say which")
36
+ if isinstance(data, dict) and "patches" in data:
37
+ groups = data["patches"]
38
+ if not isinstance(groups, list):
39
+ raise _bad(label, f"'patches' of type {type(groups).__name__}")
40
+ return [_group(g, label) for g in groups]
41
+ if isinstance(data, dict) and "model" in data:
42
+ return [_group(data, label)]
43
+ raise _bad(label, "neither a 'model' nor a 'patches' key")
44
+
45
+
46
+ def build_envelope(groups: Sequence[ModelPatch]) -> dict:
47
+ """The multi-model form, and only that form."""
48
+ return {"patches": [{"model": g.model_id, "patch": list(g.ops)} for g in groups]}
49
+
50
+
51
+ def _group(raw: object, label: str) -> ModelPatch:
52
+ if not isinstance(raw, dict) or not raw.get("model"):
53
+ raise _bad(label, "a group without a 'model'")
54
+ ops = raw.get("patch")
55
+ if not isinstance(ops, list):
56
+ raise _bad(label, f"a 'patch' of type {type(ops).__name__}")
57
+ return ModelPatch(str(raw["model"]), ops)
58
+
59
+
60
+ def _bad(label: str, got: str) -> ConfigError:
61
+ return ConfigError(
62
+ f"{label}: not a JSON-Patch envelope — expected {_SHAPES}, got {got}"
63
+ )
@@ -0,0 +1,318 @@
1
+ """The optional ``plots`` key of a ``test_spec.json`` entry, and the plots
2
+ both tiers draw.
3
+
4
+ A test's plots are DERIVED from its criteria tree (:func:`derive_plots`):
5
+ one plot per scored variable or array family, where the tree first names
6
+ it. The key holds only a person's extras — each entry one variable and the
7
+ ``context`` series drawn grey beside it. An entry on a scored variable
8
+ decorates that variable's plot; an entry on an unscored one is a WATCH
9
+ plot, after the derived ones, in list order. Absent and ``[]`` both mean no
10
+ extras (``[]`` is kept as the key's value, so a writer can tell it from an
11
+ absent key); ``null`` is refused. Read by both tiers, ignored by scoring.
12
+ The parser refuses what it does not know, so a typo is loud. No I/O here:
13
+ the spec parser, the patch validator, the Session and the report all call
14
+ in.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Collection, Iterable, Sequence
20
+ from dataclasses import dataclass
21
+ from typing import TYPE_CHECKING
22
+
23
+ from ..backends.types import is_glob_pattern
24
+
25
+ if TYPE_CHECKING:
26
+ from ..backends.types import TestResult
27
+ from ..comparison.tree_spec import LeafSpec, SpecNode
28
+ from .test_registry import TestModel
29
+
30
+
31
+ class PlotsSpecError(ValueError):
32
+ """A ``plots`` value the parser refuses; the message starts with where."""
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class PlotExtra:
37
+ """One entry of the ``plots`` key: a variable and its context series.
38
+ Scored, it decorates that variable's derived plot; unscored, it is a
39
+ watch plot."""
40
+
41
+ var: str
42
+ context: tuple[str, ...] = ()
43
+
44
+
45
+ @dataclass(frozen=True, kw_only=True)
46
+ class ResolvedPlot:
47
+ """One plot as a tier draws it. ``subject`` is one variable or the
48
+ members of one array family (by index); ``context`` the grey, unscored
49
+ series beside it; ``watch`` a plot no criterion names; ``missing`` the
50
+ subject and context names the result lacks."""
51
+
52
+ index: int
53
+ subject: tuple[str, ...]
54
+ context: tuple[str, ...] = ()
55
+ watch: bool = False
56
+ missing: tuple[str, ...] = ()
57
+ kind: str = "time"
58
+
59
+ @property
60
+ def series(self) -> tuple[str, ...]:
61
+ """Every name the plot draws — the subject, then the context —
62
+ missing ones included: a reader checks ``missing``."""
63
+ return self.subject + self.context
64
+
65
+
66
+ def _unknown(obj: dict, known: set[str], at: str) -> None:
67
+ """Refuse a key nothing reads, in the words every other part of the
68
+ spec is refused with (``strict_keys.unknown_field``). Imported late:
69
+ the comparison package reaches back into discovery."""
70
+ from ..comparison.strict_keys import unknown_field
71
+
72
+ problem = unknown_field(obj, known)
73
+ if problem is not None:
74
+ raise PlotsSpecError(f"{at}: {problem}")
75
+
76
+
77
+ def _one_name(value: object, at: str, what: str) -> str:
78
+ if not isinstance(value, str) or not value.strip():
79
+ raise PlotsSpecError(f"{at}: must be a non-empty string")
80
+ if is_glob_pattern(value):
81
+ raise PlotsSpecError(f"{at}: {value!r} is a pattern; {what} one variable")
82
+ return value
83
+
84
+
85
+ def parse_plots(raw: object, *, path: str = "plots") -> tuple[PlotExtra, ...]:
86
+ if not isinstance(raw, list):
87
+ raise PlotsSpecError(f"{path}: must be a list of plot extras")
88
+ extras: list[PlotExtra] = []
89
+ for i, entry in enumerate(raw):
90
+ at = f"{path}[{i}]"
91
+ if not isinstance(entry, dict):
92
+ raise PlotsSpecError(f"{at}: must be an object")
93
+ _unknown(entry, {"var", "context"}, at)
94
+ var = _one_name(entry.get("var"), f"{at}.var", "an entry names")
95
+ if any(e.var == var for e in extras):
96
+ raise PlotsSpecError(f"{at}.var: {var!r} already has an entry")
97
+ context_raw = entry.get("context", [])
98
+ if not isinstance(context_raw, list):
99
+ raise PlotsSpecError(f"{at}.context: must be a list of names")
100
+ context: list[str] = []
101
+ for j, one in enumerate(context_raw):
102
+ c_at = f"{at}.context[{j}]"
103
+ name = _one_name(one, c_at, "a context series names")
104
+ if name == var:
105
+ raise PlotsSpecError(f"{c_at}: {name!r} is the plot's own variable")
106
+ if name in context:
107
+ raise PlotsSpecError(f"{c_at}: {name!r} is already in the context")
108
+ context.append(name)
109
+ extras.append(PlotExtra(var, tuple(context)))
110
+ return tuple(extras)
111
+
112
+
113
+ def plots_to_raw(extras: Sequence[PlotExtra]) -> list[dict]:
114
+ """The JSON a writer stores: ``context`` only when there is some."""
115
+ return [
116
+ {"var": e.var, **({"context": list(e.context)} if e.context else {})}
117
+ for e in extras
118
+ ]
119
+
120
+
121
+ def tree_leaves(spec: SpecNode | None) -> list[LeafSpec] | None:
122
+ """The leaves of ``spec`` that name a series of the result, depth
123
+ first; ``None`` for no tree. A stored-scalar leaf (a string
124
+ ``quantity``) names a statistic, not a series, and has no plot.
125
+
126
+ Walks the SPEC, never the materialized tree: the Session's plots are
127
+ painted before any baseline is read."""
128
+ if spec is None:
129
+ return None
130
+ from ..comparison.tree_spec import LeafSpec, resolves_a_variable
131
+
132
+ out: list[LeafSpec] = []
133
+
134
+ def walk(node: SpecNode) -> None:
135
+ if isinstance(node, LeafSpec):
136
+ if resolves_a_variable(node):
137
+ out.append(node)
138
+ return
139
+ for child in node.children:
140
+ walk(child)
141
+
142
+ walk(spec)
143
+ return out
144
+
145
+
146
+ def plot_leaves(test: TestModel, compared: Sequence[str]) -> list[LeafSpec] | None:
147
+ """The leaves a test's plots derive from, in tree order: its authored
148
+ ``metrics`` tree — even with nothing compared yet, a test with no
149
+ baseline: the tree names what will be scored — else the implicit tree of
150
+ what was ``compared``; ``None`` with neither (one plot per tracked
151
+ variable, the owner's ruling O2). ONE rule, for the tool's
152
+ `Session.plots` and the report."""
153
+ from ..comparison.tree_spec import synthesize_implicit_tree
154
+
155
+ if test.metric_tree_spec is not None:
156
+ return tree_leaves(test.metric_tree_spec)
157
+ if not compared:
158
+ return None
159
+ return tree_leaves(
160
+ synthesize_implicit_tree(
161
+ list(compared), variable_overrides=test.variable_overrides
162
+ )
163
+ )
164
+
165
+
166
+ def derive_plots(
167
+ leaves: Sequence[LeafSpec] | None,
168
+ extras: Sequence[PlotExtra],
169
+ *,
170
+ tracked: Sequence[str],
171
+ unit_tests: Collection[str] = (),
172
+ available: Iterable[str] | None = None,
173
+ ) -> tuple[ResolvedPlot, ...]:
174
+ """The plots both tiers draw for one test.
175
+
176
+ ``leaves`` are the tree's (:func:`plot_leaves`), in depth-first order;
177
+ ``None`` (no tree: nothing scored) derives from the ``tracked`` names
178
+ instead. Their variables fold into array families by RESULT-FILE NAME
179
+ (the :mod:`~dstf.figures.families` rule over the names: ``pipe.T[1]`` ..
180
+ ``pipe.T[4]`` are one plot), with no scale check — except the
181
+ ``unit_tests`` names, each its own plot whatever its expression (the
182
+ owner's ruling O3: a UnitTests element is a test quantity of its own,
183
+ and `check_atm`'s local ``x`` holds 1 Pa in atm beside 1 atm in Pa).
184
+ One plot per family or lone variable, where the tree first names it — a
185
+ family where the tree first names any member.
186
+
187
+ Each extra on a member of a derived subject adds its context there;
188
+ every other extra is a watch plot, after the derived ones, in list
189
+ order.
190
+
191
+ ``available`` is every name the plots can be drawn from — the result
192
+ file's listing and the result's own variables — or ``None`` when it
193
+ cannot be listed: nothing is missing then."""
194
+ from ..figures.families import group_families
195
+
196
+ if leaves is None:
197
+ order = list(dict.fromkeys(n for n in tracked if n))
198
+ else:
199
+ order = list(dict.fromkeys(leaf.variable for leaf in leaves if leaf.variable))
200
+ apart = set(unit_tests)
201
+ family_of: dict[str, tuple[str, ...]] = {
202
+ name: (name,) for name in order if name in apart
203
+ }
204
+ for _, members in group_families([n for n in order if n not in apart]):
205
+ for name in members:
206
+ family_of[name] = tuple(members)
207
+ subjects = list(dict.fromkeys(family_of[name] for name in order))
208
+ member_of = {name: i for i, subject in enumerate(subjects) for name in subject}
209
+ context: list[list[str]] = [[] for _ in subjects]
210
+ watch: list[PlotExtra] = []
211
+ for extra in extras:
212
+ at = member_of.get(extra.var)
213
+ if at is None:
214
+ watch.append(extra)
215
+ continue
216
+ for name in extra.context:
217
+ if name not in subjects[at] and name not in context[at]:
218
+ context[at].append(name)
219
+ have = None if available is None else set(available)
220
+ rows = [(s, tuple(c), False) for s, c in zip(subjects, context)]
221
+ rows += [((e.var,), e.context, True) for e in watch]
222
+ return tuple(
223
+ ResolvedPlot(
224
+ index=i,
225
+ subject=subject,
226
+ context=ctx,
227
+ watch=is_watch,
228
+ missing=tuple(
229
+ n for n in subject + ctx if have is not None and n not in have
230
+ ),
231
+ )
232
+ for i, (subject, ctx, is_watch) in enumerate(rows)
233
+ )
234
+
235
+
236
+ def plots_of_test(
237
+ test: TestModel,
238
+ compared: Sequence[str],
239
+ result: TestResult | None,
240
+ *,
241
+ listed: Iterable[str] | None,
242
+ ) -> tuple[ResolvedPlot, ...]:
243
+ """The plots of one test — ONE rule, called by the tool's
244
+ `Session.plots` and the report's `plot_inputs`, so both tiers draw the
245
+ same list.
246
+
247
+ The leaves are :func:`plot_leaves`', the tracked names
248
+ :func:`tracked_names`', the extras the entry's ``plots`` key. Which
249
+ names are UnitTests elements — never folded (O3) — is asked of every
250
+ leaf AND every tracked name: a leaf the result does not carry is an
251
+ element all the same.
252
+
253
+ ``listed`` is the result FILE's names, or ``None`` when it was not
254
+ listed (the report lists it only for a test with extras). A name the
255
+ result in memory carries is never missing — the file's listing may be
256
+ filtered; with no listing, what the result carries is all the plots can
257
+ draw, and a leaf it lacks is missing. With no result either, nothing is
258
+ missing.
259
+
260
+ A run that FAILED has no plots, extras included (O2, literally): what a
261
+ backend wrote before it stopped is a partial file, not the test's
262
+ plots."""
263
+ from .unit_tests import is_unit_test_variable
264
+
265
+ if result is not None and not result.success:
266
+ return ()
267
+ leaves = plot_leaves(test, compared)
268
+ tracked = tracked_names(compared, result)
269
+ names = [*(leaf.variable for leaf in leaves or ()), *tracked]
270
+ available: list[str] | None = None
271
+ if result is not None:
272
+ available = [v.name for v in result.variables if v.name]
273
+ if listed is not None:
274
+ available = [*listed, *(available or ())]
275
+ return derive_plots(
276
+ leaves,
277
+ tuple(test.plots or ()),
278
+ tracked=tracked,
279
+ unit_tests=[n for n in names if is_unit_test_variable(test, n)],
280
+ available=available,
281
+ )
282
+
283
+
284
+ def tracked_names(scored: Sequence[str], result: object) -> list[str]:
285
+ """The variables a test HAS a plot for by default, in the order both
286
+ tiers draw them: what the comparison scored, then the rest of the loaded
287
+ result's, de-duplicated. ONE rule, called by the tool's `Session.tracked`
288
+ and by the report's `plot_inputs` — the two drew different default
289
+ layouts (the report one plot per scored variable, the tool the union),
290
+ and the owner's ruling is that both tiers draw the same plots.
291
+
292
+ Every name here is the result file's (a UnitTests variable's is
293
+ ``<instance>.x[i]``) and nothing relabels it, so a variable the tree
294
+ scores and the result carries is one name and is listed once — listing
295
+ the same trajectory under two spellings drew it twice (the 3b review). A
296
+ result variable with no name has nothing to plot by and is skipped; a
297
+ tree leaf's variable, on the other hand, is listed whether or not the
298
+ result carries it, because ``scored`` is what the test's author wrote.
299
+
300
+ A result of a simulation that FAILED contributes nothing: what a backend
301
+ wrote before it stopped is a partial file, not the test's plots, and the
302
+ page drew a card per half-written variable (the final review).
303
+
304
+ ``scored`` comes first even when the tree's order differs from the
305
+ result file's. The result's own come in its order — the spec's
306
+ patterns' order (`resolve_variable_patterns`: explicit names as
307
+ written, a glob's matches in the file's order), never sorted."""
308
+ names = [name for name in scored if name]
309
+ have = set(names)
310
+ out = list(dict.fromkeys(names))
311
+ if not getattr(result, "success", True):
312
+ return out
313
+ for var in getattr(result, "variables", None) or ():
314
+ if not var.name or var.name in have:
315
+ continue
316
+ have.add(var.name)
317
+ out.append(var.name)
318
+ return out