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,479 @@
1
+ """Edit criteria (§4.6 table, §4.7): the CRITERION placement; a mode switch,
2
+ the mode's form, the window, the ``against`` selector — and the
3
+ STATISTIC_CRITERION one, a statistics criterion's editor under its
4
+ Statistics row, where the mode, the window and the quantity are no fields —
5
+ and the TREE_CRITERION one, any criterion's editor under its tree row (U5). Each change stages one
6
+ ``replace`` of the whole leaf and the test rescores. The Session collapses
7
+ consecutive replaces of one leaf into one pending op, so the pending count
8
+ and the exported envelope carry one edit, never a keystroke log (the
9
+ preview drawer shows the entry's diff either way).
10
+
11
+ At FAMILY_CRITERION (D152) the same fields are a folded row's: the
12
+ criterion every member shares, read off the first, and each change replaces
13
+ EVERY member's leaf — its own variable kept — as one whole-tree op
14
+ (`family_rows.as_one`), which the Session collapses the same way.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import copy
20
+ import dataclasses
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ from ...comparison.mode_schema import Schema, derive_schema
24
+ from ...comparison.registry import metric, metrics
25
+ from ...comparison.seeds import seeded_leaf
26
+ from ...comparison.tree_spec import (
27
+ LeafSpec,
28
+ parse_metric_tree,
29
+ reads_a_statistic,
30
+ spec_to_raw,
31
+ spec_to_view,
32
+ )
33
+ from ..capabilities import Panel, Patch, Placement
34
+ from ..family_rows import NOT_A_FOLD, as_one, edit_notes
35
+ from ..session import no_tree_error
36
+ from ..tree_pointer import node_or_none
37
+ from ..unscorable import (
38
+ MIN_WIDTH,
39
+ TOLERANCES,
40
+ WIDTHS,
41
+ refuse_bounds,
42
+ refuse_negative,
43
+ refuse_negative_floor,
44
+ refuse_negative_width,
45
+ )
46
+
47
+ if TYPE_CHECKING:
48
+ from ..session import SessionView
49
+ from ..spec_document import PatchOp
50
+ from .edit_on_plot import _replace, placeable, placed
51
+ from .edit_tree import seed_data
52
+
53
+ # NO module-level `nicegui` or `..forms` import: `import dstf.authoring` runs
54
+ # register_builtins(), and the Session must import without the extra.
55
+
56
+
57
+ def _leaf_node(view: dict | None, path: str) -> dict | None:
58
+ """The node at ``path`` in the report's tree view, or None."""
59
+ if view is None:
60
+ return None
61
+ if view.get("path") == path:
62
+ return view
63
+ for child in view.get("children") or []:
64
+ found = _leaf_node(child, path)
65
+ if found is not None:
66
+ return found
67
+ return None
68
+
69
+
70
+ def _authored_leaf(tree: dict | None, path: str) -> dict | None:
71
+ """The leaf at ``path`` of the tree as AUTHORED (`SessionView.tree`,
72
+ staged edits included), shaped as the tree view's leaf — its metric,
73
+ variable, params, baseline and window, and ``advisory`` when a ``warn``
74
+ sits above it; None where ``path`` names no leaf. Not the view's: a
75
+ test with nothing compared (no baseline) has no view, and the editor
76
+ of the criterion just staged there drew no field."""
77
+ raw = node_or_none(tree, path)
78
+ if raw is None or "metric" not in raw:
79
+ return None
80
+ leaf = spec_to_view(parse_metric_tree(raw), root=path)
81
+ parts = path.split("/children/")
82
+ leaf["advisory"] = any(
83
+ (node_or_none(tree, "/children/".join(parts[:i])) or {}).get("combinator")
84
+ == "warn"
85
+ for i in range(1, len(parts))
86
+ )
87
+ return leaf
88
+
89
+
90
+ def refuse_unscorable(name: str, params: dict) -> None:
91
+ """Raise ``ValueError`` for a criterion of metric ``name`` whose
92
+ ``params`` can never score (`..unscorable`): a negative tolerance, its
93
+ sides named as the mode's own fields name them, a tube's width whose
94
+ sides sum below zero over no positive minimum width, or bounds out of
95
+ order — and a tube's minimum width below zero, which floors nothing."""
96
+ desc = metric(name)
97
+ sides: dict[str, Any] = {}
98
+ if desc is not None and dataclasses.is_dataclass(desc.config_cls):
99
+ sides = {
100
+ f.name: f.metadata.get("side_labels")
101
+ for f in dataclasses.fields(desc.config_cls)
102
+ }
103
+ for key in TOLERANCES:
104
+ refuse_negative(key, params.get(key), sides.get(key))
105
+ for key in WIDTHS:
106
+ refuse_negative_width(key, params.get(key), params.get(MIN_WIDTH))
107
+ refuse_negative_floor(MIN_WIDTH, params.get(MIN_WIDTH))
108
+ refuse_bounds(name, params)
109
+
110
+
111
+ class EditCriteria:
112
+ """One leaf's metric, its mode's parameters, its window and the baseline
113
+ it scores against — or, for a folded row, every member's at once."""
114
+
115
+ name = "edit-criteria"
116
+ label = "Edit criteria"
117
+ placements = frozenset(
118
+ {
119
+ Placement.CRITERION,
120
+ Placement.STATISTIC_CRITERION,
121
+ Placement.TREE_CRITERION,
122
+ Placement.FAMILY_CRITERION,
123
+ }
124
+ )
125
+
126
+ def caption(self, s: SessionView) -> str:
127
+ return self.label
128
+
129
+ def available(self, s: SessionView) -> bool | str:
130
+ sel = s.selection
131
+ model_id = next(iter(sel.tests)) if len(sel.tests) == 1 else None
132
+ if model_id is None or sel.leaf_path is None:
133
+ return "select one leaf"
134
+ # A compared test whose tree the Session will not materialize: its
135
+ # leaves are on the plots, but every edit is refused — the Session's
136
+ # reason, as the editor's why-line, not the view's leaf as if
137
+ # editable. Said here and not in the panel: Edit on plot is refused
138
+ # for the same reason, and the host says a sentence once.
139
+ _tree, why = s.tree_or_reason(model_id)
140
+ return True if why is None else why
141
+
142
+ def panel(self, s: SessionView, ui_panel: Panel) -> None:
143
+ from nicegui import ui
144
+
145
+ from ..forms import BOTTOM, SidesControl, render_form
146
+ from ..typed_number import TypedNumber
147
+
148
+ sel = s.selection
149
+ model_id = next(iter(sel.tests))
150
+ path = sel.leaf_path or ""
151
+ tree = s.tree(model_id)
152
+ assert tree is not None # `available` said so, on this same view
153
+ leaf = _authored_leaf(tree, path)
154
+ # The panel is rebuilt after edits made elsewhere: Edit tree wrapping
155
+ # the selected leaf leaves the selection's path naming a combinator.
156
+ if leaf is None:
157
+ ui.label("this leaf is not in the scored tree")
158
+ return
159
+ #: A folded row's members as they are read now: the act refuses
160
+ #: the edit if a path no longer holds that member (`act`).
161
+ members = tuple(
162
+ str((node_or_none(tree, member) or {}).get("variable", ""))
163
+ for member in sel.leaf_paths
164
+ )
165
+ # What the leaf was scored with: only a view says — none before a
166
+ # compare (no baseline), and then the fields show no hint.
167
+ scored = _leaf_node(s.tree_view(model_id), path) or {}
168
+ state: dict[str, Any] = {
169
+ "metric": leaf["metric"],
170
+ "variable": leaf["variable"],
171
+ "params": dict(leaf.get("params") or {}),
172
+ "against": leaf.get("against") or "primary",
173
+ "window": dict(leaf.get("window") or {}),
174
+ }
175
+ # A statistics criterion (U3): only `scalar` scores a stored number
176
+ # (no mode select, and `set_metric`'s reseed never runs on one), a
177
+ # stored number has no window, and the Statistics row the editor
178
+ # sits under names the quantity — none of the three is a field.
179
+ statistic = reads_a_statistic(state["metric"], state["params"])
180
+ # No heading: the criterion's own row sits directly above this panel
181
+ # and names it, and a second name there made the editor read as two
182
+ # panels rather than one (brief §3.2).
183
+ # Nor an error line: a leaf the scorer could not score says why in
184
+ # the criteria ROW directly above this panel, which reads out the
185
+ # criterion ("scalar leaf not scored — …"). Drawing it on the form
186
+ # itself (§4.7 "inline on its form") is sub-project 4's.
187
+
188
+ # What the leaf holds — the state its last accepted submit was made
189
+ # from — and what that was when the field being edited was ENTERED
190
+ # (`enter`): where a refused edit puts the leaf back.
191
+ taken: dict[str, Any] = {
192
+ "leaf": copy.deepcopy(state),
193
+ "entered": copy.deepcopy(state),
194
+ "field": None,
195
+ }
196
+
197
+ def enter(field: str | None) -> None:
198
+ """A field took the focus (``None``: the row heard it, whichever
199
+ it is) — or ``field`` is the first edited since another was,
200
+ where no focus was heard (a value set from Python)."""
201
+ taken["field"], taken["entered"] = field, copy.deepcopy(taken["leaf"])
202
+
203
+ def submit(values: dict) -> bool:
204
+ # A folded row's members (D152): the act replaces each of them,
205
+ # each named as this panel read it.
206
+ if not ui_panel.submit(
207
+ model_id=model_id,
208
+ path=path,
209
+ paths=sel.leaf_paths,
210
+ variables=members,
211
+ **values,
212
+ ):
213
+ return False
214
+ taken["leaf"] = copy.deepcopy(values)
215
+ return True
216
+
217
+ def emit(field: str) -> None:
218
+ """Stage the leaf as the fields now say, ``field`` being the
219
+ one just edited. A value typed passes through others on the
220
+ way — `-0.5` over a window end is `-` first, no number, which
221
+ UNSETS the end — and each that the leaf takes is staged; so a
222
+ refused one left the last of those staged, beside a field and
223
+ an error line saying something else. Refused, the leaf goes
224
+ back to what it held when the field was entered: one staged
225
+ state, as if it had not been touched. The field keeps what was
226
+ typed, and the refusal its sentence."""
227
+ if field != taken["field"]:
228
+ enter(field)
229
+ if submit(state) or taken["leaf"] == taken["entered"]:
230
+ return
231
+ submit(taken["entered"])
232
+ # The put-back went through, which hid the refusal: said again,
233
+ # by the submit that earned it.
234
+ submit(state)
235
+
236
+ # A tolerance with one side typed is in no leaf: its control hands
237
+ # nothing over, so the fields show what the spec does not hold. This
238
+ # panel is rebuilt by edits it did not make — a drag on the plot,
239
+ # a cell of the table beside it — and, built from the leaf alone,
240
+ # the blank side came back as its old value, unmarked. So a half
241
+ # pair is written where it outlives the panel (`Panel.kept`), as
242
+ # the tables write theirs: field -> ``{"base": what the leaf holds
243
+ # there, "shown": the half pair}``, read back when the form is
244
+ # built. One whose leaf has since moved under it — that tolerance
245
+ # dragged on the plot — is dropped, and the fields show the spec's.
246
+ kept = ui_panel.kept
247
+
248
+ def remember(name: str, group: SidesControl) -> None:
249
+ """After every edit of the two-sided field ``name``, once the
250
+ leaf has had what the control handed over."""
251
+ if group.half():
252
+ base = copy.deepcopy(taken["leaf"]["params"].get(name))
253
+ kept[name] = {"base": base, "shown": group.shown()}
254
+ else:
255
+ kept.pop(name, None)
256
+
257
+ #: The form as last built, and the window's two fields: what a
258
+ #: preview is shown in.
259
+ built: dict[str, Any] = {"form": None, "window": {}}
260
+ # A ⌖ only where a click on the plot can set the field as the
261
+ # criterion now is (`edit_on_plot.placeable`, the rule the click
262
+ # itself acts by) — and none beside no plot, or on a statistic.
263
+ can = placeable(s, model_id=model_id, variable=leaf["variable"], path=path)
264
+
265
+ def place(pointer: tuple, label: str, field: Any) -> None:
266
+ if ui_panel.can_place and not statistic and can(pointer):
267
+ with field.add_slot("append"):
268
+ ui_panel.place_button(pointer, label)
269
+
270
+ def build_form() -> None:
271
+ form_slot.clear()
272
+ desc = metric(state["metric"])
273
+ with form_slot:
274
+ if desc is None:
275
+ ui.label(f"unknown metric {state['metric']!r}")
276
+ return
277
+ schema = derive_schema(desc.config_cls, mode=desc.name)
278
+ if statistic:
279
+ schema = Schema(
280
+ schema.mode, [f for f in schema.fields if f.name != "quantity"]
281
+ )
282
+ for name in [
283
+ n for n in kept if kept[n]["base"] != state["params"].get(n)
284
+ ]:
285
+ del kept[name] # the leaf moved under it
286
+ built["form"] = render_form(
287
+ schema,
288
+ state["params"],
289
+ on_change=set_params,
290
+ classes="dstf-field",
291
+ shown={name: cell["shown"] for name, cell in kept.items()},
292
+ on_edit=remember,
293
+ place=place,
294
+ # The mode the leaf was scored in: a switched mode's
295
+ # fields were never applied, so nothing is in effect.
296
+ in_effect=(
297
+ scored.get("in_effect")
298
+ if state["metric"] == scored.get("metric")
299
+ else None
300
+ ),
301
+ )
302
+
303
+ def set_params(values: dict) -> None:
304
+ if statistic:
305
+ # The form's values ARE the params: with its quantity field
306
+ # left out, the path goes back in, or the replace staged a
307
+ # scalar that reads no statistic — which the Session takes.
308
+ values = {**values, "quantity": state["params"]["quantity"]}
309
+ changed = sorted(
310
+ key
311
+ for key in {*values, *state["params"]}
312
+ if values.get(key) != state["params"].get(key)
313
+ )
314
+ state["params"] = values
315
+ emit("params:" + ",".join(changed))
316
+
317
+ def set_metric(name: str) -> None:
318
+ ui_panel.disarm() # another mode's fields are other fields
319
+ state["metric"] = name
320
+ # A new mode starts from its seed, else its defaults: a range
321
+ # switched to with no bound does not resolve. The window stays,
322
+ # so the seed reads the samples it scores.
323
+ data = seed_data(s, model_id, state["variable"], window=state["window"])
324
+ seeded = seeded_leaf(name, state["variable"], data)
325
+ state["params"] = {
326
+ k: v for k, v in seeded.items() if k not in ("metric", "variable")
327
+ }
328
+ # Another mode's fields are other fields, whatever their names:
329
+ # a half pair typed in the old one is not shown in them.
330
+ kept.clear()
331
+ build_form()
332
+ emit("metric")
333
+
334
+ def set_window(key: str, value: float | None) -> None:
335
+ if value is None:
336
+ state["window"].pop(key, None)
337
+ else:
338
+ state["window"][key] = value
339
+ emit(f"window:{key}")
340
+
341
+ def set_against(name: str) -> None:
342
+ state["against"] = name
343
+ emit("against")
344
+
345
+ project = s.project
346
+ names = list(project.baseline_names(model_id)) if project is not None else []
347
+ if not leaf.get("advisory"):
348
+ # D66: a soft_check may be targeted only under a `warn`; offering
349
+ # it here would stage an op the validator refuses every time.
350
+ names = [n for n in names if n == "primary"]
351
+ if state["against"] not in names:
352
+ # A leaf naming a baseline the store no longer has is exactly the
353
+ # leaf one opens this panel to repair: keep its value selectable.
354
+ names.append(state["against"])
355
+ # ONE wrapping row (the board: the plot and its editor fit a screen
356
+ # together), the mode FIRST — it decides what the fields after it
357
+ # are, and drawn below them it read as a footnote to the form it
358
+ # controls. `dstf-form` hands the mode's own fields straight to this
359
+ # row rather than stacking them in a column of its own.
360
+ fields = ui.row().classes("dstf-fields items-end gap-3 wrap w-full")
361
+ # Whichever field it is: what the leaf holds now is its start.
362
+ fields.on("focusin", lambda _e: enter(None), []).mark("edit-criteria:fields")
363
+ with fields:
364
+ if not statistic:
365
+ ui.select(
366
+ [d.name for d in metrics()],
367
+ label="mode",
368
+ value=state["metric"],
369
+ on_change=lambda e: set_metric(e.value),
370
+ ).props(f"dense {BOTTOM}").classes("dstf-field")
371
+ form_slot = ui.column().classes("dstf-form")
372
+ if not statistic:
373
+ for end in ("start", "end"):
374
+ field = (
375
+ TypedNumber(
376
+ label=f"window {end}",
377
+ value=state["window"].get(end),
378
+ on_change=lambda e, end=end: set_window(end, e.value),
379
+ )
380
+ .props(f"dense {BOTTOM}")
381
+ .classes("dstf-field")
382
+ )
383
+ place(("window", end), f"window {end}", field)
384
+ built["window"][end] = field
385
+ ui.select(
386
+ names or ["primary"],
387
+ label="against",
388
+ value=state["against"],
389
+ on_change=lambda e: set_against(e.value),
390
+ ).props(f"dense {BOTTOM}").classes("dstf-field")
391
+ build_form()
392
+
393
+ def preview(shown: dict | None, revision: int) -> None:
394
+ """A handle held on the plot (D149): the fields its release
395
+ would change show what it would write, and nothing is staged."""
396
+ would = None
397
+ if shown is not None:
398
+ try:
399
+ would = spec_to_view(parse_metric_tree(shown), root=path)
400
+ except ValueError:
401
+ would = None # no leaf: nothing to show
402
+ form = built["form"]
403
+ if form is not None:
404
+ form.preview(None if would is None else would["params"], revision)
405
+ for end, field in built["window"].items():
406
+ value = None if would is None else (would.get("window") or {}).get(end)
407
+ held = state["window"].get(end)
408
+ field.preview(None if value == held else value, revision)
409
+
410
+ ui_panel.on_preview(preview)
411
+ variable = state["variable"]
412
+ # An armed field of THIS panel, and the plot clicked: the click is
413
+ # read as Edit on plot reads a drag — this panel's fields are the
414
+ # same leaf's — and staged from here, so it is this panel's edit.
415
+ ui_panel.on_place(
416
+ lambda pointer, click: ui_panel.submit(
417
+ kind="place",
418
+ model_id=model_id,
419
+ variable=variable,
420
+ path=path,
421
+ pointer=pointer,
422
+ click=click,
423
+ )
424
+ )
425
+
426
+ def act(self, s: SessionView, **inputs: Any) -> Patch | None:
427
+ if inputs.get("kind") == "place":
428
+ new = placed(
429
+ s,
430
+ model_id=inputs["model_id"],
431
+ variable=inputs["variable"],
432
+ path=inputs["path"],
433
+ pointer=inputs["pointer"],
434
+ click=inputs["click"],
435
+ )
436
+ return _replace(inputs["model_id"], inputs["path"], new)
437
+ params = dict(inputs["params"])
438
+ # A value that can never score is refused here, before the stage —
439
+ # as the stage refuses a window that ends before it starts, and the
440
+ # panel puts the leaf back the same way (`emit`).
441
+ refuse_unscorable(inputs["metric"], params)
442
+ leaf = LeafSpec(
443
+ metric=inputs["metric"],
444
+ variable=inputs["variable"],
445
+ params=params,
446
+ against=inputs["against"],
447
+ window_start=inputs["window"].get("start"),
448
+ window_end=inputs["window"].get("end"),
449
+ )
450
+ model_id = inputs["model_id"]
451
+ paths = tuple(inputs.get("paths") or ())
452
+ if not paths:
453
+ return Patch(
454
+ ops=[PatchOp(model_id, "replace", inputs["path"], spec_to_raw(leaf))]
455
+ )
456
+ # A folded row (D152): the same criterion on every member, each on
457
+ # its own variable — ONE op, refused or staged whole.
458
+ tree, why = s.tree_or_reason(model_id)
459
+ if tree is None:
460
+ assert why is not None # `tree` is None
461
+ raise no_tree_error(model_id, why)
462
+ # Each path has to hold the member it was read as: an edit staged
463
+ # elsewhere moves the tree under an open row's paths, and "every
464
+ # member" then overwrote whatever criterion stood there instead —
465
+ # another variable's (the executing review's M4).
466
+ variables = tuple(inputs.get("variables") or ())
467
+ if len(variables) != len(paths):
468
+ raise ValueError(NOT_A_FOLD)
469
+ ops, was = [], None
470
+ for member, expected in zip(paths, variables, strict=True):
471
+ node = node_or_none(tree, member)
472
+ if node is None or "metric" not in node or node["variable"] != expected:
473
+ raise ValueError(NOT_A_FOLD)
474
+ was = node if was is None else was
475
+ raw = spec_to_raw(dataclasses.replace(leaf, variable=expected))
476
+ ops.append(PatchOp(model_id, "replace", member, raw))
477
+ assert was is not None # `paths` is not empty
478
+ notes = edit_notes(was, ops[0].value, variables)
479
+ return Patch(ops=[as_one(model_id, tree, ops, notes)])