dynamic-systems-testing-framework 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+ )