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,501 @@
1
+ """The Statistics card (U3): every statistic the run and its baseline
2
+ recorded, side by side, above the criteria tree. Closed it is one line
3
+ naming the counts that changed, then briefly the times; open, a row per count or time under its
4
+ section, each ending in whatever attaches at STATISTIC (a `+` that adds a
5
+ requirement on it) — or, for a row a criterion reads, that criterion's
6
+ pill and its requirement in a word or two. The card is a VIEW of the one
7
+ tree (Q1 b): which rows are scored is the tree as authored, staged edits
8
+ included (`Session.statistic_leaves`); the tree view only says each
9
+ criterion's verdict. A statistics criterion is edited under its row, by
10
+ whatever attaches at STATISTIC_CRITERION. It knows no capability by name.
11
+
12
+ The rows are `Session.statistics` (`reporting.stats_rows`), the row model
13
+ the report renders too: a row is "changed" when the criterion its `+`
14
+ would add would fail. A refresh rebuilds them only when their SHAPE moved
15
+ — the model, the rows' paths, which criteria read them, the root a `+`
16
+ lands in (the `TreeCard` rule); a value or a verdict repaints in place."""
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import math
22
+ from collections.abc import Sequence
23
+ from functools import partial
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ from nicegui import ui
27
+
28
+ from ...backends.common.statistics import SUMMARIES
29
+ from ...comparison.modes import ScalarConfig
30
+ from ...comparison.sides import parse_sides, sides_text, signed_sides
31
+ from ...reporting.stats_rows import SECTION_TITLES, StatRow, card_text
32
+ from ...reporting.tree_rows import leaves_of
33
+ from ..capabilities import Placement
34
+ from ..session import Selection
35
+ from ..tree_pointer import node_or_none
36
+ from .actions import InlineHost, RowTarget
37
+ from .tree_card import leaf_status
38
+ from .widgets import Pill
39
+
40
+ if TYPE_CHECKING:
41
+ from ...comparison.tree_spec import LeafSpec
42
+ from .test_page import TestPage
43
+
44
+ NAMED = 3 #: changed counts the closed line names (S6); the rest are counted
45
+ TIMES = 2 #: changed times it names after them (the owner, 2026-09-30)
46
+ #: The node a STATISTIC panel's add acts on: the root, whose END it lands at.
47
+ ROOT = "/metrics"
48
+
49
+
50
+ def _entry(row: StatRow) -> tuple[tuple[str, str], int]:
51
+ """What the closed line names ``row`` under, and its rank there: a
52
+ list's `_count` / `_total` / `_max` are ONE entry, the list's (B6),
53
+ ranked in that order; any other row is its own."""
54
+ base, _, field = row.key.rpartition("_")
55
+ if base and field in SUMMARIES:
56
+ return (row.section, base), SUMMARIES.index(field)
57
+ return (row.section, row.key), 0
58
+
59
+
60
+ def _moved(row: StatRow) -> float:
61
+ """How far a changed time moved, relative to its baseline: its rank
62
+ on the closed line. Off a zero baseline, farther than any."""
63
+ run, base = float(row.run), float(row.baseline)
64
+ return abs((run - base) / base) if base else math.inf
65
+
66
+
67
+ def _more(named: Sequence[StatRow], cap: int) -> str:
68
+ text = ", ".join(f"{r.label} {r.delta}" for r in named[:cap])
69
+ return f"{text} +{len(named) - cap} more" if len(named) > cap else text
70
+
71
+
72
+ def line_text(rows: Sequence[StatRow], has_run: bool) -> str:
73
+ """The closed line after "run vs baseline ·": the changed COUNTS — in
74
+ section order, at most `NAMED` named, then `+N more` — or `no count
75
+ changed`; then, after a `·`, the changed times, the farthest moved
76
+ first, at most `TIMES`. `all match` where no count and no time
77
+ changed; or why it names none — "no run result" where ``has_run`` is
78
+ false: no run, or one that failed. The owner's ruling
79
+ (2026-09-30): over TRANSFORM CPUtime ran ~2.2x its baseline on 324 of
80
+ 331 tests, so a line naming times among the counts named CPUtime on
81
+ every test; the open table still tints a changed time."""
82
+ shown = [r for r in rows if r.kind != "text"]
83
+ if not shown:
84
+ return "nothing recorded"
85
+ if not has_run:
86
+ return "no run result"
87
+ if all(r.baseline is None for r in shown):
88
+ return "no baseline statistics"
89
+ entries: dict[tuple[str, str], tuple[int, StatRow]] = {}
90
+ for row in shown:
91
+ if not row.changed or row.kind != "count":
92
+ continue
93
+ key, rank = _entry(row)
94
+ if key not in entries or rank < entries[key][0]:
95
+ entries[key] = (rank, row)
96
+ sections = list(dict.fromkeys(r.section for r in shown))
97
+ counts = sorted(
98
+ (row for _, row in entries.values()),
99
+ key=lambda r: sections.index(r.section),
100
+ )
101
+ times = sorted(
102
+ (r for r in shown if r.changed and r.kind == "time"), key=_moved, reverse=True
103
+ )
104
+ if not counts and not times:
105
+ return "all match"
106
+ text = (
107
+ f"{len(counts)} changed: {_more(counts, NAMED)}"
108
+ if counts
109
+ else "no count changed"
110
+ )
111
+ return f"{text} · {_more(times, TIMES)}" if times else text
112
+
113
+
114
+ def summary(leaf: dict) -> str:
115
+ """A scored row's requirement in a word or two (B11): `exact`, `±0.5`,
116
+ `±10%`, `in [a, b]`, `≥ a`, `≤ b`; a tolerance whose two sides differ
117
+ reads `−0.5 / +1`, `−5% / +10%`. A value the leaf leaves out is the
118
+ scalar mode's default."""
119
+ p = leaf.get("params") or {}
120
+ lo, hi = p.get("min_value"), p.get("max_value")
121
+ if lo is not None and hi is not None:
122
+ return f"in [{lo:g}, {hi:g}]"
123
+ if lo is not None:
124
+ return f"≥ {lo:g}"
125
+ if hi is not None:
126
+ return f"≤ {hi:g}"
127
+ tol = parse_sides(p.get("tolerance", ScalarConfig.tolerance), "tolerance")
128
+ rel = p.get("tolerance_mode", ScalarConfig.tolerance_mode) == "rel"
129
+ if not tol.equal:
130
+ return (
131
+ signed_sides(tol, "g", scale=100, unit="%") if rel else sides_text(tol, "g")
132
+ )
133
+ if rel:
134
+ return f"±{tol.upper * 100:g}%"
135
+ return "exact" if tol.upper == 0 else f"±{tol.upper:g}"
136
+
137
+
138
+ def cell_text(row: StatRow, value: Any) -> str:
139
+ """A value as the card prints it, rounded (`card_text`); `—` for a
140
+ side that recorded none."""
141
+ return card_text(row.kind, value) or "—"
142
+
143
+
144
+ def row_of(quantity: str, paths: Sequence[str] | set[str]) -> str:
145
+ """The row a leaf's quantity is shown on: its own, or — U2's add form
146
+ writes `CPUtime.final` — the top-level statistic's (S2), as
147
+ `statistics_rows` holds it."""
148
+ if quantity in paths:
149
+ return quantity
150
+ name, _, field = quantity.rpartition(".")
151
+ return name if field == "final" and name in paths else quantity
152
+
153
+
154
+ def scored(
155
+ leaves: Sequence[tuple[LeafSpec, str]],
156
+ view: dict | None,
157
+ paths: Sequence[str],
158
+ ) -> dict[str, list[dict]]:
159
+ """Row path -> the criteria reading it, in tree order: the AUTHORED
160
+ tree's statistics leaves (``leaves``, `Session.statistic_leaves`), each
161
+ as ``{"path", "params", "status"}`` — its status the view's verdict,
162
+ "pending" where the view has none. Read from the view, a test with
163
+ nothing compared (no baseline: no view) showed a staged criterion's row
164
+ as unscored, and its `+` staged the same leaf again."""
165
+ held = set(paths)
166
+ verdicts = {str(n["path"]): leaf_status(n) for n in leaves_of(view)}
167
+ out: dict[str, list[dict]] = {}
168
+ for leaf, path in leaves:
169
+ out.setdefault(row_of(str(leaf.params["quantity"]), held), []).append(
170
+ {
171
+ "path": path,
172
+ "params": leaf.params,
173
+ "status": verdicts.get(path, "pending"),
174
+ }
175
+ )
176
+ return out
177
+
178
+
179
+ class _Row:
180
+ """One statistic's row, and what a repaint writes into it."""
181
+
182
+ def __init__(
183
+ self,
184
+ row: ui.row,
185
+ run: ui.label,
186
+ base: ui.label,
187
+ delta: ui.label,
188
+ pill: Pill | None = None,
189
+ rule: ui.label | None = None,
190
+ more: ui.label | None = None,
191
+ ) -> None:
192
+ self.row, self.run, self.base, self.delta = row, run, base, delta
193
+ self.pill, self.rule, self.more = pill, rule, more
194
+
195
+ def paint(self, stat: StatRow, leaves: list[dict]) -> None:
196
+ self.run.set_text(cell_text(stat, stat.run))
197
+ self.base.set_text(cell_text(stat, stat.baseline))
198
+ self.delta.set_text(stat.delta)
199
+ if stat.changed:
200
+ self.row.classes(add="dstf-stat-changed")
201
+ else:
202
+ self.row.classes(remove="dstf-stat-changed")
203
+ if self.pill is not None and leaves:
204
+ self.pill.set(leaves[0]["status"])
205
+ if self.rule is not None and leaves:
206
+ self.rule.set_text(summary(leaves[0]))
207
+ if self.more is not None:
208
+ self.more.set_text(f"+{len(leaves) - 1}" if len(leaves) > 1 else "")
209
+
210
+
211
+ class StatsCard:
212
+ def __init__(self, page: TestPage) -> None:
213
+ self.page = page
214
+ self.is_open = False
215
+ self.hosts: list[InlineHost] = []
216
+ self.painters: dict[str, _Row] = {}
217
+ #: Each statistic's row by its path, and the slot under it.
218
+ self.rows_by_path: dict[str, ui.row] = {}
219
+ self.slots: dict[str, ui.column] = {}
220
+ self._shape: tuple = ()
221
+ self._toggle: ui.button | None = None
222
+ self._summary: ui.label | None = None
223
+ #: The one open editor: its leaf, its host and box, and the leaf as
224
+ #: its fields show it (an edit staged ELSEWHERE rebuilds them).
225
+ self.open_path: str | None = None
226
+ self.editor: InlineHost | None = None
227
+ self.editor_box: ui.column | None = None
228
+ self._edited: str | None = None
229
+ with ui.card().classes("dstf-card dstf-stats w-full gap-1").mark("stats"):
230
+ self.line = ui.row().classes("dstf-stats-line items-center gap-3 w-full")
231
+ self.line.mark("stats:line")
232
+ self.rows = ui.column().classes("w-full gap-0").mark("stats:rows")
233
+ # Not refreshed here, as the tree card is not: the page's `sync`
234
+ # fills it, or `forget` leaves it saying nothing.
235
+ self.rows.set_visibility(False)
236
+
237
+ def toggle(self) -> None:
238
+ self.is_open = not self.is_open
239
+ if not self.is_open and self.open_path is not None:
240
+ # An editor nobody can see is no editor.
241
+ self.page.close_editor()
242
+ self.refresh(self.page._view())
243
+
244
+ def forget(self) -> None:
245
+ """The page's test is gone: the card says nothing (`TreeCard.forget`)."""
246
+ self.close_editor()
247
+ for host in self.hosts:
248
+ host.clear()
249
+ self.hosts, self.painters = [], {}
250
+ self.rows_by_path, self.slots = {}, {}
251
+ self.is_open = False
252
+ self._shape = ()
253
+ self.rows.clear()
254
+ self.rows.set_visibility(False)
255
+ self.line.clear()
256
+ self._toggle, self._summary = None, None
257
+
258
+ def refresh(self, view: dict | None) -> None:
259
+ """Follow the Session: the line every time, the rows in place unless
260
+ their shape moved. ``view`` is the page's ONE `tree_view` read."""
261
+ session, model = self.page.shell.session, self.page.model_id
262
+ stats = session.statistics(model)
263
+ self._line(stats)
264
+ self.rows.set_visibility(self.is_open)
265
+ if not self.is_open:
266
+ return
267
+ shown = [r for r in stats if r.kind != "text"]
268
+ paths = [r.path for r in shown]
269
+ reads = scored(session.statistic_leaves(model), view, paths)
270
+ tree = session.tree(model)
271
+ shape = (
272
+ model,
273
+ tuple(paths),
274
+ tuple(
275
+ (k, tuple(str(leaf["path"]) for leaf in v)) for k, v in reads.items()
276
+ ),
277
+ # What a `+` lands in, and whether it can: its reason and its
278
+ # sentence read the root.
279
+ None if tree is None else str(tree.get("combinator", "leaf")),
280
+ )
281
+ if shape != self._shape or not self.painters:
282
+ self._shape = shape
283
+ if self.open_path is not None:
284
+ # The rows its editor hangs under go, as a plot card's
285
+ # rebuild takes its editor: the page's one editor with them.
286
+ self.page.drop_editor()
287
+ self._build(shown, reads)
288
+ return
289
+ for host in self.hosts:
290
+ host.follow()
291
+ for stat in shown:
292
+ self.painters[stat.path].paint(stat, reads.get(stat.path, []))
293
+ if self.editor is not None:
294
+ edited = self._edited_now()
295
+ if edited != self._edited:
296
+ # The leaf moved by an edit staged ELSEWHERE (a Discard):
297
+ # fields showing the undone value would stage it back.
298
+ self.editor.build()
299
+ self._edited = edited
300
+ else:
301
+ self.editor.follow()
302
+
303
+ # -- the closed line -------------------------------------------------------
304
+
305
+ def _line(self, stats: list[StatRow]) -> None:
306
+ """Built once, repainted in place: a line cleared and rebuilt on
307
+ every sync shrinks the page for a frame, and whatever sits below it
308
+ moves."""
309
+ if self._toggle is None:
310
+ with self.line:
311
+ self._toggle = (
312
+ ui.button("Statistics", on_click=self.toggle)
313
+ .props("flat dense no-caps")
314
+ .mark("stats:toggle")
315
+ )
316
+ ui.label("run vs baseline ·").classes("text-secondary")
317
+ self._summary = (
318
+ ui.label().classes("text-secondary").mark("stats:summary")
319
+ )
320
+ assert self._summary is not None
321
+ self._toggle.props(f"icon={'expand_more' if self.is_open else 'chevron_right'}")
322
+ # A failed run is no result to compare: it recorded no statistics,
323
+ # and its line read "all match" with nothing on the run's side.
324
+ self._summary.set_text(line_text(stats, self.page.run_state() == "ok"))
325
+
326
+ # -- the rows ----------------------------------------------------------------
327
+
328
+ def _build(self, shown: list[StatRow], reads: dict[str, list[dict]]) -> None:
329
+ self.close_editor()
330
+ for host in self.hosts:
331
+ host.clear()
332
+ self.hosts, self.painters = [], {}
333
+ self.rows_by_path, self.slots = {}, {}
334
+ self.rows.clear()
335
+ section = None
336
+ with self.rows:
337
+ if shown:
338
+ # The board's columns: the statistic, the run, its baseline.
339
+ with (
340
+ ui.row()
341
+ .classes("dstf-stat-row dstf-stat-head items-center no-wrap gap-3")
342
+ .mark("stats:head")
343
+ ):
344
+ ui.label("Statistic").classes("dstf-stat-label")
345
+ ui.label("Run").classes("dstf-stat-num")
346
+ ui.label("Baseline").classes("dstf-stat-num")
347
+ ui.element("span").classes("dstf-stat-delta")
348
+ ui.element("span").classes("dstf-stat-end")
349
+ for stat in shown:
350
+ if stat.section != section:
351
+ section = stat.section
352
+ title = SECTION_TITLES.get(
353
+ section, section.replace("_", " ").capitalize()
354
+ )
355
+ ui.label(title).classes("dstf-group").mark("stats:section")
356
+ self._row(stat, reads.get(stat.path, []))
357
+
358
+ def _row(self, stat: StatRow, leaves: list[dict]) -> None:
359
+ path, model = stat.path, self.page.model_id
360
+ row = ui.row().classes("dstf-stat-row items-center no-wrap gap-3")
361
+ self.rows_by_path[path] = row
362
+ host: InlineHost | None = None
363
+ target: RowTarget | None = None
364
+ pill: Pill | None = None
365
+ rule: ui.label | None = None
366
+ more: ui.label | None = None
367
+ with row.mark(f"stats:row:{path}"):
368
+ with ui.label(stat.label).classes("dstf-stat-label text-body2"):
369
+ ui.tooltip(path)
370
+ run = ui.label().classes("dstf-stat-num dstf-mono")
371
+ base = ui.label().classes("dstf-stat-num dstf-mono")
372
+ delta = ui.label().classes("dstf-stat-delta text-secondary text-xs")
373
+ with ui.element("span").classes("dstf-stat-end"):
374
+ if leaves:
375
+ pill = Pill(leaves[0]["status"], f"stats:pill:{path}")
376
+ rule = ui.label().classes("dstf-mono text-xs")
377
+ rule.mark(f"stats:rule:{path}")
378
+ more = ui.label().classes("text-secondary text-xs")
379
+ more.mark(f"stats:more:{path}")
380
+ else:
381
+ target = RowTarget(self.page, f"stat:{path}", row, ROOT, None)
382
+ host = InlineHost(
383
+ self.page.shell,
384
+ Placement.STATISTIC,
385
+ partial(Selection, tests=frozenset({model}), quantity=path),
386
+ figure=None,
387
+ marker=f"statistic:{path}",
388
+ row=target,
389
+ apply=self.page.apply,
390
+ )
391
+ host.column.classes(remove="w-full", add="dstf-node-host")
392
+ if leaves:
393
+ # A scored row's own click opens its (first) criterion's editor
394
+ # under it, or closes it, as a criteria row's does.
395
+ row.classes(add="cursor-pointer")
396
+ row.on(
397
+ "click",
398
+ partial(self.page.toggle_stats_editor, str(leaves[0]["path"])),
399
+ )
400
+ # The row's slot: a refused add's error, and the editor.
401
+ slot = ui.column().classes("w-full gap-0")
402
+ self.slots[path] = slot
403
+ if host is not None and target is not None:
404
+ target.slot = slot
405
+ host.build()
406
+ self.hosts.append(host)
407
+ self.painters[path] = _Row(row, run, base, delta, pill, rule, more)
408
+ self.painters[path].paint(stat, leaves)
409
+
410
+ # -- the one open editor -------------------------------------------------
411
+
412
+ def row_for(self, leaf_path: str) -> str | None:
413
+ """The row a statistics criterion is shown on, or None — a leaf
414
+ that reads no statistic, or none at that path. The authored tree's,
415
+ as `scored` reads it."""
416
+ session, model = self.page.shell.session, self.page.model_id
417
+ leaf = next(
418
+ (
419
+ leaf
420
+ for leaf, path in session.statistic_leaves(model)
421
+ if path == leaf_path
422
+ ),
423
+ None,
424
+ )
425
+ if leaf is None:
426
+ return None
427
+ paths = [r.path for r in session.statistics(model)]
428
+ return row_of(str(leaf.params["quantity"]), paths)
429
+
430
+ def open_editor(self, leaf_path: str, view: dict | None) -> bool:
431
+ """The criterion's editor under its row (the `PlotCard.open_editor`
432
+ pattern, with no plot), the card opened if it was closed. The page
433
+ keeps the one-editor rule: it closes what else is open first."""
434
+ key = self.row_for(leaf_path)
435
+ if key is None:
436
+ return False
437
+ if not self.is_open:
438
+ self.is_open = True
439
+ self.refresh(view)
440
+ # Every row a statistics leaf is shown on is built — `row_for` and
441
+ # `statistics_rows` agree on it (S4: a leaf's quantity always has
442
+ # a row) — so its slot exists.
443
+ slot = self.slots[key]
444
+ self.close_editor()
445
+ model = self.page.model_id
446
+ self.open_path = leaf_path
447
+ self.rows_by_path[key].classes(add="dstf-crit-open")
448
+ with slot:
449
+ self.editor_box = ui.column().classes("dstf-editor gap-2")
450
+ with self.editor_box:
451
+ with ui.row().classes("items-center no-wrap gap-3 w-full"):
452
+ ui.label(
453
+ "Edits are staged as you make them; Save writes them."
454
+ ).classes("dstf-why")
455
+ ui.space()
456
+ ui.button("Done", on_click=self.page.close_editor).props(
457
+ "flat dense no-caps"
458
+ ).mark(f"stats-editor-done:{leaf_path}")
459
+ self.editor = InlineHost(
460
+ self.page.shell,
461
+ Placement.STATISTIC_CRITERION,
462
+ partial(Selection, tests=frozenset({model}), leaf_path=leaf_path),
463
+ figure=None,
464
+ marker=f"stats-editor:{leaf_path}",
465
+ on_applied=self._applied,
466
+ apply=self.page.apply,
467
+ )
468
+ self.editor.build()
469
+ self._edited = self._edited_now()
470
+ return True
471
+
472
+ def _applied(self, name: str) -> None:
473
+ """A panel's own submit rebuilds every OTHER panel, leaving the one
474
+ being typed in; the leaf recorded as it now stands, so its own edit
475
+ is not read as one staged elsewhere."""
476
+ if self.editor is None:
477
+ return
478
+ self.editor.build(skip=name)
479
+ self._edited = self._edited_now()
480
+
481
+ def _edited_now(self) -> str | None:
482
+ """The open editor's leaf as AUTHORED — staged edits included — not
483
+ the view's: a test with nothing compared has no view, and its
484
+ editor never redrew for an edit staged elsewhere."""
485
+ if self.open_path is None:
486
+ return None
487
+ session = self.page.shell.session
488
+ leaf = node_or_none(session.tree(self.page.model_id), self.open_path)
489
+ return None if leaf is None else json.dumps(leaf, sort_keys=True, default=str)
490
+
491
+ def close_editor(self) -> None:
492
+ """The editor goes — its column only: the row's slot also holds
493
+ nothing else a close may take."""
494
+ if self.editor is not None:
495
+ self.editor.clear()
496
+ if self.editor_box is not None and not self.editor_box.is_deleted:
497
+ self.editor_box.delete()
498
+ for row in self.rows_by_path.values():
499
+ row.classes(remove="dstf-crit-open")
500
+ self.editor, self.editor_box = None, None
501
+ self.open_path, self._edited = None, None