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,526 @@
|
|
|
1
|
+
"""A criterion over an array family as data (D152): which of a plot's
|
|
2
|
+
criteria rows fold into one, which elements an Indices text names, and the
|
|
3
|
+
per-leaf ops an edit of the fold is — the shell draws the rows
|
|
4
|
+
(`shell/family_fold.py`) and the builtins stage the ops, and neither knows
|
|
5
|
+
the rules, as the Variables drawer draws `variable_rows`.
|
|
6
|
+
|
|
7
|
+
Nothing here is stored: `test_spec.json` holds one literal leaf per element
|
|
8
|
+
(the brief's ruling §3.10), and the fold is derived from the tree every
|
|
9
|
+
time it is drawn. The rows come from the report's walk
|
|
10
|
+
(`reporting.tree_rows.criteria_rows`), which the report goes on rendering
|
|
11
|
+
one per leaf — the fold is the tool's alone."""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import copy
|
|
16
|
+
import json
|
|
17
|
+
from collections.abc import Collection, Mapping, Sequence
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from ..discovery.patch_apply import apply_patch_to_document
|
|
21
|
+
from ..figures.families import family_label, family_of, parse_slice, slice_text
|
|
22
|
+
from .spec_document import PatchOp, json_equal
|
|
23
|
+
from .tree_pointer import node_or_none
|
|
24
|
+
from .unscorable import Refused
|
|
25
|
+
|
|
26
|
+
ROOT = "/metrics"
|
|
27
|
+
|
|
28
|
+
#: What a folded row's key ends with. The key is its first member's path
|
|
29
|
+
#: and this — never a JSON pointer — so the Test page's one open editor
|
|
30
|
+
#: `(plot index, key)` names a fold or a criterion and cannot mistake them.
|
|
31
|
+
_FOLD = "#fold"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fold_key(path: str) -> str:
|
|
35
|
+
"""The key of the fold whose first member (tree order) is at ``path``."""
|
|
36
|
+
return path + _FOLD
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_fold(key: str) -> bool:
|
|
40
|
+
return key.endswith(_FOLD)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def leaf_of(key: str) -> str:
|
|
44
|
+
"""A fold's first member's path; a criterion's own path as it is."""
|
|
45
|
+
return key[: -len(_FOLD)] if is_fold(key) else key
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parent_of(path: str) -> str | None:
|
|
49
|
+
"""The path of the combinator ``path``'s node is a child of."""
|
|
50
|
+
return path.rsplit("/children/", 1)[0] if "/children/" in path else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def criterion_of(leaf: Mapping) -> dict:
|
|
54
|
+
"""What a leaf of a `tree_view` IS apart from its variable: its metric,
|
|
55
|
+
its parameters, the baseline it is scored against and its window. Two
|
|
56
|
+
leaves with one of these are one criterion on two variables."""
|
|
57
|
+
return {
|
|
58
|
+
"metric": leaf.get("metric"),
|
|
59
|
+
"params": leaf.get("params") or {},
|
|
60
|
+
"against": leaf.get("against") or "primary",
|
|
61
|
+
"window": leaf.get("window") or {},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Fold:
|
|
67
|
+
"""One folded criteria row: ``rows`` are its members' rows
|
|
68
|
+
(`criteria_rows`' dicts), in tree order — two or more leaves of one
|
|
69
|
+
array ``family`` (its key, ``pipe.T[]``), children of one node, that
|
|
70
|
+
are one criterion (:func:`criterion_of`)."""
|
|
71
|
+
|
|
72
|
+
family: str
|
|
73
|
+
rows: tuple[dict, ...]
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def leaves(self) -> list[dict]:
|
|
77
|
+
return [row["leaf"] for row in self.rows]
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def paths(self) -> tuple[str, ...]:
|
|
81
|
+
return tuple(str(leaf["path"]) for leaf in self.leaves)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def variables(self) -> list[str]:
|
|
85
|
+
return [str(leaf["variable"]) for leaf in self.leaves]
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def key(self) -> str:
|
|
89
|
+
return fold_key(self.paths[0])
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def crumbs(self) -> str:
|
|
93
|
+
"""One parent, so one breadcrumb: the first member's."""
|
|
94
|
+
return str(self.rows[0]["crumbs"])
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def label(self) -> str:
|
|
98
|
+
"""``pipe.T[1:6,8:20]``: the family with its folded indices."""
|
|
99
|
+
return family_label(self.family, self.variables)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def name(self) -> str:
|
|
103
|
+
"""``tube · pipe.T[1:20]`` — what the row READS, as a criterion's
|
|
104
|
+
row reads ``metric · variable`` (`tree_text.leaf_name`)."""
|
|
105
|
+
return f"{self.leaves[0].get('metric')} · {self.label}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def fold_rows(
|
|
109
|
+
rows: Sequence[dict], *, pinned: Collection[str] = ()
|
|
110
|
+
) -> list[dict | Fold]:
|
|
111
|
+
"""A plot's criteria ``rows`` (`criteria_rows`) with each array
|
|
112
|
+
family's identical criteria folded into one :class:`Fold`, placed where
|
|
113
|
+
its first member's row was; every other row as it came, in its order.
|
|
114
|
+
|
|
115
|
+
Leaves fold when they are elements of ONE family by result-file name
|
|
116
|
+
(`figures.families.family_of`), children of ONE node, and ONE criterion
|
|
117
|
+
(:func:`criterion_of`, compared as JSON — `json_equal` — so ``1`` is
|
|
118
|
+
``1.0``) — and are two or more: a group of one is a row. Two groups of
|
|
119
|
+
one family are two folds. A second identical leaf on a variable the
|
|
120
|
+
group already holds stays its own row: a fold names each element once.
|
|
121
|
+
The rows are ONE plot's, so no UnitTests element is among them with
|
|
122
|
+
another: each is a plot of its own (D140, `derive_plots`).
|
|
123
|
+
|
|
124
|
+
``pinned`` are the member paths of a fold whose editor is OPEN: they
|
|
125
|
+
are kept together and apart from every other leaf, whatever they equal
|
|
126
|
+
— a value typed into that editor passes through others on its way
|
|
127
|
+
(`0.05` is `0.0` first), and one of those equal to another group's
|
|
128
|
+
would otherwise merge the two and carry the other group along with
|
|
129
|
+
the next keystroke."""
|
|
130
|
+
held = set(pinned)
|
|
131
|
+
#: (family, parent, criterion, member rows) in order of first appearance
|
|
132
|
+
groups: list[tuple[str, str | None, dict | None, list[dict]]] = []
|
|
133
|
+
group_of: dict[str, int] = {}
|
|
134
|
+
for row in rows:
|
|
135
|
+
leaf = row["leaf"]
|
|
136
|
+
name, path = leaf.get("variable"), str(leaf["path"])
|
|
137
|
+
fam = family_of(str(name)) if name else None
|
|
138
|
+
if fam is None:
|
|
139
|
+
continue
|
|
140
|
+
parent = parent_of(path)
|
|
141
|
+
# The pinned group is the open fold's, whatever its leaves equal.
|
|
142
|
+
criterion = None if path in held else criterion_of(leaf)
|
|
143
|
+
found = next(
|
|
144
|
+
(
|
|
145
|
+
i
|
|
146
|
+
for i, (family, node, held_criterion, _) in enumerate(groups)
|
|
147
|
+
if family == fam[0]
|
|
148
|
+
and node == parent
|
|
149
|
+
and (
|
|
150
|
+
(criterion is None and held_criterion is None)
|
|
151
|
+
or (
|
|
152
|
+
criterion is not None
|
|
153
|
+
and held_criterion is not None
|
|
154
|
+
and json_equal(criterion, held_criterion)
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
),
|
|
158
|
+
None,
|
|
159
|
+
)
|
|
160
|
+
if found is None:
|
|
161
|
+
groups.append((fam[0], parent, criterion, [row]))
|
|
162
|
+
group_of[path] = len(groups) - 1
|
|
163
|
+
elif name not in {m["leaf"].get("variable") for m in groups[found][3]}:
|
|
164
|
+
groups[found][3].append(row)
|
|
165
|
+
group_of[path] = found
|
|
166
|
+
out: list[dict | Fold] = []
|
|
167
|
+
for row in rows:
|
|
168
|
+
at = group_of.get(str(row["leaf"]["path"]))
|
|
169
|
+
if at is None or len(groups[at][3]) < 2:
|
|
170
|
+
out.append(row)
|
|
171
|
+
elif groups[at][3][0] is row:
|
|
172
|
+
out.append(Fold(groups[at][0], tuple(groups[at][3])))
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def tally(statuses: Sequence[str]) -> str:
|
|
177
|
+
"""A folded row's count, ``18 pass · 2 fail`` — its members by verdict
|
|
178
|
+
(`tree_card.leaf_status`'s words), an advisory and an unscored count
|
|
179
|
+
when either is non-zero, as the tree card's closed line tallies."""
|
|
180
|
+
parts = [f"{statuses.count('pass')} pass"]
|
|
181
|
+
for status, word in (
|
|
182
|
+
("fail", "fail"),
|
|
183
|
+
("warn", "advisory"),
|
|
184
|
+
("queued", "unscored"),
|
|
185
|
+
):
|
|
186
|
+
if statuses.count(status):
|
|
187
|
+
parts.append(f"{statuses.count(status)} {word}")
|
|
188
|
+
return " · ".join(parts)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# -- the elements an Indices field names ------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True)
|
|
195
|
+
class Family:
|
|
196
|
+
"""An array family as the last run's result file has it: its key
|
|
197
|
+
(``pipe.T[]``) and its elements' names by index."""
|
|
198
|
+
|
|
199
|
+
key: str
|
|
200
|
+
names: Mapping[int, str]
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def label(self) -> str:
|
|
204
|
+
"""``pipe.T[1:20]`` — the family and every element it has."""
|
|
205
|
+
return family_label(self.key, list(self.names.values()))
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def text(self) -> str:
|
|
209
|
+
"""Every element, as an Indices field holds them: ``1:20``."""
|
|
210
|
+
return slice_text(list(self.names))
|
|
211
|
+
|
|
212
|
+
def chosen(self, text: str) -> list[str]:
|
|
213
|
+
"""The names the Indices ``text`` chooses, by index ascending.
|
|
214
|
+
`Refused` — a person's own, shown and never logged — with the
|
|
215
|
+
sentence to show: `parse_slice`'s for a text that is no slice, and
|
|
216
|
+
one naming the family's size for an index it does not have."""
|
|
217
|
+
try:
|
|
218
|
+
indices = parse_slice(text)
|
|
219
|
+
except ValueError as e:
|
|
220
|
+
raise Refused(str(e)) from None
|
|
221
|
+
missing = [i for i in indices if i not in self.names]
|
|
222
|
+
if missing:
|
|
223
|
+
n = len(self.names)
|
|
224
|
+
what = (
|
|
225
|
+
f"{missing[0]} is not one of them"
|
|
226
|
+
if len(missing) == 1
|
|
227
|
+
else f"{slice_text(missing)} are not among them"
|
|
228
|
+
)
|
|
229
|
+
raise Refused(
|
|
230
|
+
f"{self.label} has {n} element{'' if n == 1 else 's'}; {what}."
|
|
231
|
+
)
|
|
232
|
+
return [self.names[i] for i in indices]
|
|
233
|
+
|
|
234
|
+
def indices_of(self, names: Collection[str]) -> str:
|
|
235
|
+
"""``names`` — elements of this family — as an Indices text."""
|
|
236
|
+
wanted = set(names)
|
|
237
|
+
return slice_text([i for i, name in self.names.items() if name in wanted])
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def family_in(names: Sequence[str], variable: str) -> Family | None:
|
|
241
|
+
"""The family ``variable`` is an element of among ``names`` (every name
|
|
242
|
+
of the result file, `Session.variable_names`), or ``None``: it names no
|
|
243
|
+
element of an array, or no other element of its array is there — one
|
|
244
|
+
variable is no family, and gets no Indices field. By name alone: that
|
|
245
|
+
a UnitTests element is no element of a family (D140) is its caller's
|
|
246
|
+
to say, which knows the test (`edit_tree._family`)."""
|
|
247
|
+
fam = family_of(variable)
|
|
248
|
+
if fam is None:
|
|
249
|
+
return None
|
|
250
|
+
members: dict[int, str] = {fam[1]: variable}
|
|
251
|
+
for name in names:
|
|
252
|
+
other = family_of(name)
|
|
253
|
+
if other is not None and other[0] == fam[0]:
|
|
254
|
+
members.setdefault(other[1], name)
|
|
255
|
+
if len(members) < 2:
|
|
256
|
+
return None
|
|
257
|
+
return Family(fam[0], dict(sorted(members.items())))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# -- an edit of a fold, as per-leaf ops and as one -----------------------------
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
NOT_A_FOLD = "these criteria are no longer one folded row"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def elements_text(names: Sequence[str]) -> str:
|
|
267
|
+
"""Elements of one array as a person reads them: ``pipe.T[1:5:16]``."""
|
|
268
|
+
fam = family_of(names[0]) if names else None
|
|
269
|
+
return family_label(fam[0], list(names)) if fam is not None else ", ".join(names)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def as_one(
|
|
273
|
+
model_id: str,
|
|
274
|
+
tree: dict,
|
|
275
|
+
ops: Sequence[PatchOp],
|
|
276
|
+
notes: Sequence[tuple[str, str]] = (),
|
|
277
|
+
) -> PatchOp:
|
|
278
|
+
"""``ops`` — per-leaf edits of ``tree``, the test's raw tree as it is
|
|
279
|
+
now — as ONE ``add /metrics`` of the tree they give: what a gesture on
|
|
280
|
+
a whole fold stages, as a move stages its one whole tree (D144). One
|
|
281
|
+
pending edit, not one per element; a second in a row replaces the
|
|
282
|
+
first (`Session.stage` folds an add onto an add of one member), so a
|
|
283
|
+
value typed into a folded field is one edit, not a keystroke log.
|
|
284
|
+
Applied by the spec's own patch applier, so the tree is exactly what
|
|
285
|
+
the ops would have left — every node they do not name as it was.
|
|
286
|
+
|
|
287
|
+
Marked as an edit of the tree (`PatchOp.edits_tree`): a flat entry's
|
|
288
|
+
implicit tree is written out before it, as before any per-leaf op, and
|
|
289
|
+
the preview shows what the gesture changed, not a whole tree added.
|
|
290
|
+
``notes`` say what the gesture did (`PatchOp.notes`)."""
|
|
291
|
+
data = {"tests": [{"model": model_id, "metrics": copy.deepcopy(tree)}]}
|
|
292
|
+
apply_patch_to_document(data, model_id, [op.raw() for op in ops])
|
|
293
|
+
return PatchOp(
|
|
294
|
+
model_id,
|
|
295
|
+
"add",
|
|
296
|
+
ROOT,
|
|
297
|
+
data["tests"][0]["metrics"],
|
|
298
|
+
edits_tree=True,
|
|
299
|
+
notes=tuple(notes),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _shown(value: object) -> str:
|
|
304
|
+
"""A leaf's value in a note: a number as a person types it, a word as
|
|
305
|
+
it is, anything else (a pair, a table) as JSON."""
|
|
306
|
+
if isinstance(value, str):
|
|
307
|
+
return value
|
|
308
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
309
|
+
return json.dumps(value)
|
|
310
|
+
return f"{value:g}"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def edit_notes(
|
|
314
|
+
old: Mapping, new: Mapping, variables: Sequence[str]
|
|
315
|
+
) -> list[tuple[str, str]]:
|
|
316
|
+
"""What a folded field's change did, one sentence per field it moved —
|
|
317
|
+
``tolerance → 0.05 on 20 criteria of pipe.T[1:20]`` — from the members'
|
|
318
|
+
leaf before (``old``) and after (``new``), their variables apart. A
|
|
319
|
+
mode switched says that and nothing more: every field went with it.
|
|
320
|
+
Each sentence's key is its field and array, so a value typed over
|
|
321
|
+
replaces its line (`PatchOp.notes`)."""
|
|
322
|
+
what = f"on {len(variables)} criteria of {elements_text(variables)}"
|
|
323
|
+
fam = family_of(variables[0])
|
|
324
|
+
array = fam[0] if fam is not None else variables[0]
|
|
325
|
+
|
|
326
|
+
def note(field: str, value: object, there: bool) -> tuple[str, str]:
|
|
327
|
+
said = f"{field} → {_shown(value)}" if there else f"{field} cleared"
|
|
328
|
+
return f"{field}:{array}", f"{said} {what}"
|
|
329
|
+
|
|
330
|
+
if old.get("metric") != new.get("metric"):
|
|
331
|
+
return [note("mode", str(new.get("metric")), True)]
|
|
332
|
+
flat_old, flat_new = _fields(old), _fields(new)
|
|
333
|
+
return [
|
|
334
|
+
note(key, flat_new.get(key), key in flat_new)
|
|
335
|
+
for key in sorted({*flat_old, *flat_new})
|
|
336
|
+
if not (
|
|
337
|
+
key in flat_old
|
|
338
|
+
and key in flat_new
|
|
339
|
+
and json_equal(flat_old[key], flat_new[key])
|
|
340
|
+
)
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _fields(leaf: Mapping) -> dict:
|
|
345
|
+
"""A raw leaf as the fields an editor shows: its parameters, the two
|
|
346
|
+
ends of its window and a baseline that is not the primary one."""
|
|
347
|
+
out = {
|
|
348
|
+
key: value
|
|
349
|
+
for key, value in leaf.items()
|
|
350
|
+
if key not in ("metric", "variable", "window", "against")
|
|
351
|
+
}
|
|
352
|
+
for end, value in (leaf.get("window") or {}).items():
|
|
353
|
+
out[f"window {end}"] = value
|
|
354
|
+
if leaf.get("against") not in (None, "primary"):
|
|
355
|
+
out["against"] = leaf["against"]
|
|
356
|
+
return out
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@dataclass(frozen=True)
|
|
360
|
+
class Reindexed:
|
|
361
|
+
"""A fold's members after an Indices edit: the per-leaf ``ops`` that
|
|
362
|
+
make it so (removes, then adds), the members' ``paths`` in the tree
|
|
363
|
+
they leave, in tree order, and the elements that came and went."""
|
|
364
|
+
|
|
365
|
+
ops: tuple[PatchOp, ...]
|
|
366
|
+
paths: tuple[str, ...]
|
|
367
|
+
added: tuple[str, ...] = ()
|
|
368
|
+
removed: tuple[str, ...] = ()
|
|
369
|
+
|
|
370
|
+
@property
|
|
371
|
+
def notes(self) -> list[tuple[str, str]]:
|
|
372
|
+
"""What the edit did, for the preview (`PatchOp.notes`): ``Indices:
|
|
373
|
+
removed pipe.T[20]``. Each sentence is its own key: two Indices
|
|
374
|
+
edits are two lines."""
|
|
375
|
+
said = [
|
|
376
|
+
f"Indices: {verb} {elements_text(names)}"
|
|
377
|
+
for verb, names in (("removed", self.removed), ("added", self.added))
|
|
378
|
+
if names
|
|
379
|
+
]
|
|
380
|
+
return [(text, text) for text in said]
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _own_leaf(child: Mapping) -> Mapping | None:
|
|
384
|
+
"""The criterion ``child`` IS — a leaf — or holds as a warning of its
|
|
385
|
+
own (a ``warn`` around one leaf); None for any other group."""
|
|
386
|
+
if "metric" in child:
|
|
387
|
+
return child
|
|
388
|
+
kids = child.get("children") or []
|
|
389
|
+
if child.get("combinator") == "warn" and len(kids) == 1 and "metric" in kids[0]:
|
|
390
|
+
return kids[0]
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _one_criterion(a: Mapping, b: Mapping) -> bool:
|
|
395
|
+
"""Whether two raw leaves are one criterion apart from their variable
|
|
396
|
+
(`criterion_of`'s rule, over the spec's own shape: no baseline named is
|
|
397
|
+
the primary one, no window is the whole run)."""
|
|
398
|
+
return json_equal(
|
|
399
|
+
{**_fields(a), "metric": a.get("metric")},
|
|
400
|
+
{**_fields(b), "metric": b.get("metric")},
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def reindex(
|
|
405
|
+
model_id: str, tree: dict, paths: Sequence[str], names: Sequence[str]
|
|
406
|
+
) -> Reindexed:
|
|
407
|
+
"""The fold whose members are at ``paths`` of ``tree`` (tree order, all
|
|
408
|
+
children of one node), made to hold exactly the elements ``names``: a
|
|
409
|
+
member whose variable is not among them is removed; each name no member
|
|
410
|
+
has is added with the fold's criterion — the first member's leaf, its
|
|
411
|
+
variable changed.
|
|
412
|
+
|
|
413
|
+
The group's leaves stay in INDEX order: a new element goes right after
|
|
414
|
+
the member with the next smaller index — before the first, when it is
|
|
415
|
+
smaller than all; where the group was, when no member is left — so an
|
|
416
|
+
Indices text put back puts the tree back, and is no edit. Members the
|
|
417
|
+
tree holds out of index order are not moved.
|
|
418
|
+
|
|
419
|
+
An element that already has the fold's very criterion as a leaf under
|
|
420
|
+
that node gets no second one: that leaf is taken as a member. One with
|
|
421
|
+
a criterion of the fold's METRIC that differs — or sits in a ``warn``
|
|
422
|
+
of its own — is not given another beside it: `Refused` naming it,
|
|
423
|
+
and nothing is planned. So is ``paths`` that do not name leaves under
|
|
424
|
+
one node (`NOT_A_FOLD`).
|
|
425
|
+
|
|
426
|
+
The removes come first, last-to-first, then the adds by index, so
|
|
427
|
+
every op's path is the one it has when it is applied."""
|
|
428
|
+
leaves = [node_or_none(tree, path) for path in paths]
|
|
429
|
+
parents = {parent_of(path) for path in paths}
|
|
430
|
+
if (
|
|
431
|
+
not paths
|
|
432
|
+
or len(parents) != 1
|
|
433
|
+
or None in parents
|
|
434
|
+
or any(leaf is None or "metric" not in leaf for leaf in leaves)
|
|
435
|
+
):
|
|
436
|
+
raise Refused(NOT_A_FOLD)
|
|
437
|
+
(parent,) = parents
|
|
438
|
+
siblings = (node_or_none(tree, str(parent)) or {}).get("children") or []
|
|
439
|
+
template = leaves[0]
|
|
440
|
+
assert template is not None # checked above
|
|
441
|
+
#: each member's place among its parent's children -> its variable
|
|
442
|
+
member = {
|
|
443
|
+
int(path.rsplit("/", 1)[1]): str(leaf["variable"])
|
|
444
|
+
for path, leaf in zip(paths, leaves)
|
|
445
|
+
if leaf
|
|
446
|
+
}
|
|
447
|
+
wanted = list(dict.fromkeys(names))
|
|
448
|
+
taken = []
|
|
449
|
+
for name in [n for n in wanted if n not in member.values()]:
|
|
450
|
+
for at, child in enumerate(siblings):
|
|
451
|
+
own = None if at in member else _own_leaf(child)
|
|
452
|
+
if (
|
|
453
|
+
own is None
|
|
454
|
+
or own.get("variable") != name
|
|
455
|
+
or own.get("metric") != template.get("metric")
|
|
456
|
+
):
|
|
457
|
+
continue
|
|
458
|
+
if own is child and _one_criterion(own, template):
|
|
459
|
+
member[at] = name # it has this very criterion already
|
|
460
|
+
else:
|
|
461
|
+
taken.append(name)
|
|
462
|
+
break
|
|
463
|
+
if taken:
|
|
464
|
+
metric = template.get("metric")
|
|
465
|
+
# A plain list: these are indices to take OUT of the text typed.
|
|
466
|
+
indices = ",".join(
|
|
467
|
+
str(fam[1]) for n in taken if (fam := family_of(n)) is not None
|
|
468
|
+
)
|
|
469
|
+
raise Refused(
|
|
470
|
+
f"{taken[0]} already has its own {metric} criterion under this group — "
|
|
471
|
+
f"edit or remove that one, or leave {indices} out"
|
|
472
|
+
if len(taken) == 1
|
|
473
|
+
else f"{', '.join(taken[:-1])} and {taken[-1]} already have their own "
|
|
474
|
+
f"{metric} criteria under this group — edit or remove those, or leave "
|
|
475
|
+
f"{indices} out"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
def index_of(name: str) -> float:
|
|
479
|
+
fam = family_of(name)
|
|
480
|
+
return fam[1] if fam is not None else float("inf")
|
|
481
|
+
|
|
482
|
+
#: The parent's children as the ops leave them: a child that stands by
|
|
483
|
+
#: its old place, a new leaf by its variable.
|
|
484
|
+
order: list[int | str] = list(range(len(siblings)))
|
|
485
|
+
members: dict[int | str, str] = {at: name for at, name in member.items()}
|
|
486
|
+
where = min(member) # the group's first place, for a fold left empty
|
|
487
|
+
removed = [at for at, name in member.items() if name not in wanted]
|
|
488
|
+
ops = []
|
|
489
|
+
for at in sorted(removed, reverse=True):
|
|
490
|
+
ops.append(PatchOp(model_id, "remove", f"{parent}/children/{at}"))
|
|
491
|
+
order.remove(at)
|
|
492
|
+
del members[at]
|
|
493
|
+
added = sorted((n for n in wanted if n not in member.values()), key=index_of)
|
|
494
|
+
for name in added:
|
|
495
|
+
placed = [
|
|
496
|
+
(index_of(members[token]), k)
|
|
497
|
+
for k, token in enumerate(order)
|
|
498
|
+
if token in members
|
|
499
|
+
]
|
|
500
|
+
below = [(i, k) for i, k in placed if i < index_of(name)]
|
|
501
|
+
if below:
|
|
502
|
+
at = max(below)[1] + 1
|
|
503
|
+
elif placed:
|
|
504
|
+
at = min(placed)[1]
|
|
505
|
+
else:
|
|
506
|
+
at = sum(1 for token in order if isinstance(token, int) and token < where)
|
|
507
|
+
ops.append(
|
|
508
|
+
PatchOp(
|
|
509
|
+
model_id,
|
|
510
|
+
"add",
|
|
511
|
+
f"{parent}/children/{at}",
|
|
512
|
+
{**copy.deepcopy(template), "variable": name},
|
|
513
|
+
)
|
|
514
|
+
)
|
|
515
|
+
order.insert(at, name)
|
|
516
|
+
members[name] = name
|
|
517
|
+
return Reindexed(
|
|
518
|
+
tuple(ops),
|
|
519
|
+
tuple(
|
|
520
|
+
f"{parent}/children/{k}"
|
|
521
|
+
for k, token in enumerate(order)
|
|
522
|
+
if token in members
|
|
523
|
+
),
|
|
524
|
+
tuple(added),
|
|
525
|
+
tuple(member[at] for at in sorted(removed)),
|
|
526
|
+
)
|