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.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- 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]
|