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,1399 @@
1
+ """Edit tree (§4.6 table, §4.7): the NODE and VARIABLE placements, exactly
2
+ one test selected. At NODE the panel IS that node's controls — add, wrap,
3
+ edit on a k-of-n or weighted node, and remove — each form a ROW under the
4
+ node's row (`Panel.row`), and on a combinator its kind as a dropdown in
5
+ the row's lead place (`Panel.lead`), unwrap its last entry; at VARIABLE —
6
+ a plot's criteria header — it is one `+` whose "Add a criterion for x"
7
+ form is a row under the header, the new criterion's editor open after
8
+ Add; at STATISTIC — a Statistics row no criterion reads yet — one `+`
9
+ that adds a requirement on the row's quantity at once, and opens its
10
+ editor. An add form whose variable is an element of an array carries an
11
+ Indices field (D152): Add writes one criterion per chosen element, all
12
+ the same, as ONE whole-tree op.
13
+ Every control stages one structural change against the paths the report's
14
+ tree already shows. The D66 validator's refusal reaches the page through
15
+ ``Panel.submit`` — the Session is what it was.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import copy
21
+ from collections.abc import Callable
22
+ from functools import partial
23
+ from types import SimpleNamespace
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ from ...backends.types import DatasetType
27
+ from ...comparison.registry import DEFAULT_METRIC, combinators, metrics
28
+ from ...comparison.seeds import (
29
+ SeedData,
30
+ quantity_kind,
31
+ seed_diagnostic,
32
+ seed_quantity,
33
+ seeded_leaf,
34
+ )
35
+ from ...comparison.sides import parse_sides, signed_sides
36
+ from ...discovery.unit_tests import is_unit_test_variable
37
+ from ...figures.families import family_of
38
+ from ..capabilities import Panel, Patch, Placement, plural
39
+ from ..family_rows import Family, as_one, elements_text, family_in, fold_key
40
+ from ..session import SIMULATE_ONLY, no_tree_error
41
+
42
+ if TYPE_CHECKING:
43
+ from ..session import SessionView
44
+ from ..spec_document import PatchOp
45
+ from ..tree_move import (
46
+ BEFORE,
47
+ WHERE_IT_IS,
48
+ Dest,
49
+ Moved,
50
+ move_destinations,
51
+ path_map,
52
+ plan_move,
53
+ validator_for,
54
+ zone_dest,
55
+ zone_map,
56
+ )
57
+ from ..tree_pointer import node_or_none
58
+ from ..tree_text import node_text, nodes_of
59
+
60
+ # NO module-level `nicegui` import: `import dstf.authoring` runs
61
+ # register_builtins(), and the Session must import without the extra.
62
+
63
+ ROOT = "/metrics"
64
+
65
+ #: What `EditTree.act` does; anything else is refused before its inputs are
66
+ #: read.
67
+ _ACTIONS = ("remove", "move", "add", "unwrap", "wrap", "edit")
68
+
69
+ #: The combinator-only raw keys each kind carries (``tree_spec``); every
70
+ #: other one is removed when a node changes kind.
71
+ _KIND_KEYS: dict[str, tuple[str, ...]] = {
72
+ "k-of-n": ("k",),
73
+ "weighted": ("weights", "threshold", "direction"),
74
+ }
75
+ _COMBINATOR_KEYS = ("k", "weights", "threshold", "direction")
76
+
77
+
78
+ def _kind_fields(kind: str, inputs: dict[str, Any]) -> dict[str, Any]:
79
+ """The raw fields ``kind`` takes, from the panel's controls. A weighted
80
+ node's threshold is never read as 0 when none was typed: 0 with `less`
81
+ always fails."""
82
+ if kind == "k-of-n":
83
+ # A cleared field stays None, for the parser to say "k is empty"
84
+ # rather than refuse a 0 nobody typed. A fraction stays the number
85
+ # typed, for the parser to refuse by name ("k must be a whole
86
+ # number of at least 1; got 2.7."): rounded, 2.7 was staged as 3.
87
+ if not _typed(inputs["k"]):
88
+ return {"k": None}
89
+ k = float(inputs["k"])
90
+ return {"k": int(k) if k.is_integer() else k}
91
+ if kind == "weighted":
92
+ text = str(inputs["weights"] or "")
93
+ try:
94
+ weights = [float(w) for w in text.replace(",", " ").split()]
95
+ except ValueError:
96
+ raise ValueError(
97
+ f"weights: {text!r} is not a comma-separated list of numbers"
98
+ ) from None
99
+ if not _typed(inputs["threshold"]):
100
+ raise ValueError(_NO_THRESHOLD)
101
+ return {
102
+ "weights": weights,
103
+ "threshold": float(inputs["threshold"]),
104
+ "direction": str(inputs.get("direction") or "less"),
105
+ }
106
+ return {}
107
+
108
+
109
+ def _typed(value: Any) -> bool:
110
+ """Whether a number field holds a number: an emptied `ui.number` reads
111
+ None, an untouched one here too."""
112
+ return value is not None and value != ""
113
+
114
+
115
+ #: What each Wrap does to the tree, one sentence per kind (the 2026-09-21
116
+ #: board, 1C). ``{what}`` is "criterion" for a leaf, "node" for a
117
+ #: combinator.
118
+ _WRAP_SENTENCES = {
119
+ "and": "The {what} becomes the only child of a new and; add siblings to it "
120
+ "with its +.",
121
+ "or": "The {what} becomes the only child of a new or; add siblings to it "
122
+ "with its +.",
123
+ "k-of-n": "The {what} becomes the only child of a new k-of-n; add siblings "
124
+ "to it with its +.",
125
+ "warn": "A failure of this {what} becomes a warning; the test still passes.",
126
+ "weighted": "The {what} becomes the only child of a new weighted node, with "
127
+ "the one weight above.",
128
+ }
129
+ #: The Edit form's sentence: on the node's own kind it sets the kind's
130
+ #: fields; preset by the dropdown to another kind, the kind changes too.
131
+ _EDIT_SENTENCE = "Changes this node's fields; its children stay."
132
+ _EDIT_KIND_SENTENCE = "Changes this node's kind and fields; its children stay."
133
+ #: The kind dropdown's value for its last entry, which is not a kind: no
134
+ #: combinator is named this.
135
+ _UNWRAP_VALUE = "unwrap…"
136
+ #: What that entry reads, below a separator.
137
+ _UNWRAP_TEXT = "unwrap — its children take its place"
138
+ #: The kinds a node cannot take without fields: the dropdown opens the Edit
139
+ #: form preset to one of them, and nothing is staged until Apply. And, or
140
+ #: and warn have none — only these nodes' rows open it on a click (U5).
141
+ _NEEDS_FIELDS = ("k-of-n", "weighted")
142
+ #: The dropdown's option slot: the unwrap entry below a separator.
143
+ _KIND_OPTION = (
144
+ "<q-separator v-if=\"props.opt.label.startsWith('unwrap')\" />"
145
+ '<q-item v-bind="props.itemProps"><q-item-section>'
146
+ "<q-item-label>{{ props.opt.label }}</q-item-label>"
147
+ "</q-item-section></q-item>"
148
+ )
149
+ #: Under a weighted node's Threshold, following its Direction (the pass
150
+ #: rule of `metric_tree`'s weighted node).
151
+ _THRESHOLD_HINTS = {
152
+ "less": "Passes when Σ weight × score over the children is below the "
153
+ "threshold (lower is better).",
154
+ "greater": "Passes when Σ weight × score over the children is above the "
155
+ "threshold (higher is better).",
156
+ }
157
+ #: The form row's title says where the criterion lands; this, where its
158
+ #: values come from.
159
+ _ADD_SENTENCE = "Values come from the data where the metric needs them."
160
+ #: Why ``+`` is grey where a child cannot be added alone: the validator
161
+ #: refuses it by name whatever the form says, so the form is not offered.
162
+ _NO_ADD = {
163
+ "warn": "a warn takes exactly one child",
164
+ "weighted": "a weighted node has one weight per child; a child cannot be "
165
+ "added alone",
166
+ }
167
+ _ROOT_UNWRAP = "the root has no parent to take its children"
168
+ #: Why a weighted form's action is grey: its threshold starts EMPTY, not 0
169
+ #: (0 with `less` always fails), and has to be typed.
170
+ _NO_THRESHOLD = "enter a threshold"
171
+ #: The direction a weighted node has when it names none (`tree_spec`); an
172
+ #: Edit leaves it unwritten rather than stage the default.
173
+ _DEFAULT_DIRECTION = "less"
174
+ #: A weighted node's ``direction``, as the Direction select reads it.
175
+ _DIRECTIONS = {"less": "lower is better", "greater": "higher is better"}
176
+ #: The Move to… icon's tooltip and accessible name (U4).
177
+ _MOVE_TO = "Move to…"
178
+
179
+
180
+ def seed_data(
181
+ s: SessionView, model_id: str, variable: str, window: dict | None = None
182
+ ) -> SeedData:
183
+ """The series a new criterion on ``variable`` will score, for its seed:
184
+ only the samples inside ``window`` (a leaf's ``start`` / ``end``) when
185
+ it has one.
186
+
187
+ Read from the variable's view, so a variable no criterion scores has no
188
+ reference there even when the baseline file holds one: it seeds from
189
+ its actual result (a scalar then gets bounds, not a tolerance). A
190
+ variable without a result gives neither, and the leaf stays bare. Only
191
+ an unknown test raises (``KeyError``); an unknown variable is an empty
192
+ view."""
193
+ try:
194
+ view = s.view(model_id, variable)
195
+ except KeyError:
196
+ return SeedData(ref=None, act=None)
197
+ ref = (
198
+ (view.ref_time, view.ref_values)
199
+ if view.ref_time is not None and view.ref_values is not None
200
+ else None
201
+ )
202
+ act = (
203
+ (view.act_time, view.act_values)
204
+ if view.act_time is not None and view.act_values is not None
205
+ else None
206
+ )
207
+ return SeedData(ref=ref, act=act).within(window)
208
+
209
+
210
+ class EditTree:
211
+ """The metric tree's shape: wrap a node in a combinator, change a
212
+ combinator's kind and its fields, add a leaf, remove a node, move a
213
+ node (`tree_move`)."""
214
+
215
+ name = "edit-tree"
216
+ label = "Edit tree"
217
+ placements = frozenset({Placement.NODE, Placement.VARIABLE, Placement.STATISTIC})
218
+
219
+ def caption(self, s: SessionView) -> str:
220
+ # NODE and VARIABLE are inline: no button reads a caption there.
221
+ return self.label
222
+
223
+ def available(self, s: SessionView) -> bool | str:
224
+ sel = s.selection
225
+ if len(sel.tests) != 1:
226
+ return "select exactly one test"
227
+ model_id = next(iter(sel.tests))
228
+ if sel.quantity is not None:
229
+ # A Statistics row's `+` is drawn grey with the add form's
230
+ # reason where nothing can be added (A9): a why-line in place
231
+ # of it, on every row, said the same thing twenty times (57 on
232
+ # a TRANSFORM card).
233
+ return True
234
+ tree, why = s.tree_or_reason(model_id)
235
+ if tree is None:
236
+ assert why is not None # `tree` is None
237
+ return why
238
+ if sel.node_path is not None and node_or_none(tree, sel.node_path) is None:
239
+ # A selection the tree moved under: the panel would index a list
240
+ # that is shorter now.
241
+ return f"{sel.node_path} names no node of {model_id}"
242
+ if sel.node_path is None and not sel.variable:
243
+ return "select a tree node or a variable"
244
+ return True
245
+
246
+ def panel(self, s: SessionView, ui_panel: Panel) -> None:
247
+ from nicegui import ui
248
+
249
+ sel = s.selection
250
+ model_id = next(iter(sel.tests))
251
+ if sel.node_path is not None:
252
+ _cluster(ui, s, ui_panel, model_id, sel.node_path)
253
+ elif sel.quantity is not None:
254
+ _statistic_plus(ui, s, ui_panel, model_id, sel.quantity)
255
+ else:
256
+ # The plot's criteria header (Q3 A): one `+`, its form a row
257
+ # under the header, the new criterion's editor open after Add.
258
+ _variable_plus(ui, s, ui_panel, model_id, str(sel.variable))
259
+
260
+ def act(self, s: SessionView, **inputs: Any) -> Patch | None:
261
+ action, model_id, path = inputs["action"], inputs["model_id"], inputs["path"]
262
+ if action not in _ACTIONS:
263
+ # First: an action this does not know is the error, not the
264
+ # input its last branch would read.
265
+ raise ValueError(f"unknown action {action!r}")
266
+ if action == "remove":
267
+ return _patch(s, None, inputs)
268
+ if action == "move":
269
+ # The whole new tree as ONE `add /metrics` (V1) — the root add
270
+ # a wrap uses: a flat test materializes nothing first, and two
271
+ # moves in a row fold into one pending op (`Session.stage`).
272
+ moved = planned_move(s, model_id, path, inputs["dest"])
273
+ if moved is None:
274
+ return None
275
+ return Patch(ops=[PatchOp(model_id, "add", ROOT, moved.tree)])
276
+ tree, why = s.tree_or_reason(model_id)
277
+ if tree is None:
278
+ assert why is not None # `tree` is None
279
+ raise no_tree_error(model_id, why)
280
+ patch = _patch(s, tree, inputs)
281
+ if action == "add" and patch is not None and len(patch.ops) > 1:
282
+ # One criterion per chosen element of a family (D152): one
283
+ # gesture, one pending edit — the tree those adds give.
284
+ added = [op.value["variable"] for op in patch.ops]
285
+ said = f"added {len(added)} criteria: {elements_text(added)}"
286
+ return Patch(ops=[as_one(model_id, tree, patch.ops, [(said, said)])])
287
+ return patch
288
+
289
+
290
+ def _patch(s: SessionView, tree: dict | None, inputs: dict[str, Any]) -> Patch | None:
291
+ """What `EditTree.act` stages for every action but a move, built from
292
+ ``tree`` — the test's raw tree as it is now, read by the caller, so a
293
+ value that carries one of its nodes carries THAT node (`path_map`). A
294
+ remove reads none: the Session's stage says why a path names nothing."""
295
+ action, model_id, path = inputs["action"], inputs["model_id"], inputs["path"]
296
+ if action == "remove":
297
+ return Patch(ops=[PatchOp(model_id, "remove", path)])
298
+ assert tree is not None # read by the caller for every other action
299
+ node = node_or_none(tree, path)
300
+ if node is None:
301
+ raise ValueError(f"{path} names no node of {model_id}")
302
+ if action == "add":
303
+ into = _into(tree, node, path)
304
+ diagnostics = _diagnostics(s, model_id)
305
+ quantity = inputs.get("quantity")
306
+ if quantity is not None and inputs.get("variable") is not None:
307
+ raise ValueError("add takes a quantity or a variable, not both")
308
+ if quantity is not None:
309
+ leaves = [_quantity_leaf(diagnostics, quantity, into)]
310
+ elif inputs["variable"] in diagnostics:
311
+ # A diagnostic is one stored number (K1): its final through
312
+ # a quantity path, never a trajectory — a `{"reduce": ...}`
313
+ # scalar on one never scores.
314
+ if inputs["metric"] not in _metrics_for(diagnostic=True):
315
+ raise ValueError(
316
+ f"{inputs['variable']} is a diagnostic: scalar scores it"
317
+ )
318
+ leaves = [
319
+ seed_diagnostic(
320
+ inputs["variable"], diagnostics[inputs["variable"]], into=into
321
+ )
322
+ ]
323
+ else:
324
+ # One criterion per chosen element of the variable's family
325
+ # (D152) — the variable alone with no Indices text. Seeded: a
326
+ # bare range, tube or scalar does not resolve, and a bare
327
+ # dominant-frequency always fails "no peaks declared". ONCE,
328
+ # from the first chosen element: the leaves are one criterion
329
+ # on several variables, which is what folds them into one row.
330
+ names = _chosen(s, model_id, inputs["variable"], inputs.get("indices"))
331
+ seeded = seeded_leaf(
332
+ inputs["metric"], names[0], seed_data(s, model_id, names[0])
333
+ )
334
+ leaves = [{**copy.deepcopy(seeded), "variable": name} for name in names]
335
+ if "combinator" in node:
336
+ # At the end, in order.
337
+ return Patch(
338
+ ops=[
339
+ PatchOp(model_id, "add", f"{path}/children/-", leaf)
340
+ for leaf in leaves
341
+ ]
342
+ )
343
+ if path == ROOT:
344
+ # Beside a root leaf: the root becomes an `and` of them all, in
345
+ # one op (`add` at /metrics replaces the member). A leaf has
346
+ # no `children` for the patch to append to.
347
+ value = {"combinator": "and", "children": [node, *leaves]}
348
+ return Patch(ops=[PatchOp(model_id, "add", ROOT, value)])
349
+ # Beside any other leaf: right AFTER it, under its parent
350
+ # combinator (Q3 A) — where the `+` that was clicked is.
351
+ parent, index = path.rsplit("/children/", 1)
352
+ return Patch(
353
+ ops=[
354
+ PatchOp(
355
+ model_id, "add", f"{parent}/children/{int(index) + 1 + k}", leaf
356
+ )
357
+ for k, leaf in enumerate(leaves)
358
+ ]
359
+ )
360
+ if action == "unwrap":
361
+ # A single-child combinator gives way to its child (the browser
362
+ # editor's unwrap): `warn(leaf)` becomes `leaf`, params kept.
363
+ children = node.get("children") or []
364
+ if len(children) == 1:
365
+ op = "add" if path == ROOT else "replace"
366
+ return Patch(ops=[PatchOp(model_id, op, path, children[0])])
367
+ # Several children take the node's place in its parent, in
368
+ # order. A `warn` or `weighted` parent refuses that by name, in
369
+ # the validator, like any other op.
370
+ if path == ROOT:
371
+ raise ValueError(_ROOT_UNWRAP)
372
+ if not children:
373
+ raise ValueError(f"{path} has no children to take its place")
374
+ parent, index = path.rsplit("/children/", 1)
375
+ ops = [PatchOp(model_id, "remove", path)]
376
+ ops += [
377
+ PatchOp(model_id, "add", f"{parent}/children/{int(index) + j}", child)
378
+ for j, child in enumerate(children)
379
+ ]
380
+ return Patch(ops=ops)
381
+ kind = inputs["kind"]
382
+ fields = _kind_fields(kind, inputs)
383
+ if action == "wrap":
384
+ # `add` at /metrics: a flat test has no member there yet, and
385
+ # RFC 6902 `replace` on a missing member is an error, while
386
+ # `add` on an existing one replaces it.
387
+ op = "add" if path == ROOT else "replace"
388
+ value = {"combinator": kind, "children": [node], **fields}
389
+ return Patch(ops=[PatchOp(model_id, op, path, value)])
390
+ # edit: only what moved. An Apply with nothing changed staged the
391
+ # kind and every field again, four ops for no edit. Nothing moved
392
+ # is None — the row has closed, and nothing is staged.
393
+ ops = []
394
+ if node.get("combinator") != kind:
395
+ ops.append(PatchOp(model_id, "replace", f"{path}/combinator", kind))
396
+ for key, value in fields.items():
397
+ if key in node:
398
+ if node[key] != value:
399
+ ops.append(PatchOp(model_id, "replace", f"{path}/{key}", value))
400
+ elif not (key == "direction" and value == _DEFAULT_DIRECTION):
401
+ ops.append(PatchOp(model_id, "add", f"{path}/{key}", value))
402
+ keeps = _KIND_KEYS.get(kind, ())
403
+ ops += [
404
+ PatchOp(model_id, "remove", f"{path}/{key}")
405
+ for key in _COMBINATOR_KEYS
406
+ if key in node and key not in keeps
407
+ ]
408
+ return Patch(ops=ops) if ops else None
409
+
410
+
411
+ def _combinator_fields(
412
+ ui: Any,
413
+ node: dict | None,
414
+ sentence: Callable[[str], str],
415
+ children: int,
416
+ fixed: str | None = None,
417
+ ) -> tuple[Callable[[], dict[str, Any]], Callable[[Any], None]]:
418
+ """The kind select and ONLY the chosen kind's fields (board 1B), in a
419
+ column rebuilt on the select's change; a value typed for a kind is kept
420
+ for when the user returns to it. Under them, ``sentence(kind)`` — what
421
+ the action does for that kind (`dstf-why`). ``node`` prefills them: the
422
+ node's own kind and fields for Edit, none for Wrap.
423
+
424
+ Returns the reader the action calls at apply time, and ``guard``: the
425
+ action button handed to it is grey, with `enter a threshold` as its
426
+ tooltip, while the kind is weighted and no threshold is typed. The
427
+ threshold starts EMPTY unless the node has one, and the weights start
428
+ as one 1 per child — ``children``, the node's count after the action
429
+ (1 for a wrap) — so an untouched weighted form stages what it shows:
430
+ before, it staged no weights and threshold 0, refused every time.
431
+
432
+ ``fixed`` draws no kind select: the form is that kind's (the Edit form,
433
+ whose kind is the node's or the one its dropdown chose)."""
434
+ names = [c.name for c in combinators()]
435
+ node = node or {}
436
+ current = node.get("combinator")
437
+ weights = node.get("weights") or [1] * children
438
+ values: dict[str, Any] = {
439
+ "k": node.get("k") or 1,
440
+ "weights": ", ".join(str(w) for w in weights),
441
+ "threshold": node.get("threshold"),
442
+ "direction": node.get("direction") or _DEFAULT_DIRECTION,
443
+ }
444
+ kind: Any = (
445
+ SimpleNamespace(value=fixed)
446
+ if fixed is not None
447
+ else ui.select(
448
+ names, label="Combinator", value=current if current in names else names[0]
449
+ )
450
+ .props("dense")
451
+ .classes("w-full")
452
+ )
453
+ fields = ui.column().classes("w-full gap-1")
454
+ why = ui.label("").classes("dstf-why")
455
+ #: The action buttons `guard` was handed, each with its tooltip.
456
+ guarded: list[tuple[Any, Any]] = []
457
+
458
+ def gate() -> None:
459
+ waiting = kind.value == "weighted" and not _typed(values["threshold"])
460
+ for button, tip in guarded:
461
+ button.set_enabled(not waiting)
462
+ tip.set_visibility(waiting)
463
+
464
+ def keep(key: str) -> Callable[[Any], None]:
465
+ def kept(e: Any) -> None:
466
+ values[key] = e.value
467
+ gate()
468
+
469
+ return kept
470
+
471
+ def rebuild() -> None:
472
+ from ..typed_number import TypedNumber
473
+
474
+ fields.clear()
475
+ with fields:
476
+ if kind.value == "k-of-n":
477
+ TypedNumber(
478
+ label="k — how many must pass",
479
+ value=values["k"],
480
+ on_change=keep("k"),
481
+ ).props("dense").classes("w-full")
482
+ elif kind.value == "weighted":
483
+ ui.input(
484
+ label="Weights, one per child",
485
+ value=values["weights"],
486
+ on_change=keep("weights"),
487
+ ).props("dense").classes("w-full")
488
+ TypedNumber(
489
+ label="Threshold",
490
+ value=values["threshold"],
491
+ on_change=keep("threshold"),
492
+ ).props("dense").classes("w-full")
493
+ hint = (
494
+ ui.label(_THRESHOLD_HINTS[str(values["direction"])])
495
+ .classes("dstf-why")
496
+ .mark("threshold-hint")
497
+ )
498
+
499
+ def direction(e: Any) -> None:
500
+ keep("direction")(e)
501
+ hint.set_text(_THRESHOLD_HINTS[str(e.value)])
502
+
503
+ ui.select(
504
+ _DIRECTIONS,
505
+ label="Direction",
506
+ value=values["direction"],
507
+ on_change=direction,
508
+ ).props("dense").classes("w-full")
509
+ why.set_text(sentence(str(kind.value)))
510
+ gate()
511
+
512
+ def guard(button: Any) -> None:
513
+ with button:
514
+ tip = ui.tooltip(_NO_THRESHOLD)
515
+ guarded.append((button, tip))
516
+ gate()
517
+
518
+ if fixed is None:
519
+ kind.on_value_change(lambda _e: rebuild())
520
+ rebuild()
521
+ return (lambda: {"kind": kind.value, **values}), guard
522
+
523
+
524
+ def _add_form(
525
+ ui: Any,
526
+ s: SessionView,
527
+ ui_panel: Panel,
528
+ model_id: str,
529
+ *,
530
+ under: str,
531
+ variable: str | None,
532
+ mark: str,
533
+ close: Callable[[], None],
534
+ added: str,
535
+ own: bool = False,
536
+ title: Callable[[str], str] | None = None,
537
+ ) -> None:
538
+ """A form row's variable picker and metric select, the sentence, Cancel
539
+ and Add (marked ``mark``), which stages and, once that went through,
540
+ closes the row and reveals ``added`` — the new criterion's path; a
541
+ refused Add keeps the row.
542
+
543
+ The picker (Q8) offers EVERY variable of the result file, tracked or
544
+ not — a criterion on one is what tracks it (the next run records it) —
545
+ in sections: the plot's own series, the rest, the diagnostics the run
546
+ recorded. Its values are result-file names — a leaf is written against
547
+ the name — and a row reads the label, with the name beside it. A list
548
+ of ``unitTests.x[1..12]`` named nothing a person could choose between.
549
+ The names are read when the form OPENS, never when the `+` is drawn.
550
+
551
+ Under the picker, while the variable picked is an element of an array
552
+ (`_family`), an Indices field (D152, the owner's R1): filled with every
553
+ element the result has (``1:20``), it takes a range, a stepped range
554
+ and a list (`families.parse_slice`), and Add writes one criterion per
555
+ chosen element. Enter in it is Add. ``own``: the form was opened from
556
+ the row of ONE element (a criterion's `+`), and the field starts at
557
+ that element's own index — a second criterion on `pipe.T[3]` is what
558
+ its `+` is asked for, and "all twenty" there added nineteen nobody
559
+ meant. Beside the field a count of what it names, as it is typed, and
560
+ Add says the same (`Add 20 criteria`): `1:20:5` is start:step:end, one
561
+ element, and reads as five. ``title`` is the row's heading for what is
562
+ being added to — the plot's `+` says `Add a criterion for
563
+ pipe.T[1:20]`, following the pick and the Indices (`Panel.retitle`)."""
564
+ from ..picker_rows import picker_rows
565
+ from ..shell.picker import VariablePicker
566
+
567
+ names = s.variable_names(model_id)
568
+ if variable and variable not in names:
569
+ names = [variable, *names]
570
+ on_plot = _plot_series(s, model_id, s.selection.variable)
571
+ diagnostics = list(_diagnostics(s, model_id))
572
+ texts = s.variable_texts(model_id)
573
+ first = variable or (on_plot or names or [""])[0]
574
+
575
+ #: The metric chosen on an ordinary variable while a diagnostic —
576
+ #: which offers scalar alone — is picked: back on an ordinary one, it is
577
+ #: chosen again, not the first metric of the list.
578
+ kept: dict[str, str] = {}
579
+
580
+ #: The picked variable's family, read once per pick: the count below
581
+ #: asks it on every key.
582
+ picked: dict[str, Family | None] = {"family": None}
583
+
584
+ def offer_indices(name: str, only: bool = False) -> None:
585
+ """The Indices field follows the pick: every element of its family
586
+ — the picked element's own index with ``only`` — or no field."""
587
+ family = None if name in diagnostics else _family(s, model_id, name, names)
588
+ picked["family"] = family
589
+ indices.set_visibility(family is not None)
590
+ counted.set_visibility(family is not None)
591
+ if family is not None:
592
+ own_index = family_of(name)
593
+ text = family.text if not only or own_index is None else str(own_index[1])
594
+ indices.props["hint"] = (
595
+ f"Elements of {family.label} to add the criterion for: a range "
596
+ "(1:5), a stepped range (1:5:20, start:step:end) or a list (1:3,7)."
597
+ )
598
+ indices.update()
599
+ indices.set_value(text)
600
+ count()
601
+
602
+ def count() -> None:
603
+ """What Add would add, said beside Indices and on the button, and
604
+ in the row's title, as the text is typed."""
605
+ family, name = picked["family"], var.value
606
+ what = texts[name].text if name in texts else name
607
+ if family is None:
608
+ add.set_text("Add")
609
+ else:
610
+ try:
611
+ chosen = family.chosen(str(indices.value or ""))
612
+ except ValueError as e:
613
+ counted.set_text(str(e))
614
+ add.set_text("Add")
615
+ return # the title keeps the last text that named something
616
+ counted.set_text(plural(len(chosen), "element"))
617
+ add.set_text(f"Add {plural(len(chosen), 'criterion', 'criteria')}")
618
+ what = elements_text(chosen)
619
+ if title is not None:
620
+ ui_panel.retitle(title(what))
621
+
622
+ def follow(name: str) -> None:
623
+ """The Metric select offers what can score ``name`` (Q2)."""
624
+ options = _metrics_for(diagnostic=name in diagnostics)
625
+ value = met.value
626
+ if name in diagnostics:
627
+ if list(met.options) != options: # leaving an ordinary variable
628
+ kept["metric"] = met.value
629
+ else:
630
+ value = kept.pop("metric", value)
631
+ met.set_options(options, value=value if value in options else options[0])
632
+ offer_indices(name)
633
+
634
+ var = VariablePicker(
635
+ lambda keep: picker_rows(
636
+ on_plot=on_plot,
637
+ names=names,
638
+ diagnostics=diagnostics,
639
+ texts=texts,
640
+ keep=keep,
641
+ ),
642
+ value=first,
643
+ text_of=lambda name: texts[name].text if name in texts else name,
644
+ on_change=follow,
645
+ marker=f"{mark}:variable",
646
+ )
647
+ indices = (
648
+ ui.input(label="Indices", on_change=lambda _e: count())
649
+ .props("dense")
650
+ .classes("w-full")
651
+ .mark(f"{mark}:indices")
652
+ )
653
+ counted = ui.label().classes("dstf-why").mark(f"{mark}:count")
654
+ options = _metrics_for(diagnostic=first in diagnostics)
655
+ met = (
656
+ ui.select(
657
+ options,
658
+ label="Metric",
659
+ value=DEFAULT_METRIC if DEFAULT_METRIC in options else options[0],
660
+ )
661
+ .props("dense")
662
+ .classes("w-full")
663
+ )
664
+
665
+ def apply() -> None:
666
+ text = str(indices.value or "") if indices.visible else None
667
+ try:
668
+ several = len(_chosen(s, model_id, var.value, text, names)) > 1
669
+ except ValueError: # the submit raises it again, and says it
670
+ several = False
671
+ if not _submit(
672
+ s,
673
+ ui_panel,
674
+ model_id=model_id,
675
+ action="add",
676
+ path=under,
677
+ variable=var.value,
678
+ metric=met.value,
679
+ indices=text,
680
+ ):
681
+ return # refused: the row stays, with what was chosen
682
+ close()
683
+ leaf = _first_leaf(s.tree(model_id), added)
684
+ # Several elements are one folded row, and that row's editor is the
685
+ # one to show: its first member is the first leaf added.
686
+ ui_panel.reveal(fold_key(leaf) if several else leaf)
687
+
688
+ indices.on("keydown.enter", apply)
689
+
690
+ # A criterion staged on a simulate-only test is never scored (the
691
+ # comparator short-circuits it): Add is grey there, tree or not.
692
+ simulate_only = _simulate_only(s, model_id)
693
+ ui.label(SIMULATE_ONLY if simulate_only else _ADD_SENTENCE).classes("dstf-why")
694
+ with ui.row().classes("items-center gap-2"):
695
+ _cancel(ui, close, under)
696
+ # A disabled Quasar button takes no pointer events: the reason's
697
+ # tooltip is on a span around it.
698
+ with ui.element("span"):
699
+ add = ui.button("Add", on_click=apply).props("dense no-caps").mark(mark)
700
+ if simulate_only:
701
+ add.disable()
702
+ ui.tooltip(SIMULATE_ONLY)
703
+ # Once Add stands: the count is said on it too.
704
+ offer_indices(first, only=own)
705
+
706
+
707
+ def _family(
708
+ s: SessionView, model_id: str, variable: str, names: list[str] | None = None
709
+ ) -> Family | None:
710
+ """The array family ``variable`` is an element of, as the last run's
711
+ result file has it (`family_rows.family_in` over ``names``, read here
712
+ when not given) — or None: no element of an array, the only one there
713
+ is, or an element of the test's UnitTests component, which is a test
714
+ quantity of its own and never one of a family (D140)."""
715
+ project = s.project
716
+ tests = project.tests if project is not None else ()
717
+ test = next((t for t in tests if t.model_id == model_id), None)
718
+ if test is not None and is_unit_test_variable(test, variable):
719
+ return None
720
+ return family_in(s.variable_names(model_id) if names is None else names, variable)
721
+
722
+
723
+ def _chosen(
724
+ s: SessionView,
725
+ model_id: str,
726
+ variable: str,
727
+ indices: str | None,
728
+ names: list[str] | None = None,
729
+ ) -> list[str]:
730
+ """The variables an add scores: ``variable`` alone with no Indices
731
+ text, else the elements of its family the text names, by index
732
+ (`Family.chosen`, whose refusals are the sentences shown)."""
733
+ if indices is None:
734
+ return [variable]
735
+ family = _family(s, model_id, variable, names)
736
+ if family is None:
737
+ raise ValueError(f"{variable} is no element of an array: it takes no indices")
738
+ return family.chosen(indices)
739
+
740
+
741
+ def _simulate_only(s: SessionView, model_id: str) -> bool:
742
+ project = s.project
743
+ tests = project.tests if project is not None else ()
744
+ return any(t.simulate_only for t in tests if t.model_id == model_id)
745
+
746
+
747
+ def _into(tree: dict | None, node: dict, path: str) -> str:
748
+ """The kind of the combinator an add at ``path`` lands in — a root
749
+ leaf becomes an `and` of both: a time warns only in an `and`."""
750
+ if "combinator" in node:
751
+ return str(node["combinator"])
752
+ if path == ROOT:
753
+ return "and"
754
+ parent_node = node_or_none(tree, path.rsplit("/children/", 1)[0])
755
+ return str((parent_node or {}).get("combinator"))
756
+
757
+
758
+ def _quantity_leaf(diagnostics: dict[str, Any], quantity: str, into: str) -> dict:
759
+ """The criterion an add on ``quantity`` stages, landing in ``into``: a
760
+ stored number, no variable. One seed per path — a diagnostic's final
761
+ is the add form's diagnostic criterion, its kind read from its samples,
762
+ and a top-level statistic naming a diagnostic reads them too, as its
763
+ ``X.final`` would."""
764
+ name, _, field_name = quantity.rpartition(".")
765
+ if field_name == "final" and name in diagnostics:
766
+ return seed_diagnostic(name, diagnostics[name], into=into)
767
+ return seed_quantity(
768
+ quantity, quantity_kind(quantity, diagnostics.get(quantity)), into=into
769
+ )
770
+
771
+
772
+ def _requirement(leaf: dict) -> str:
773
+ """What a Statistics row's `+` adds, in the words of its seed — the
774
+ tolerance is the seed's, never a second copy of it. Read as any
775
+ tolerance is (`parse_sides`): a number, or a lower/upper pair, whose
776
+ two sides — where they differ — read as its two edges."""
777
+ inner = leaf["children"][0] if "combinator" in leaf else leaf
778
+ written = inner.get("tolerance")
779
+ tolerance = parse_sides(0 if written is None else written, "tolerance")
780
+ rel = inner.get("tolerance_mode") == "rel"
781
+ if not tolerance.equal:
782
+ edges = (
783
+ signed_sides(tolerance, "g", scale=100, unit="%")
784
+ if rel
785
+ else signed_sides(tolerance, "g")
786
+ )
787
+ what = f"within {edges}"
788
+ elif rel:
789
+ what = f"within {tolerance.upper * 100:g}%"
790
+ else:
791
+ what = "exact match" if tolerance.upper == 0 else f"within ±{tolerance.upper:g}"
792
+ warning = ", as a warning" if leaf.get("combinator") == "warn" else ""
793
+ return f"Add a requirement: {what}{warning}"
794
+
795
+
796
+ def _cancel(ui: Any, close: Callable[[], None], path: str) -> None:
797
+ """A form row's Cancel: the row goes, nothing is staged."""
798
+ ui.button("Cancel", on_click=close).props("flat dense no-caps").mark(
799
+ f"cancel:{path}"
800
+ )
801
+
802
+
803
+ def _parent_of(path: str) -> str | None:
804
+ return path.rsplit("/children/", 1)[0] if "/children/" in path else None
805
+
806
+
807
+ def _first_leaf(tree: dict | None, path: str) -> str:
808
+ """The first leaf at or under ``path``: what an Add reveals. A seed may
809
+ be a combinator around its leaf — CPU time is added to an ``and``
810
+ inside a ``warn`` (`seed_diagnostic`) — and the editor opens on the
811
+ criterion."""
812
+ node = node_or_none(tree, path)
813
+ while node is not None and node.get("children"):
814
+ path, node = f"{path}/children/0", node["children"][0]
815
+ return path
816
+
817
+
818
+ def _added_path(tree: dict | None, path: str) -> str:
819
+ """Where an Add from ``path``'s row lands, read from the tree as it is
820
+ when the row is built — after the add the count has moved by one:
821
+ under a combinator its next child (the end); beside the root leaf the
822
+ root's second (the root becomes an ``and`` of both); beside any other
823
+ leaf the place right after it."""
824
+ node = node_or_none(tree, path) or {}
825
+ if "combinator" in node:
826
+ return f"{path}/children/{len(node.get('children') or [])}"
827
+ if path == ROOT:
828
+ return f"{ROOT}/children/1"
829
+ parent, index = path.rsplit("/children/", 1)
830
+ return f"{parent}/children/{int(index) + 1}"
831
+
832
+
833
+ def _cluster(
834
+ ui: Any, s: SessionView, ui_panel: Panel, model_id: str, path: str
835
+ ) -> None:
836
+ """One node's controls: flat round icon buttons, each form a ROW under
837
+ the node's row (`Panel.row`, the 2026-09-21 board, 1A-1D).
838
+
839
+ A form is built on its icon's CLICK, from the tree as it is then, and
840
+ cleared on Cancel, on an action that went through (a refused one keeps
841
+ the row and what was typed, with the refusal under the node's row,
842
+ above the form), or when the page closes the row; the page rebuilds an
843
+ open one when an edit staged elsewhere moves its node (`Panel.row`'s
844
+ contract), so a form never shows a value the node no longer has — a
845
+ combinator edited and then Discarded reopened on its old ``k``. Nothing
846
+ but the icons exists until then: a cluster whose three forms were built
847
+ with it was ~28 elements, and a tree row holds one per node — 40 leaves
848
+ made a 2387-element page with the tree open, and every sync re-sent
849
+ them. The title is read inside the click too: a `tree_view` read per
850
+ cluster at build cost one read per node on every rebuild of the rows
851
+ (the page's cost tests)."""
852
+ tree = s.tree(model_id)
853
+ node = node_or_none(tree, path)
854
+ if node is None: # `available` already says so; an edit may race
855
+ return
856
+ is_combinator = "combinator" in node
857
+ # `+` adds at the end of a combinator, right after a leaf: the
858
+ # combinator that would take the child decides whether one can be
859
+ # added alone.
860
+ parent = _parent_of(path)
861
+ taker = node if is_combinator else node_or_none(tree, parent or "")
862
+ no_add = _NO_ADD.get(str((taker or {}).get("combinator")))
863
+
864
+ def icon(
865
+ name: str, tip: str, mark: str, on_click: Any = None, enabled: bool = True
866
+ ) -> Any:
867
+ button = ui.button(icon=name, on_click=on_click)
868
+ button.props("flat dense round size=sm")
869
+ with button:
870
+ # Above the icon: below it (Quasar's default) the tooltip covers
871
+ # the title of the form row the same click opens under the row.
872
+ ui.tooltip(tip).props('anchor="top middle" self="bottom middle"')
873
+ # Its accessible name is the tooltip: the icon's ligature ("add",
874
+ # "data_array") names nothing. Set as an item, not through the props
875
+ # string, whose parser a quote in a node's text would break.
876
+ button.props["aria-label"] = tip
877
+ if not enabled:
878
+ button.disable()
879
+ return button.mark(mark)
880
+
881
+ def text() -> str:
882
+ """What the node's row reads, for a form's title — read at the
883
+ icon's click, never while the cluster is built."""
884
+ seen = next(
885
+ (n for n in nodes_of(s.tree_view(model_id)) if n.get("path") == path),
886
+ None,
887
+ )
888
+ return path if seen is None else node_text(seen)
889
+
890
+ def act_now(action: str) -> bool:
891
+ return _submit(s, ui_panel, model_id=model_id, action=action, path=path)
892
+
893
+ def fresh() -> dict | None:
894
+ """The node as it is at THIS build; the sentence in the row when the
895
+ tree has moved out from under the path since the cluster was drawn."""
896
+ at_open = node_or_none(s.tree(model_id), path)
897
+ if at_open is None:
898
+ ui.label(f"{path} names no node of {model_id}").classes("text-negative")
899
+ return at_open
900
+
901
+ def build_add(close: Callable[[], None]) -> None:
902
+ at_open = fresh()
903
+ if at_open is None:
904
+ return
905
+ _add_form(
906
+ ui,
907
+ s,
908
+ ui_panel,
909
+ model_id,
910
+ under=path,
911
+ variable=s.selection.variable or at_open.get("variable"),
912
+ mark=f"apply-add:{path}",
913
+ close=close,
914
+ # From the tree as it is NOW, before the add moves the count.
915
+ added=_added_path(s.tree(model_id), path),
916
+ # A criterion's `+` is its ONE element's.
917
+ own=not is_combinator,
918
+ )
919
+
920
+ def build_wrap(close: Callable[[], None]) -> None:
921
+ if fresh() is None:
922
+ return
923
+ what = "node" if is_combinator else "criterion"
924
+ # The wrapped node is the new combinator's one child.
925
+ values, guard = _combinator_fields(
926
+ ui,
927
+ None,
928
+ lambda kind: _WRAP_SENTENCES.get(kind, "").format(what=what),
929
+ children=1,
930
+ )
931
+
932
+ def apply() -> None:
933
+ if _submit(
934
+ s, ui_panel, model_id=model_id, action="wrap", path=path, **values()
935
+ ):
936
+ close()
937
+
938
+ with ui.row().classes("items-center gap-2"):
939
+ _cancel(ui, close, path)
940
+ guard(
941
+ ui.button("Wrap", on_click=apply)
942
+ .props("dense no-caps")
943
+ .mark(f"apply-wrap:{path}")
944
+ )
945
+
946
+ def build_edit(close: Callable[[], None], kind: str | None = None) -> None:
947
+ """The Edit form: ``kind``'s fields — the node's own kind, or the
948
+ one its dropdown chose (k-of-n, weighted: they need fields before
949
+ anything can be staged). No kind select: the dropdown is where the
950
+ kind changes, and unwrap is its last entry."""
951
+ at_open = fresh()
952
+ if at_open is None:
953
+ return
954
+ own = str(at_open.get("combinator"))
955
+ fixed = kind or own
956
+ values, guard = _combinator_fields(
957
+ ui,
958
+ at_open,
959
+ lambda _kind: _EDIT_SENTENCE if fixed == own else _EDIT_KIND_SENTENCE,
960
+ children=len(at_open.get("children") or []),
961
+ fixed=fixed,
962
+ )
963
+
964
+ def apply() -> None:
965
+ if _submit(
966
+ s, ui_panel, model_id=model_id, action="edit", path=path, **values()
967
+ ):
968
+ close()
969
+
970
+ with ui.row().classes("items-center gap-2"):
971
+ _cancel(ui, close, path)
972
+ guard(
973
+ ui.button("Apply", on_click=apply)
974
+ .props("dense no-caps")
975
+ .mark(f"apply-edit:{path}")
976
+ )
977
+
978
+ _drag(s, ui_panel, model_id, path)
979
+ if node.get("combinator") in _NEEDS_FIELDS:
980
+ # A click on the node's row opens its fields (U5, Q6) — the root's
981
+ # too, or a k-of-n root's k could not be edited at all. and / or /
982
+ # warn have none: their kind is all there is, and the dropdown
983
+ # changes it, so their row's click does nothing.
984
+ ui_panel.on_click(lambda: ui_panel.row("Edit this combinator", build_edit))
985
+ if is_combinator:
986
+ ui_panel.lead(
987
+ partial(
988
+ _kind_select,
989
+ ui,
990
+ s,
991
+ ui_panel,
992
+ model_id,
993
+ path,
994
+ node,
995
+ lambda kind: ui_panel.row(
996
+ "Edit this combinator", partial(build_edit, kind=kind)
997
+ ),
998
+ )
999
+ )
1000
+
1001
+ with ui.row().classes("dstf-node-controls items-center no-wrap gap-0"):
1002
+ # A combinator takes the new criterion as its last child; a leaf
1003
+ # has it right after itself (Q3 A).
1004
+ where = "at the end of" if is_combinator else "after"
1005
+ icon(
1006
+ "add",
1007
+ no_add
1008
+ or (
1009
+ f"Add a criterion at the end of this {node['combinator']}"
1010
+ if is_combinator
1011
+ else "Add a criterion after this one"
1012
+ ),
1013
+ f"add:{path}",
1014
+ lambda: ui_panel.row(f"Add a criterion {where} {text()}", build_add),
1015
+ enabled=no_add is None,
1016
+ )
1017
+ icon(
1018
+ "data_array",
1019
+ "Wrap in a combinator (and / or / k-of-n / warn / weighted)",
1020
+ f"wrap:{path}",
1021
+ lambda: ui_panel.row(f"Wrap {text()} in a combinator", build_wrap),
1022
+ )
1023
+ if path != ROOT and ui_panel.can_move:
1024
+ # Moving is the tree card's (V2); the root has nowhere to go.
1025
+ _move_menu(ui, s, ui_panel, model_id, path)
1026
+ if path != ROOT: # the root has nothing to be removed from
1027
+ icon(
1028
+ "remove",
1029
+ "Remove this node and everything under it"
1030
+ if is_combinator
1031
+ else "Remove this criterion",
1032
+ f"remove:{path}",
1033
+ lambda: act_now("remove"),
1034
+ )
1035
+
1036
+
1037
+ def _variable_plus(
1038
+ ui: Any, s: SessionView, ui_panel: Panel, model_id: str, variable: str
1039
+ ) -> None:
1040
+ """The plot's `+`: an Add row for ``variable`` (its select offers the
1041
+ test's other variables too), landing at the end of the root combinator
1042
+ — or, under a root leaf, as ``and[leaf, new]`` — and revealing the new
1043
+ criterion's editor. Grey, with the reason, under a root that takes no
1044
+ child alone. A watch plot's variable the run did not record is offered
1045
+ too: a criterion names it, and that tracks it (U2).
1046
+
1047
+ It reads the variable as the PAGE does — the scope handed down with
1048
+ the selection (`Selection.texts`: the expression its model wrote, with
1049
+ the name beside it when another variable of that test shares it). The
1050
+ scope is not derived here: a caption per card that built it for itself
1051
+ was the page's whole rebuild cost. A selection carrying none reads the
1052
+ name; the form still writes the leaf against the NAME."""
1053
+ entry = s.selection.texts.get(variable)
1054
+ text = variable if entry is None else entry.text
1055
+ no_add = _NO_ADD.get(str((s.tree(model_id) or {}).get("combinator")))
1056
+ tip = no_add or f"Add a criterion for {text}"
1057
+
1058
+ def build_add(close: Callable[[], None]) -> None:
1059
+ _add_form(
1060
+ ui,
1061
+ s,
1062
+ ui_panel,
1063
+ model_id,
1064
+ under=ROOT,
1065
+ variable=variable,
1066
+ mark=f"apply-add:{variable}",
1067
+ close=close,
1068
+ # From the tree as it is NOW, before the add moves the count.
1069
+ added=_added_path(s.tree(model_id), ROOT),
1070
+ title=lambda what: f"Add a criterion for {what}",
1071
+ )
1072
+
1073
+ button = ui.button(
1074
+ icon="add",
1075
+ on_click=lambda: ui_panel.row(f"Add a criterion for {text}", build_add),
1076
+ )
1077
+ button.props("flat dense round size=sm")
1078
+ # Its accessible name is the sentence: the icon's ligature says "add".
1079
+ # Set as an item, not through the props string, whose parser a quote
1080
+ # in an expression would break.
1081
+ button.props["aria-label"] = tip
1082
+ with button.mark(f"add-for:{variable}"):
1083
+ ui.tooltip(tip).props('anchor="top middle" self="bottom middle"')
1084
+ if no_add is not None:
1085
+ button.disable()
1086
+
1087
+
1088
+ def planned_move(s: SessionView, model_id: str, src: str, dest: Dest) -> Moved | None:
1089
+ """The move `EditTree.act` stages, with where the node lands and what
1090
+ else changed — what a panel reveals and says after its ``submit`` went
1091
+ through; `act` stages this plan's tree, so the two cannot disagree.
1092
+ Compute it BEFORE `submit`: afterwards ``src`` names another node (in
1093
+ ``[x, y]``, child 1 to the front gives ``[y, x]``; once that went
1094
+ through, the same call moves x back). Checked against the test's
1095
+ baseline roles as `Session.stage` checks it, so a refusal is the
1096
+ validator's sentence, raised here."""
1097
+ tree, why = s.tree_or_reason(model_id)
1098
+ if tree is None:
1099
+ assert why is not None # `tree` is None
1100
+ raise no_tree_error(model_id, why)
1101
+ return plan_move(tree, src, dest, validate=_validator(s, model_id))
1102
+
1103
+
1104
+ def _validator(s: SessionView, model_id: str) -> Callable[[dict], None]:
1105
+ """What a move's new tree is checked against: the test's baseline
1106
+ roles, as `Session.stage` checks them."""
1107
+ project = s.project
1108
+ assert project is not None # a tree came from it
1109
+ return validator_for(project.role_lookup(model_id))
1110
+
1111
+
1112
+ def moved_text(notes: tuple[str, ...]) -> str:
1113
+ """What a move's row says (V4): "Moved." and each other change as a
1114
+ sentence of its own — "Moved. The empty warn was removed." A note that
1115
+ starts with a field's name keeps it as written: "Moved. k 3 → 2." """
1116
+
1117
+ def sentence(note: str) -> str:
1118
+ return note if note.startswith("k ") else note[:1].upper() + note[1:]
1119
+
1120
+ return " ".join(["Moved.", *(f"{sentence(n)}." for n in notes)])
1121
+
1122
+
1123
+ def _submit_move(
1124
+ s: SessionView, ui_panel: Panel, model_id: str, src: str, dest: Dest
1125
+ ) -> None:
1126
+ """Stage the move; once it went through, the page closes what is open
1127
+ and says where the node landed and what else changed (`Panel.moved`).
1128
+ Planned BEFORE the submit: afterwards ``src`` names another node."""
1129
+ try:
1130
+ moved = planned_move(s, model_id, src, dest)
1131
+ except Exception: # the submit raises it again, and says or logs it
1132
+ moved = None
1133
+ went = ui_panel.submit(model_id=model_id, action="move", path=src, dest=dest)
1134
+ if went and moved is not None:
1135
+ ui_panel.moved(moved.path, moved_text(moved.notes), moved.paths)
1136
+
1137
+
1138
+ def _submit(s: SessionView, ui_panel: Panel, **inputs: Any) -> bool:
1139
+ """`Panel.submit` for every action but a move, and once it went through
1140
+ where each node it kept went (`Panel.edited`, U5 R1): mapped BEFORE the
1141
+ submit, from the ops `act` builds over the tree as it is now — as a
1142
+ move is planned before its submit: afterwards the paths name the new
1143
+ tree. An act that raises maps nothing; the submit raises it again, and
1144
+ says or logs it."""
1145
+ paths: dict[str, str] | None = None
1146
+ try:
1147
+ tree = s.tree(inputs["model_id"])
1148
+ patch = None if tree is None else _patch(s, tree, inputs)
1149
+ except Exception:
1150
+ tree = patch = None
1151
+ if tree is not None and patch is not None:
1152
+ paths = path_map(tree, patch.ops)
1153
+ went = ui_panel.submit(**inputs)
1154
+ if went and paths is not None:
1155
+ ui_panel.edited(paths)
1156
+ return went
1157
+
1158
+
1159
+ def _drag(s: SessionView, ui_panel: Panel, model_id: str, path: str) -> None:
1160
+ """The node's part in the host's drag (U4): a drop on its row is a
1161
+ move to that zone's place (`zone_dest`); a drag from it — never from
1162
+ the root — asks the tree as it is at that moment which zones refuse,
1163
+ and why (`zone_map`, once per drag)."""
1164
+
1165
+ def on_drop(src: str, zone: str) -> None:
1166
+ dest = zone_dest(path, zone)
1167
+ if dest is not None: # a zone the row has not got moves nothing
1168
+ _submit_move(s, ui_panel, model_id, src, dest)
1169
+
1170
+ def zones() -> dict[str, dict[str, str | None]]:
1171
+ tree = s.tree(model_id)
1172
+ if tree is None:
1173
+ return {}
1174
+ return zone_map(tree, path, validate=_validator(s, model_id))
1175
+
1176
+ ui_panel.movable(on_drop, None if path == ROOT else zones)
1177
+
1178
+
1179
+ def _move_menu(
1180
+ ui: Any, s: SessionView, ui_panel: Panel, model_id: str, path: str
1181
+ ) -> None:
1182
+ """Move to… (U4): an icon whose menu lists every place the node can go
1183
+ (`move_destinations`, in tree order) — BUILT AT THE CLICK, from the tree
1184
+ as it is then, like a form: the list is every place of the tree, and
1185
+ one per row built with the rows was ~20 s of rebuild on a 134-node
1186
+ tree (Q6). A refused place is greyed with its reason as a second line,
1187
+ where the node is greyed with none; the arrow keys skip both. Each
1188
+ place reads its node's text cut in the middle, then where in it — the
1189
+ row a "before" names cut in the middle too."""
1190
+ from ..shell.drawer_controls import menu_keys
1191
+ from ..shell.widgets import MiddleCut
1192
+
1193
+ button = ui.button(icon="open_with").props("flat dense round size=sm")
1194
+ # Its accessible name is the tooltip; set as items, like the icons'.
1195
+ button.props["aria-label"] = _MOVE_TO
1196
+ button.props["aria-haspopup"] = "menu"
1197
+ button.props["aria-expanded"] = "false"
1198
+ with button:
1199
+ tip = ui.tooltip(_MOVE_TO).props('anchor="top middle" self="bottom middle"')
1200
+ # Opened by `fill`, not by the button's own click: it is filled
1201
+ # first. Below the row's right end, and scrolling within its own
1202
+ # height: a menu of every place in the tree is long.
1203
+ menu = (
1204
+ ui.menu()
1205
+ .props(
1206
+ 'no-parent-event role="menu" anchor="bottom right" '
1207
+ 'self="top right" max-height="40vh"'
1208
+ )
1209
+ .classes("dstf-move-menu")
1210
+ .mark(f"move-menu:{path}")
1211
+ )
1212
+ menu.on("keydown", js_handler=menu_keys(menu))
1213
+
1214
+ def toggled(e: Any) -> None:
1215
+ # No tooltip while the menu is open: it covered the first place.
1216
+ button.props["aria-expanded"] = "true" if e.value else "false"
1217
+ button.update()
1218
+ tip.set_visibility(not e.value)
1219
+
1220
+ menu.on_value_change(toggled)
1221
+
1222
+ def choose(dest: Dest) -> None:
1223
+ menu.close()
1224
+ _submit_move(s, ui_panel, model_id, path, dest)
1225
+
1226
+ def fill() -> None:
1227
+ menu.clear()
1228
+ tree = s.tree(model_id)
1229
+ if tree is None: # `available` already says why; an edit may race
1230
+ return
1231
+ places = move_destinations(
1232
+ tree, path, validate=_validator(s, model_id), names=s.labels(model_id)
1233
+ )
1234
+ with menu:
1235
+ for i, (text, where, dest, reason) in enumerate(places):
1236
+ item = (
1237
+ ui.menu_item(on_click=partial(choose, dest), auto_close=False)
1238
+ .props('dense role="menuitem" tabindex="-1"')
1239
+ .mark(f"move-dest:{path}:{i}")
1240
+ )
1241
+ with item, ui.item_section():
1242
+ with ui.item_label().classes("dstf-move-label"):
1243
+ MiddleCut(text)
1244
+ with ui.element("span").classes("dstf-move-where"):
1245
+ if where.startswith(f"{BEFORE} "):
1246
+ # The row it names, cut in the middle too:
1247
+ # whole, a long one ran past the menu.
1248
+ ui.label(f"— {BEFORE}")
1249
+ MiddleCut(where[len(BEFORE) + 1 :])
1250
+ else:
1251
+ ui.label(f"— {where}")
1252
+ if reason is not None:
1253
+ ui.item_label(reason).props("caption").mark("move-why")
1254
+ if reason is not None or where == WHERE_IT_IS:
1255
+ item.disable()
1256
+ item.props["aria-disabled"] = "true"
1257
+ menu.open()
1258
+
1259
+ button.on_click(fill)
1260
+ button.mark(f"move:{path}")
1261
+
1262
+
1263
+ def _statistic_plus(
1264
+ ui: Any, s: SessionView, ui_panel: Panel, model_id: str, quantity: str
1265
+ ) -> None:
1266
+ """A Statistics row's `+` (U3): no form — the requirement is the seed
1267
+ (a count exact, a time within its tolerance, inside a `warn` where it
1268
+ lands in an `and`), added at the END of the root (Q6 b), or as
1269
+ ``and[leaf, new]`` under a root leaf; the new criterion's editor opens
1270
+ after it. Grey with the add form's reason where nothing can take it:
1271
+ a simulate-only test, a test with no tree to
1272
+ add to (the Session's `tree_or_reason`), a root that takes no child
1273
+ alone."""
1274
+ tree, why = s.tree_or_reason(model_id)
1275
+ reason: str | None
1276
+ if _simulate_only(s, model_id):
1277
+ # Tree or not: the comparator scores nothing on it.
1278
+ reason = SIMULATE_ONLY
1279
+ elif tree is None:
1280
+ # The Session's one reason: nothing scored, a stale run, or a tree
1281
+ # it refuses.
1282
+ reason = why
1283
+ else:
1284
+ reason = _NO_ADD.get(str(tree.get("combinator")))
1285
+ tip = reason
1286
+ if tip is None:
1287
+ assert tree is not None # `reason` said so
1288
+ into = _into(tree, tree, ROOT)
1289
+ tip = _requirement(_quantity_leaf(_diagnostics(s, model_id), quantity, into))
1290
+
1291
+ def add() -> None:
1292
+ # From the tree as it is NOW, before the add moves the count.
1293
+ added = _added_path(s.tree(model_id), ROOT)
1294
+ if _submit(
1295
+ s, ui_panel, model_id=model_id, action="add", path=ROOT, quantity=quantity
1296
+ ):
1297
+ ui_panel.reveal(_first_leaf(s.tree(model_id), added))
1298
+
1299
+ # A disabled Quasar button takes no pointer events: the reason's
1300
+ # tooltip is on a span around it.
1301
+ with ui.element("span"):
1302
+ button = ui.button(icon="add", on_click=add)
1303
+ button.props("flat dense round size=sm")
1304
+ # Its accessible name is the sentence: the icon's ligature says
1305
+ # "add". Set as an item, not through the props string.
1306
+ button.props["aria-label"] = tip
1307
+ button.mark(f"add-statistic:{quantity}")
1308
+ ui.tooltip(tip).props('anchor="top middle" self="bottom middle"')
1309
+ if reason is not None:
1310
+ button.disable()
1311
+
1312
+
1313
+ def _kind_select(
1314
+ ui: Any,
1315
+ s: SessionView,
1316
+ ui_panel: Panel,
1317
+ model_id: str,
1318
+ path: str,
1319
+ node: dict,
1320
+ open_edit: Callable[[str], None],
1321
+ ) -> None:
1322
+ """A combinator's kind as a dropdown in its row (Q3 A), changed in
1323
+ place: and / or / warn stage at once; k-of-n / weighted open the Edit
1324
+ form preset to that kind (``open_edit``); `unwrap…`, below a separator,
1325
+ unwraps. A refused change says why in the row, and the select goes
1326
+ back to the node's kind.
1327
+
1328
+ Unwrap is not offered where `act` refuses it by rule — a root of
1329
+ several children has no parent to take them: an option cannot carry a
1330
+ why-tooltip."""
1331
+ current = str(node.get("combinator"))
1332
+ options = {c.name: c.name for c in combinators()}
1333
+ children = len(node.get("children") or [])
1334
+ if not (path == ROOT and children > 1):
1335
+ options[_UNWRAP_VALUE] = _UNWRAP_TEXT
1336
+ select = (
1337
+ ui.select(options, value=current)
1338
+ .props('dense borderless options-dense aria-label="Combinator kind"')
1339
+ .classes("dstf-kind")
1340
+ .mark(f"kind:{path}")
1341
+ )
1342
+ select.add_slot("option", _KIND_OPTION)
1343
+
1344
+ def chosen(e: Any) -> None:
1345
+ value = e.value
1346
+ if value == current:
1347
+ return
1348
+ if value in _NEEDS_FIELDS:
1349
+ select.set_value(current) # nothing is staged until Apply
1350
+ open_edit(str(value))
1351
+ return
1352
+ if value == _UNWRAP_VALUE:
1353
+ done = _submit(s, ui_panel, model_id=model_id, action="unwrap", path=path)
1354
+ else:
1355
+ done = _submit(
1356
+ s, ui_panel, model_id=model_id, action="edit", path=path, kind=value
1357
+ )
1358
+ if not done:
1359
+ select.set_value(current)
1360
+
1361
+ select.on_value_change(chosen)
1362
+
1363
+
1364
+ def _diagnostics(s: SessionView, model_id: str) -> dict[str, Any]:
1365
+ """``{name: values}`` of the diagnostics the last run RECORDED — the
1366
+ picker's "Diagnostics" (B4). Not `diagnostic_variables` against the
1367
+ file's names: a JSON-driver run records none, and a `CPUtime` column in
1368
+ its file is a variable a `CPUtime.final` quantity path never reads."""
1369
+ project = s.project
1370
+ result = project.results.get(model_id) if project is not None else None
1371
+ if result is None:
1372
+ return {}
1373
+ return {d.name: d.values for d in result.diagnostics or [] if d.name}
1374
+
1375
+
1376
+ def _metrics_for(*, diagnostic: bool) -> list[str]:
1377
+ """What the Metric select offers (Q2): a diagnostic is one stored
1378
+ number, scored by a metric that takes a ``quantity``; any other
1379
+ variable, every metric that scores a time series."""
1380
+ return [
1381
+ d.name
1382
+ for d in metrics()
1383
+ if (
1384
+ d.accepts_quantity
1385
+ if diagnostic
1386
+ else DatasetType.TIME_SERIES in d.accepted_datasets
1387
+ )
1388
+ ]
1389
+
1390
+
1391
+ def _plot_series(s: SessionView, model_id: str, variable: str | None) -> list[str]:
1392
+ """Every series of the plot ``variable`` is the subject of — the
1393
+ picker's "On this plot" — or none, where no plot is (a tree row)."""
1394
+ if not variable:
1395
+ return []
1396
+ for plot in s.plots(model_id):
1397
+ if variable in plot.subject:
1398
+ return list(plot.series)
1399
+ return [variable]