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,413 @@
1
+ """JSON-Schema export for ``test_spec.json`` (PHASE_6_PLAN 6.4.5).
2
+
3
+ Derives a JSON-Schema document from:
4
+ * every registered metric's Config dataclass (reusing the 6.1.1
5
+ introspection — one definition per mode);
6
+ * the :class:`LeafSpec` / :class:`CombinatorSpec` grammar (the
7
+ MetricTree shape);
8
+ * the per-test entry shape (simulation block, comparison block,
9
+ variables, metrics tree, window, baselines).
10
+
11
+ Emits JSON-Schema draft 2020-12 shape. Thin layer — no validation
12
+ logic here (that lives in ``comparison/validator.py`` + parse-time).
13
+ Downstream uses: IDE autocomplete, LLM authoring, tool handoff.
14
+
15
+ The schema is as strict as the parser: wherever a key nobody reads is a
16
+ spec error (``comparison/strict_keys.py``) the object says
17
+ ``additionalProperties: false``, and its property names come from the
18
+ same data the parser checks against — a metric's config dataclass, the
19
+ tree parser's key tables, ``discovery/spec_shape.py`` for the entry.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import copy
25
+ import dataclasses
26
+ from typing import Any
27
+
28
+ from ..comparison.mode_schema import derive_schema
29
+ from ..comparison.modes import QUANTITY_KEYS
30
+ from ..comparison.registry import (
31
+ combinator_names,
32
+ metric_names,
33
+ metrics,
34
+ unnamed_metrics,
35
+ )
36
+ from ..comparison.strict_keys import EVERY_METRIC, declares_quantity, row_fields
37
+ from ..comparison.tree_spec import COMBINATOR_KIND_KEYS
38
+ from ..discovery import spec_shape
39
+
40
+ _JSON_SCHEMA_URI = "https://json-schema.org/draft/2020-12/schema"
41
+
42
+ #: A tolerance with two sides: one number (both sides), or exactly
43
+ #: ``lower`` and ``upper`` — what :func:`~dstf.comparison.sides.parse_sides`
44
+ #: takes. A pair with one side, or with a third key, is neither.
45
+ _SIDES = [
46
+ {"type": "number"},
47
+ {
48
+ "type": "object",
49
+ "properties": {"lower": {"type": "number"}, "upper": {"type": "number"}},
50
+ "required": ["lower", "upper"],
51
+ "additionalProperties": False,
52
+ },
53
+ ]
54
+
55
+
56
+ def _field_to_schema(field) -> dict:
57
+ """Translate a 6.1.1 FieldSpec into a JSON-Schema property dict."""
58
+ node: dict[str, Any] = {}
59
+ if field.type == "float":
60
+ node["type"] = ["number", "null"] if field.optional else "number"
61
+ elif field.type == "int":
62
+ node["type"] = ["integer", "null"] if field.optional else "integer"
63
+ elif field.type == "bool":
64
+ node["type"] = ["boolean", "null"] if field.optional else "boolean"
65
+ elif field.type == "str":
66
+ node["type"] = ["string", "null"] if field.optional else "string"
67
+ elif field.type == "enum":
68
+ base: dict[str, Any] = {"type": "string", "enum": list(field.choices or [])}
69
+ if field.optional:
70
+ node["oneOf"] = [base, {"type": "null"}]
71
+ else:
72
+ node.update(base)
73
+ elif field.type == "sides":
74
+ node["oneOf"] = copy.deepcopy(_SIDES)
75
+ elif field.type == "passthrough":
76
+ # Complex / unknown — allow any JSON shape. Documentation hints
77
+ # via description if we have one.
78
+ node = {}
79
+ if field.default is not None:
80
+ node["default"] = field.default
81
+ if field.label:
82
+ node["title"] = field.label
83
+ if field.help:
84
+ node["description"] = field.help
85
+ return node
86
+
87
+
88
+ def _modes() -> list[tuple[str, type]]:
89
+ """Registered metrics in registration order, as ``(name, config_cls)``.
90
+
91
+ Single source for both the ``$defs`` emission and the leaf's if/then
92
+ wiring (finding 72) — and the reason a registered metric shows up in
93
+ the exported schema without editing this module.
94
+ """
95
+ return [(desc.name, desc.config_cls) for desc in metrics()]
96
+
97
+
98
+ def _mode_def_name(mode_name: str) -> str:
99
+ return f"mode_{mode_name.replace('-', '_')}"
100
+
101
+
102
+ def _quantity_metric_names() -> list[str]:
103
+ """Registered metrics whose ``quantity`` may stand in for ``variable``.
104
+
105
+ Mirrors the parser rule in :func:`~dstf.comparison.tree_spec._parse_leaf`
106
+ — both read ``MetricDescriptor.accepts_quantity``, so the exported
107
+ grammar and the one the CLI enforces can't drift apart.
108
+ """
109
+ return [desc.name for desc in metrics() if desc.accepts_quantity]
110
+
111
+
112
+ def _leaf_identity_rule() -> dict:
113
+ """The "how a leaf names its data" requirement, as JSON-Schema.
114
+
115
+ Every leaf needs ``metric``. It then needs either a ``variable`` (the
116
+ tracked series it scores) or — only for a metric that declares
117
+ ``accepts_quantity`` — a string ``quantity`` naming a stored scalar.
118
+ """
119
+ alternatives: list[dict] = [{"required": ["variable"]}]
120
+ quantity_metrics = _quantity_metric_names()
121
+ if quantity_metrics:
122
+ alternatives.append(
123
+ {
124
+ "properties": {
125
+ "metric": {"enum": quantity_metrics},
126
+ "quantity": {"type": "string", "minLength": 1},
127
+ },
128
+ "required": ["metric", "quantity"],
129
+ }
130
+ )
131
+ return {"anyOf": alternatives}
132
+
133
+
134
+ def _leaf_common_properties() -> dict[str, Any]:
135
+ """Leaf keys that co-reside with mode-specific knobs in the same object.
136
+
137
+ Shared between the leaf spec and every mode ``$def`` — the mode defs are
138
+ ``$ref``'d from inside the leaf (if metric == <mode> then $ref), so they
139
+ describe the same flat object and must list these keys for editor/LLM
140
+ autocomplete (review 2026-07-06 finding 72).
141
+ """
142
+ return {
143
+ "metric": {
144
+ "type": "string",
145
+ "enum": sorted(metric_names()),
146
+ "description": "The per-variable metric to score with.",
147
+ },
148
+ "variable": {
149
+ "type": "string",
150
+ "description": "Variable name as reported by the backend.",
151
+ },
152
+ "against": {
153
+ "type": "string",
154
+ "default": "primary",
155
+ "description": (
156
+ "Which baseline to score against — 'primary' or the name "
157
+ "of a registered soft_check. Companions are not scorable."
158
+ ),
159
+ },
160
+ "window": {
161
+ "type": "object",
162
+ "description": "Scope the leaf to [start, end] (idea #46).",
163
+ "properties": {
164
+ "start": {"type": "number"},
165
+ "end": {"type": "number"},
166
+ },
167
+ "additionalProperties": False,
168
+ },
169
+ }
170
+
171
+
172
+ def _param_properties(config_cls: type, mode_name: str) -> dict[str, Any] | None:
173
+ """One metric's params as schema properties: its config's fields — a
174
+ row table's rows closed to its ``columns`` — and the two params read
175
+ off every leaf (``strict_keys.EVERY_METRIC``). None for a metric whose
176
+ config is not a dataclass: nothing says what it reads, the parser
177
+ does not check it, and neither does the schema."""
178
+ if not dataclasses.is_dataclass(config_cls):
179
+ return None
180
+ props: dict[str, Any] = {
181
+ # A leaf's own tolerance, a number or a pair, where the metric's
182
+ # config does not type it itself; `quantity` of any shape.
183
+ "tolerance": {"oneOf": copy.deepcopy(_SIDES)},
184
+ "quantity": {},
185
+ }
186
+ assert set(props) == set(EVERY_METRIC)
187
+ for f in derive_schema(config_cls, mode=mode_name).fields:
188
+ props[f.name] = _field_to_schema(f)
189
+ if declares_quantity(mode_name):
190
+ # Read here: a path, or an object closed to the keys the parser
191
+ # takes inside one (`strict_keys`).
192
+ props["quantity"] = {
193
+ **props["quantity"],
194
+ "oneOf": [
195
+ {"type": "string"},
196
+ {
197
+ "type": "object",
198
+ "properties": {key: {} for key in QUANTITY_KEYS},
199
+ "additionalProperties": False,
200
+ },
201
+ {"type": "null"},
202
+ ],
203
+ }
204
+ for name, columns in row_fields(mode_name).items():
205
+ props[name] = {
206
+ **props[name],
207
+ "type": ["array", "null"],
208
+ "items": {
209
+ "type": "object",
210
+ "properties": {column: {} for column in sorted(columns)},
211
+ "additionalProperties": False,
212
+ },
213
+ }
214
+ return props
215
+
216
+
217
+ def _mode_def(config_cls: type, mode_name: str) -> dict:
218
+ """A leaf of one metric, whole: the leaf's own keys and the metric's
219
+ params, and nothing else — the parser refuses any other key (it was
220
+ lenient until a misspelled bound scored as no bound at all)."""
221
+ params = _param_properties(config_cls, mode_name)
222
+ node: dict[str, Any] = {
223
+ "type": "object",
224
+ "title": f"{mode_name} mode config",
225
+ "properties": {**_leaf_common_properties(), **(params or {})},
226
+ }
227
+ if params is not None:
228
+ node["additionalProperties"] = False
229
+ return node
230
+
231
+
232
+ def _override_def_name(mode_name: str) -> str:
233
+ return f"override_{mode_name.replace('-', '_')}"
234
+
235
+
236
+ def _override_def(config_clss: list[tuple[str, type]], title: str) -> dict:
237
+ """A flat ``comparison.variable_overrides`` entry scored by one of
238
+ ``config_clss`` (more than one for an entry with no ``mode``): ``mode``
239
+ and those metrics' params, and nothing else."""
240
+ props: dict[str, Any] = {"mode": {"type": "string", "enum": sorted(metric_names())}}
241
+ closed = True
242
+ for name, config_cls in config_clss:
243
+ params = _param_properties(config_cls, name)
244
+ closed = closed and params is not None
245
+ props.update(params or {})
246
+ node: dict[str, Any] = {"type": "object", "title": title, "properties": props}
247
+ if closed:
248
+ node["additionalProperties"] = False
249
+ return node
250
+
251
+
252
+ def _override_defs(modes: list[tuple[str, type]]) -> dict[str, dict]:
253
+ """``override`` — one entry of ``variable_overrides`` — and the def per
254
+ mode it selects by its ``mode`` key. With no ``mode`` the entry is
255
+ scored by the default metric, or by ``points`` under
256
+ ``--default-points`` (``registry.unnamed_metrics``): it may carry
257
+ what either reads."""
258
+ by_name = dict(modes)
259
+ defs = {
260
+ _override_def_name(name): _override_def([(name, cls)], f"{name} override")
261
+ for name, cls in modes
262
+ }
263
+ defs["override_without_mode"] = _override_def(
264
+ [(name, by_name[name]) for name in unnamed_metrics() if name in by_name],
265
+ "override with no mode",
266
+ )
267
+ defs["override"] = {
268
+ "type": "object",
269
+ "title": "Flat per-variable override",
270
+ "allOf": [
271
+ {
272
+ "if": {"required": ["mode"]},
273
+ "else": {"$ref": "#/$defs/override_without_mode"},
274
+ },
275
+ *(
276
+ {
277
+ "if": {
278
+ "properties": {"mode": {"const": name}},
279
+ "required": ["mode"],
280
+ },
281
+ "then": {"$ref": f"#/$defs/{_override_def_name(name)}"},
282
+ }
283
+ for name, _ in modes
284
+ ),
285
+ ],
286
+ }
287
+ return defs
288
+
289
+
290
+ def _combinator_kind_rules() -> list[dict]:
291
+ """Each key only some kinds read (``k``, ``weights``, ...) is forbidden
292
+ on every other kind — the parser's own table, so the two agree."""
293
+ keys = sorted({key for kind in COMBINATOR_KIND_KEYS.values() for key in kind})
294
+ return [
295
+ {
296
+ "if": {
297
+ "properties": {
298
+ "combinator": {
299
+ "enum": sorted(
300
+ kind
301
+ for kind, takes in COMBINATOR_KIND_KEYS.items()
302
+ if key in takes
303
+ )
304
+ }
305
+ },
306
+ "required": ["combinator"],
307
+ },
308
+ "else": {"properties": {key: False}},
309
+ }
310
+ for key in keys
311
+ ]
312
+
313
+
314
+ def build_schema() -> dict:
315
+ """Build the top-level JSON-Schema document."""
316
+ modes = _modes()
317
+ mode_defs = {_mode_def_name(name): _mode_def(cls, name) for name, cls in modes}
318
+
319
+ leaf_spec = {
320
+ "type": "object",
321
+ "title": "Metric tree leaf",
322
+ # ``variable`` is not unconditionally required: a metric that
323
+ # declares ``accepts_quantity`` may name its data with a string
324
+ # ``quantity`` instead (see _leaf_identity_rule, folded into allOf
325
+ # below so it composes with the per-mode if/then clauses).
326
+ "required": ["metric"],
327
+ # Mode-specific knobs live inline alongside metric/variable per the
328
+ # historical override shape — e.g., a leaf with metric=tube may set
329
+ # tube_rel, tube_width_mode, etc. (typed via the allOf below).
330
+ "properties": _leaf_common_properties(),
331
+ # review 2026-07-06 finding 72: make the mode $defs real — the
332
+ # ``metric`` discriminator selects the matching mode definition, so
333
+ # validators type-check mode knobs and editors get autocomplete.
334
+ # Each of those defs is closed (``additionalProperties: false``):
335
+ # that is where a leaf's unknown key is refused.
336
+ "allOf": [
337
+ _leaf_identity_rule(),
338
+ *(
339
+ {
340
+ "if": {
341
+ "properties": {"metric": {"const": name}},
342
+ "required": ["metric"],
343
+ },
344
+ "then": {"$ref": f"#/$defs/{_mode_def_name(name)}"},
345
+ }
346
+ for name, _ in modes
347
+ ),
348
+ ],
349
+ }
350
+
351
+ combinator_spec = {
352
+ "type": "object",
353
+ "title": "Metric tree combinator",
354
+ "required": ["combinator", "children"],
355
+ "properties": {
356
+ "combinator": {
357
+ "type": "string",
358
+ "enum": sorted(combinator_names()),
359
+ },
360
+ "children": {
361
+ "type": "array",
362
+ "items": {"$ref": "#/$defs/tree_node"},
363
+ "minItems": 1,
364
+ },
365
+ "k": {
366
+ "type": "integer",
367
+ "minimum": 1,
368
+ "description": "Required when combinator = 'k-of-n'.",
369
+ },
370
+ "weights": {
371
+ "type": "array",
372
+ "items": {"type": "number"},
373
+ "description": "Weights for 'weighted' combinator; parallel to children.",
374
+ },
375
+ "threshold": {"type": "number"},
376
+ "direction": {"type": "string", "enum": ["less", "greater"]},
377
+ },
378
+ "additionalProperties": False,
379
+ "allOf": _combinator_kind_rules(),
380
+ }
381
+
382
+ test_entry = {
383
+ "type": "object",
384
+ "required": ["model"],
385
+ # The entry's keys, and its simulation and comparison blocks': the
386
+ # one table the spec parser checks an entry against.
387
+ "properties": copy.deepcopy(spec_shape.ENTRY),
388
+ "additionalProperties": False,
389
+ }
390
+
391
+ return {
392
+ "$schema": _JSON_SCHEMA_URI,
393
+ "title": "DSTF test_spec.json",
394
+ "type": "object",
395
+ "required": ["tests"],
396
+ # The root's own keys — `tests`, and the `$schema` that points an
397
+ # editor here — and no other: the parser refuses one (`spec_shape`).
398
+ "properties": copy.deepcopy(spec_shape.ROOT),
399
+ "additionalProperties": False,
400
+ "$defs": {
401
+ "test_entry": test_entry,
402
+ "tree_node": {
403
+ "oneOf": [
404
+ {"$ref": "#/$defs/leaf"},
405
+ {"$ref": "#/$defs/combinator"},
406
+ ]
407
+ },
408
+ "leaf": leaf_spec,
409
+ "combinator": combinator_spec,
410
+ **mode_defs,
411
+ **_override_defs(modes),
412
+ },
413
+ }