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,332 @@
1
+ """MetricTree: composition of metric results with Boolean / weighted combinators.
2
+
3
+ See docs/vision.md "Metric composition" and docs/extensibility.md §6.
4
+
5
+ A spec with no ``metrics`` tree collapses to a degenerate flat-AND over
6
+ per-variable ``VariableComparison`` results (``implicit_and_tree``), which is
7
+ exactly the implicit semantics. An explicit ``metrics`` tree in
8
+ ``test_spec.json`` reaches the and / or / k-of-n / warn / weighted
9
+ combinators below.
10
+
11
+ Design contract:
12
+
13
+ * Combinators are pure functions of child ``MetricResult``s. No I/O, no mutation.
14
+ * A combinator's output ``diagnostics`` must preserve enough of its children's
15
+ diagnostics that a report can show which branch failed and why.
16
+ * Leaves wrap a ``VariableComparison``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from abc import ABC, abstractmethod
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, cast
24
+
25
+ from .comparator import VariableComparison
26
+ from .sides import as_written
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Uniform metric result (combinator output shape)
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ @dataclass
34
+ class MetricResult:
35
+ """Uniform shape produced by both leaves and combinators.
36
+
37
+ ``VariableComparison`` is a richer, reporting-friendly leaf result; this
38
+ is the narrow type combinators pass around. A leaf is adapted to a
39
+ ``MetricResult`` on the way in via :func:`leaf_from_variable`.
40
+
41
+ Fields:
42
+ passed — overall pass/fail after combinator logic.
43
+ score — numeric score; interpretation is combinator-dependent
44
+ (min NRMSE for AND, max NRMSE for OR, etc.). Not normalized
45
+ in general. A value of ``None`` means "no meaningful score"
46
+ (e.g. after a ``warn`` wrapper).
47
+ label — short human-readable tag for reports (e.g. a variable name
48
+ or a combinator name + children count).
49
+ diagnostics — open-ended structured extras. Reports render these.
50
+ children — empty for leaves; populated for combinator nodes so the
51
+ tree shape is reconstructible.
52
+ """
53
+
54
+ passed: bool
55
+ score: float | None
56
+ label: str = ""
57
+ diagnostics: dict[str, Any] = field(default_factory=dict)
58
+ children: list[MetricResult] = field(default_factory=list)
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Combinator protocol
63
+ # ---------------------------------------------------------------------------
64
+
65
+
66
+ class Combinator(ABC):
67
+ """Strategy interface for combining child metric results into one."""
68
+
69
+ #: Short identifier used in config / diagnostics.
70
+ name: str = ""
71
+
72
+ @abstractmethod
73
+ def combine(self, children: list[MetricResult]) -> MetricResult: ...
74
+
75
+
76
+ class AndCombinator(Combinator):
77
+ """All children must pass. Score is ``min`` over the children's scores.
78
+
79
+ Matches the implicit behavior of a spec with no ``metrics`` tree: a test
80
+ passes iff every variable's metric passes.
81
+
82
+ ``min`` is only "the worst child" for lower-is-better metrics. Children
83
+ can mix directions (the registry's ``score_direction`` is ``"lower"``
84
+ for NRMSE-like scores, ``"higher"`` for tube coverage), and this node
85
+ does not consult it — ``passed`` is a pure Boolean AND and is always
86
+ right; the reported score is a summary number, not a ranking.
87
+ """
88
+
89
+ name = "and"
90
+
91
+ def combine(self, children: list[MetricResult]) -> MetricResult:
92
+ if not children:
93
+ # Vacuously true. Score undefined; pick None rather than lie.
94
+ return MetricResult(passed=True, score=None, label="and[0]", children=[])
95
+ passed = all(c.passed for c in children)
96
+ scores = [c.score for c in children if c.score is not None]
97
+ score = min(scores) if scores else None
98
+ return MetricResult(
99
+ passed=passed,
100
+ score=score,
101
+ label=f"and[{len(children)}]",
102
+ diagnostics={"n_failed": sum(1 for c in children if not c.passed)},
103
+ children=list(children),
104
+ )
105
+
106
+
107
+ class OrCombinator(Combinator):
108
+ """At least one child must pass. Score is ``max`` over the children's scores.
109
+
110
+ Same caveat as :class:`AndCombinator`: ``max`` is only "the best child"
111
+ for higher-is-better metrics, and this node does not consult the
112
+ registry's ``score_direction``. ``passed`` (a Boolean OR) is unaffected.
113
+ """
114
+
115
+ name = "or"
116
+
117
+ def combine(self, children: list[MetricResult]) -> MetricResult:
118
+ if not children:
119
+ # Vacuously false for OR (no children = no passing child).
120
+ return MetricResult(passed=False, score=None, label="or[0]", children=[])
121
+ passed = any(c.passed for c in children)
122
+ scores = [c.score for c in children if c.score is not None]
123
+ score = max(scores) if scores else None
124
+ return MetricResult(
125
+ passed=passed,
126
+ score=score,
127
+ label=f"or[{len(children)}]",
128
+ diagnostics={"n_passed": sum(1 for c in children if c.passed)},
129
+ children=list(children),
130
+ )
131
+
132
+
133
+ class KOfNCombinator(Combinator):
134
+ """At least K of N children must pass."""
135
+
136
+ name = "k-of-n"
137
+
138
+ def __init__(self, k: int):
139
+ if k < 0:
140
+ raise ValueError("k must be non-negative")
141
+ self.k = k
142
+
143
+ def combine(self, children: list[MetricResult]) -> MetricResult:
144
+ n_passed = sum(1 for c in children if c.passed)
145
+ passed = n_passed >= self.k
146
+ scores = [c.score for c in children if c.score is not None]
147
+ # Heuristic: score = K-th best child score (or worst if K > len).
148
+ score: float | None
149
+ if not scores:
150
+ score = None
151
+ else:
152
+ scores_sorted = sorted(scores, reverse=True)
153
+ idx = min(self.k - 1, len(scores_sorted) - 1) if self.k > 0 else 0
154
+ score = scores_sorted[idx] if scores_sorted else None
155
+ return MetricResult(
156
+ passed=passed,
157
+ score=score,
158
+ label=f"k-of-n[{self.k}/{len(children)}]",
159
+ diagnostics={"k": self.k, "n": len(children), "n_passed": n_passed},
160
+ children=list(children),
161
+ )
162
+
163
+
164
+ class WeightedCombinator(Combinator):
165
+ """Weighted sum of child scores against a threshold (4.E).
166
+
167
+ Pass condition is direction-aware:
168
+ - ``direction="less"`` (default — NRMSE-like; lower is better):
169
+ ``sum(w_i * score_i) < threshold``.
170
+ - ``direction="greater"`` (tube-like; higher is better):
171
+ ``sum(w_i * score_i) > threshold``.
172
+
173
+ All children must produce a numeric score; if any child has ``score=None``,
174
+ the weighted node fails (with a diagnostic explaining why) since the
175
+ aggregate is undefined.
176
+ """
177
+
178
+ name = "weighted"
179
+
180
+ def __init__(self, weights: list[float], threshold: float, direction: str = "less"):
181
+ if direction not in ("less", "greater"):
182
+ raise ValueError(
183
+ f"weighted: direction must be 'less' or 'greater', got {direction!r}"
184
+ )
185
+ if not weights:
186
+ raise ValueError("weighted: requires at least one weight")
187
+ self.weights = list(weights)
188
+ self.threshold = float(threshold)
189
+ self.direction = direction
190
+
191
+ def combine(self, children: list[MetricResult]) -> MetricResult:
192
+ if len(children) != len(self.weights):
193
+ raise ValueError(
194
+ f"weighted: weights ({len(self.weights)}) must match "
195
+ f"children ({len(children)})"
196
+ )
197
+ if any(c.score is None for c in children):
198
+ return MetricResult(
199
+ passed=False,
200
+ score=None,
201
+ label=f"weighted[{len(children)}]",
202
+ diagnostics={
203
+ "reason": "child has no numeric score; weighted sum undefined",
204
+ "weights": list(self.weights),
205
+ "threshold": self.threshold,
206
+ "direction": self.direction,
207
+ },
208
+ children=list(children),
209
+ )
210
+ # Every child has a numeric score — the guard above returned otherwise.
211
+ weighted_sum = sum(
212
+ w * cast(float, c.score) for w, c in zip(self.weights, children)
213
+ )
214
+ passed = (
215
+ weighted_sum < self.threshold
216
+ if self.direction == "less"
217
+ else weighted_sum > self.threshold
218
+ )
219
+ return MetricResult(
220
+ passed=passed,
221
+ score=weighted_sum,
222
+ label=f"weighted[{len(children)}]",
223
+ diagnostics={
224
+ "weighted_sum": weighted_sum,
225
+ "threshold": self.threshold,
226
+ "direction": self.direction,
227
+ "weights": list(self.weights),
228
+ },
229
+ children=list(children),
230
+ )
231
+
232
+
233
+ class WarnCombinator(Combinator):
234
+ """Single-child wrapper that always passes the parent but surfaces the
235
+ child's diagnostics as warnings.
236
+
237
+ Used when an additional baseline comparison is informational (e.g. an
238
+ experimental-data overlay) and should not gate pass/fail. A ``warn`` node
239
+ always reports ``passed=True``; the report renders the child's failure
240
+ (if any) as a warning rather than a failure.
241
+ """
242
+
243
+ name = "warn"
244
+
245
+ def combine(self, children: list[MetricResult]) -> MetricResult:
246
+ if len(children) != 1:
247
+ raise ValueError(
248
+ f"warn combinator expects exactly 1 child, got {len(children)}"
249
+ )
250
+ child = children[0]
251
+ # Surface child's failure (if any) as a warning in diagnostics; parent
252
+ # passes regardless. Score is intentionally None — a warn branch does
253
+ # not contribute to a meaningful aggregate score.
254
+ warned = not child.passed
255
+ return MetricResult(
256
+ passed=True,
257
+ score=None,
258
+ label="warn",
259
+ diagnostics={"warned": warned, "child_label": child.label},
260
+ children=[child],
261
+ )
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # Leaf adapter + implicit-tree construction
266
+ # ---------------------------------------------------------------------------
267
+
268
+
269
+ def leaf_from_variable(vc: VariableComparison) -> MetricResult:
270
+ """Adapt a per-variable ``VariableComparison`` to a leaf ``MetricResult``.
271
+
272
+ The numeric ``score`` is chosen per mode:
273
+ * NRMSE — ``vc.nrmse`` (lower is better; worst = min)
274
+ * tube — ``vc.tube_points_inside`` (higher is better; uses OR if
275
+ composed with another tube node)
276
+ * final — ``vc.nrmse`` (same semantics)
277
+
278
+ Callers that need the raw ``VariableComparison`` can recover it from
279
+ ``result.diagnostics['variable']``.
280
+ """
281
+ if vc.mode == "tube" and vc.tube_points_inside is not None:
282
+ score = vc.tube_points_inside
283
+ else:
284
+ score = vc.nrmse
285
+ diagnostics = {
286
+ "mode": vc.mode,
287
+ "tolerance": as_written(vc.tolerance_used),
288
+ "max_abs_error": vc.max_abs_error,
289
+ "variable": vc,
290
+ }
291
+ if vc.against is not None:
292
+ # The baseline the criterion read; none where it reads none.
293
+ diagnostics["against"] = vc.against
294
+ return MetricResult(
295
+ passed=vc.passed,
296
+ score=score,
297
+ label=vc.name,
298
+ diagnostics=diagnostics,
299
+ children=[],
300
+ )
301
+
302
+
303
+ def stamp_leaf_path(leaf: MetricResult, path: str) -> None:
304
+ """Address a leaf result by its RFC 6902 path — the same path a patch op uses.
305
+
306
+ Prefixes the stashed ``VariableComparison``'s geometry ids with the path
307
+ so a figure handle and a spec edit share one name. Idempotent: a mode
308
+ emits local ids, and a stamped id starts with ``/``.
309
+ """
310
+ leaf.diagnostics["leaf_path"] = path
311
+ vc = leaf.diagnostics.get("variable")
312
+ if vc is not None:
313
+ vc.geometry = tuple(
314
+ p if p.id.startswith("/") else p.prefixed(path) for p in vc.geometry
315
+ )
316
+
317
+
318
+ def implicit_and_tree(variables: list[VariableComparison]) -> MetricResult:
319
+ """Build the implicit flat-AND tree from a list of per-variable comparisons.
320
+
321
+ This is the degenerate MetricTree that matches the current pass/fail
322
+ semantics exactly: a test passes iff every variable passes. Leaves are
323
+ stamped ``/metrics/children/<i>`` — the paths
324
+ :func:`~.tree_spec.synthesize_implicit_tree` produces for the same list,
325
+ so the report can look leaves up by path on both evaluation routes.
326
+ """
327
+ leaves = []
328
+ for i, vc in enumerate(variables):
329
+ leaf = leaf_from_variable(vc)
330
+ stamp_leaf_path(leaf, f"/metrics/children/{i}")
331
+ leaves.append(leaf)
332
+ return AndCombinator().combine(leaves)
@@ -0,0 +1,176 @@
1
+ """UI schema derived from a mode's Config dataclass — what ``export-schema``
2
+ writes as JSON-Schema and the authoring tool's form emitter renders.
3
+
4
+ Each :class:`ComparisonMode`'s Config is a frozen dataclass with strictly
5
+ typed fields. :func:`derive_schema` reads those types via
6
+ :func:`typing.get_type_hints` and returns the introspected field list —
7
+ types, defaults, ``Literal`` choices and the display metadata — as a data
8
+ artifact. :mod:`dstf.reporting.schema_export` turns it into the published
9
+ JSON Schema; the authoring tool builds its forms from the same
10
+ introspection. The report has no forms and does not use this module.
11
+
12
+ The :class:`ComparisonMode` ABC stays pure compute — no UI coupling on the
13
+ mode class. The bridge lives here instead, keyed on mode name.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import dataclasses
19
+ import types
20
+ import typing
21
+ from collections.abc import Mapping
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, Union
24
+
25
+ from .sides import Sides, as_written
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Schema types
29
+ # ---------------------------------------------------------------------------
30
+
31
+ #: Recognized field types. ``sides`` is a tolerance with two sides
32
+ #: (:class:`~.sides.Sides`): a number, or a ``{"lower", "upper"}`` pair.
33
+ #: ``passthrough`` is the fallback for complex types (nested dataclasses,
34
+ #: list[dict], etc.) that introspection cannot reduce to a scalar or an
35
+ #: enum — a consumer renders those as raw JSON.
36
+ FIELD_TYPES = {"float", "int", "bool", "str", "enum", "sides", "passthrough"}
37
+
38
+ #: What the two sides of a ``sides`` field are called, lower first, unless
39
+ #: its metadata says (``side_labels``): a value's are below and above its
40
+ #: target. A time tolerance names its own — ``early`` / ``late``.
41
+ SIDE_LABELS = ("−", "+")
42
+
43
+
44
+ @dataclass
45
+ class FieldSpec:
46
+ name: str
47
+ type: str # one of FIELD_TYPES
48
+ default: Any = None
49
+ optional: bool = False
50
+ choices: list[str] | None = None
51
+ label: str | None = None
52
+ help: str | None = None
53
+ # UI hints for number inputs. ``ui_max`` is a SOFT cap: a consumer that
54
+ # renders an input raises it to fit a larger current value rather than
55
+ # clamping the value down.
56
+ ui_min: float | None = None
57
+ ui_max: float | None = None
58
+ # A ``sides`` field's two labels, lower first; None on every other kind.
59
+ side_labels: tuple[str, str] | None = None
60
+
61
+
62
+ @dataclass
63
+ class Schema:
64
+ mode: str | None = None
65
+ fields: list[FieldSpec] = field(default_factory=list)
66
+
67
+ def to_dict(self) -> dict:
68
+ return {
69
+ "mode": self.mode,
70
+ "fields": [dataclasses.asdict(f) for f in self.fields],
71
+ }
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Introspection
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def derive_schema(config_cls: type, *, mode: str | None = None) -> Schema:
80
+ """Inspect a dataclass and return its UI schema.
81
+
82
+ Handles ``float``, ``int``, ``bool``, ``str``, ``Optional[X]``,
83
+ ``Literal[...]``, and ``Optional[Literal[...]]``. Unknown shapes
84
+ (``list[dict]``, nested dataclasses, etc.) degrade to
85
+ ``type="passthrough"`` so callers can render a raw-JSON fallback
86
+ without crashing.
87
+
88
+ Reads ``metadata`` from :func:`dataclasses.field` for display hints:
89
+ - ``label``: human-readable label (defaults to a titleized name)
90
+ - ``help``: tooltip text
91
+ - ``side_labels``: what a ``sides`` field's two sides are called,
92
+ lower first (defaults to :data:`SIDE_LABELS`)
93
+ """
94
+ if not dataclasses.is_dataclass(config_cls):
95
+ raise TypeError(f"{config_cls!r} is not a dataclass")
96
+
97
+ hints = typing.get_type_hints(config_cls)
98
+ fields_out: list[FieldSpec] = []
99
+ for f in dataclasses.fields(config_cls):
100
+ hint = hints.get(f.name, f.type)
101
+ kind, optional, choices = _classify_type(hint)
102
+ default = _field_default(f)
103
+ meta: Mapping[str, Any] = f.metadata or {}
104
+ fields_out.append(
105
+ FieldSpec(
106
+ name=f.name,
107
+ type=kind,
108
+ default=default,
109
+ optional=optional,
110
+ choices=choices,
111
+ label=meta.get("label") or _titleize(f.name),
112
+ help=meta.get("help"),
113
+ ui_min=meta.get("ui_min"),
114
+ ui_max=meta.get("ui_max"),
115
+ side_labels=(
116
+ tuple(meta.get("side_labels") or SIDE_LABELS)
117
+ if kind == "sides"
118
+ else None
119
+ ),
120
+ )
121
+ )
122
+ return Schema(mode=mode, fields=fields_out)
123
+
124
+
125
+ def _classify_type(hint: Any) -> tuple[str, bool, list[str] | None]:
126
+ """Return (field_kind, optional, choices-if-enum) for a type hint."""
127
+ origin = typing.get_origin(hint)
128
+ args = typing.get_args(hint)
129
+
130
+ # Optional[X] — unwrap, mark optional, recurse. Accept both spellings:
131
+ # typing.Optional/Union[X, None] (origin is typing.Union) and the PEP 604
132
+ # form X | None (origin is types.UnionType on 3.10+).
133
+ if origin in (Union, types.UnionType) and type(None) in args:
134
+ non_none = [a for a in args if a is not type(None)]
135
+ if len(non_none) == 1:
136
+ inner_kind, _, choices = _classify_type(non_none[0])
137
+ return inner_kind, True, choices
138
+ # Multi-type Optional union: give up, passthrough.
139
+ return "passthrough", True, None
140
+
141
+ # Literal[...] — enum.
142
+ if origin is typing.Literal:
143
+ return "enum", False, [str(a) for a in args]
144
+
145
+ # Plain scalars.
146
+ if hint is float:
147
+ return "float", False, None
148
+ if hint is Sides:
149
+ return "sides", False, None
150
+ if hint is bool:
151
+ return "bool", False, None
152
+ if hint is int:
153
+ return "int", False, None
154
+ if hint is str:
155
+ return "str", False, None
156
+
157
+ # Everything else (list[dict], nested dataclass, forward ref, ...).
158
+ return "passthrough", False, None
159
+
160
+
161
+ def _field_default(f: dataclasses.Field) -> Any:
162
+ if f.default is not dataclasses.MISSING:
163
+ if isinstance(f.default, Sides):
164
+ # As a spec would hold it: the number while the sides are equal.
165
+ return as_written(f.default)
166
+ return f.default
167
+ if f.default_factory is not dataclasses.MISSING:
168
+ try:
169
+ return f.default_factory()
170
+ except Exception:
171
+ return None
172
+ return None
173
+
174
+
175
+ def _titleize(name: str) -> str:
176
+ return name.replace("_", " ").strip().capitalize()