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,188 @@
1
+ """`Action`: a button whose label is its caption and whose disabled state
2
+ carries the sentence that says why and what enables it, beside it
3
+ (DESIGN.md "Disabled states explain themselves": never a bare grey
4
+ button). The header's Save / Discard / Export and every capability's
5
+ button at a placement are Actions; `IconAction` is one drawn as an
6
+ icon, its caption or its reason the tooltip."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+
12
+ from nicegui import ui
13
+ from nicegui.elements.mixins.text_element import TextElement
14
+
15
+
16
+ class Action:
17
+ def __init__(
18
+ self,
19
+ on_click: Callable[[], None],
20
+ *,
21
+ props: str,
22
+ marker: str,
23
+ why: bool = True,
24
+ filled: bool = False,
25
+ ) -> None:
26
+ #: `filled`: `unelevated` while enabled, `flat` while disabled —
27
+ #: Quasar's disabled only dims a filled button, and the header's
28
+ #: Save read as the page's active primary action at 0 pending
29
+ #: edits. Its colour stays, so disabled it reads as Discard does.
30
+ self.filled = filled
31
+ with ui.row().classes("items-center no-wrap gap-2") as self.container:
32
+ self.button = ui.button(on_click=on_click).props(props).mark(marker)
33
+ self.why: ui.label | None = (
34
+ ui.label().classes("dstf-why").mark(f"{marker}:why") if why else None
35
+ )
36
+
37
+ def set(self, caption: str, reason: str | None) -> None:
38
+ """``reason`` None enables the button; a sentence disables it and is
39
+ shown beside it (when this Action carries a why-line)."""
40
+ self.button.set_text(caption)
41
+ if reason is None:
42
+ self.button.enable()
43
+ else:
44
+ self.button.disable()
45
+ if self.filled:
46
+ if reason is None:
47
+ self.button.props(remove="flat", add="unelevated")
48
+ else:
49
+ self.button.props(remove="unelevated", add="flat")
50
+ if self.why is not None:
51
+ self.why.set_text(reason or "")
52
+ self.why.set_visibility(reason is not None)
53
+
54
+
55
+ class IconAction:
56
+ """An Action drawn as an icon button: the caption — or, disabled, the
57
+ sentence that says why — is its tooltip and its accessible name (an
58
+ icon's ligature text is not a name). A disabled Quasar button takes no
59
+ pointer events, so the tooltip sits on a span around it (as Remove
60
+ plot's does)."""
61
+
62
+ def __init__(self, on_click: Callable[[], None], *, icon: str, marker: str) -> None:
63
+ with ui.element("span").classes("dstf-icon-action") as self.container:
64
+ self.button = (
65
+ ui.button(icon=icon, on_click=on_click)
66
+ .props("outline dense")
67
+ .mark(marker)
68
+ )
69
+ self.tip = ui.tooltip("").mark(f"{marker}:tip")
70
+
71
+ def set(self, caption: str, reason: str | None) -> None:
72
+ text = caption if reason is None else reason
73
+ self.tip.set_text(text)
74
+ self.button.props["aria-label"] = text
75
+ self.button.update()
76
+ if reason is None:
77
+ self.button.enable()
78
+ else:
79
+ self.button.disable()
80
+
81
+
82
+ class MenuAction:
83
+ """An Action drawn as an item of a bar's ⋮ menu: its caption the item's
84
+ text (in a label: a menu item has no text of its own to set).
85
+ Disabled, it reads ``label`` — the action's plain name — over the
86
+ sentence that says why: its caption counts what it would act on, and
87
+ with nothing to act on the menu offered "Accept 0 results as
88
+ baselines" and "Queue 0 (1 already queued)", their reasons a hover
89
+ away."""
90
+
91
+ def __init__(
92
+ self, on_click: Callable[[], None], *, marker: str, label: str
93
+ ) -> None:
94
+ self.label = label
95
+ self.button = (
96
+ ui.menu_item(on_click=on_click)
97
+ .props('role="menuitem"')
98
+ .classes("dstf-menu-action")
99
+ .mark(marker)
100
+ )
101
+ with self.button, ui.item_section():
102
+ self.text = ui.label().classes("dstf-menu-name")
103
+ self.why = ui.label().classes("dstf-why").mark(f"{marker}:why")
104
+ self.why.set_visibility(False)
105
+
106
+ def set(self, caption: str, reason: str | None) -> None:
107
+ self.text.set_text(caption if reason is None else self.label)
108
+ self.why.set_text(reason or "")
109
+ self.why.set_visibility(reason is not None)
110
+ if reason is None:
111
+ self.button.enable()
112
+ else:
113
+ self.button.disable()
114
+
115
+
116
+ class Pill:
117
+ """The tinted status pill of a criterion or a tree node (DESIGN.md
118
+ "Status badges": tinted inside a page, filled only in the header). It
119
+ repaints in place — a verdict that flips must not cost the row it sits
120
+ in."""
121
+
122
+ def __init__(self, status: str, marker: str) -> None:
123
+ self.label = ui.label().classes("dstf-pill").mark(marker)
124
+ self.status = ""
125
+ self.set(status)
126
+
127
+ def set(self, status: str, text: str | None = None) -> None:
128
+ if self.status:
129
+ self.label.classes(remove=f"dstf-pill-{self.status}")
130
+ self.status = status
131
+ self.label.classes(add=f"dstf-pill-{status}")
132
+ self.label.set_text(text if text is not None else status)
133
+
134
+
135
+ #: How much of a label with no index the tail keeps (Q20).
136
+ TAIL_CHARS = 12
137
+
138
+
139
+ def middle_split(text: str) -> tuple[str, str]:
140
+ """``(head, tail)``: the tail is the text from the last ``[`` — the
141
+ index says which element — or its last `TAIL_CHARS` characters when it
142
+ has none, or when that ``[`` is further back than that: a tail from
143
+ ``volume[1]`` on took the width and hid the head (D140, Q20)."""
144
+ at = text.rfind("[")
145
+ if at == -1 or len(text) - at > TAIL_CHARS:
146
+ at = max(0, len(text) - TAIL_CHARS)
147
+ return text[:at], text[at:]
148
+
149
+
150
+ class _Part(TextElement):
151
+ """A span of text: not a `ui.label`, whose `div` would break the line."""
152
+
153
+ def __init__(self, cls: str) -> None:
154
+ super().__init__(tag="span", text="")
155
+ self.classes(cls)
156
+
157
+
158
+ class MiddleCut(ui.element):
159
+ """A variable label cut in the MIDDLE (D140, Q20): the head — what the
160
+ variable is — ellipsized, the tail — which element — whole. Both are
161
+ inline-blocks in an inline-block (never a flex pair: HIGH 6 of the
162
+ plan's review), the head's width capped at the element's less the
163
+ tail's ``--tail`` (its length in ``ch``), so the element's innerText,
164
+ a copy of it and its accessible name are the whole label; its title is
165
+ too. ``text`` / ``set_text`` as a label's, so a reader of ``.text`` is
166
+ unchanged. Model ids keep their start cut (the Tests drawer)."""
167
+
168
+ def __init__(self, text: str = "") -> None:
169
+ super().__init__("span")
170
+ self.classes("dstf-mid")
171
+ with self:
172
+ self.head = _Part("dstf-mid-head")
173
+ self.tail = _Part("dstf-mid-tail")
174
+ self.text = ""
175
+ self.set_text(text)
176
+
177
+ def set_text(self, text: str) -> None:
178
+ # A repaint of the text it has sends nothing, as a label's does.
179
+ if text == self.text and "title" in self.props:
180
+ return
181
+ self.text = text
182
+ head, tail = middle_split(text)
183
+ self.head.set_text(head)
184
+ self.tail.set_text(tail)
185
+ self.style(f"--tail: {len(tail)}ch")
186
+ # On the props dict: a label can hold a `"`.
187
+ self.props["title"] = text
188
+ self.update()
@@ -0,0 +1,287 @@
1
+ """A test's situation, as the Test page's status strip says it: what state
2
+ the test is in, the tone the strip is drawn in, and the one sentence that
3
+ says it. Pure — no Session, no UI: the page reads the inputs, this decides.
4
+
5
+ The situations are checked in :class:`Situation`'s order; the first that
6
+ applies wins (a test the queue holds before pending edits, edits before
7
+ anything the last scoring said).
8
+
9
+ There is no state for edits to the simulation settings (the owner's ruling
10
+ Q2 A drew one): ``/simulation`` is not stageable — `patch_apply`'s default
11
+ whitelist is ``/comparison``, ``/metrics`` and ``/plots``, and widening it
12
+ changes ``spec-update``, a seam another repository relies on. It waits for
13
+ a simulation-settings editor."""
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Iterable
18
+ from dataclasses import dataclass
19
+ from enum import Enum
20
+ from typing import TYPE_CHECKING
21
+
22
+ from ..backends.pool import ItemState
23
+ from ..comparison.tree_spec import tree_variables
24
+ from ..reporting.status import FAIL, NO_REF, NOT_RUN, PASS, SIM_FAIL
25
+ from .capabilities import plural
26
+ from .live_rows import CANCELLED, NOT_AVAILABLE
27
+ from .queue_rows import one_line
28
+
29
+ if TYPE_CHECKING:
30
+ from ..backends.types import TestResult
31
+ from ..discovery.test_registry import TestModel
32
+ from .live_rows import Partial
33
+ from .queue_rows import QueueRow
34
+
35
+
36
+ class Situation(Enum):
37
+ #: the simulation queue holds the test, waiting (D150)
38
+ QUEUED = "queued"
39
+ RUNNING = "running"
40
+ EDITS = "edits"
41
+ #: pending edits whose criteria name a variable the last run did not
42
+ #: record: Queue is the one next action, Save is quiet (the owner's O1)
43
+ EDITS_NEW = "edits_new"
44
+ SCORING_FAILED = "scoring_failed"
45
+ WAITING = "waiting"
46
+ #: its last run in the queue was stopped: it has no result, and the
47
+ #: one before was replaced when that run started
48
+ CANCELLED = "cancelled"
49
+ #: the queue could not start it — no worker could start: the
50
+ #: backend's message; its last result stands
51
+ START_FAILED = "start_failed"
52
+ NO_RESULT = "no_result"
53
+ SIM_FAIL = "sim_fail"
54
+ #: saved criteria name a variable the last run did not record: a
55
+ #: baseline accepted now would not hold it (the owner's O3)
56
+ NEW_VARIABLES = "new_variables"
57
+ NO_REF = "no_ref"
58
+ #: the comparison refused the test (`reporting.status.refusal`): it
59
+ #: fails with a reason and nothing scored, and accepting does not
60
+ #: clear it — no capability names this as its next action
61
+ REFUSED = "refused"
62
+ FAIL = "fail"
63
+ SIMULATE_ONLY = "simulate_only"
64
+ PASS = "pass"
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Strip:
69
+ situation: Situation
70
+ tone: str # "grey" | "red" | "amber"
71
+ sentence: str
72
+ #: A line under the strip, said once: why a running test's page shows
73
+ #: no growing trace (D151). Empty for every other strip.
74
+ note: str = ""
75
+ #: What the sentence is one line of — a failed run's message in full,
76
+ #: shown on hover. Empty where the sentence is the whole of it.
77
+ tip: str = ""
78
+
79
+
80
+ _ACCEPT_IT = "If the new behaviour is right, accept it as the baseline."
81
+
82
+ #: What a failed run's strip ends with: its one next action.
83
+ _FIX_IT = "fix the model or its settings, then queue it again."
84
+ #: The comparator's words for a failed run that said nothing of why.
85
+ _NO_REASON = "Simulation failed"
86
+
87
+
88
+ def _why(failure: str | None) -> str:
89
+ """A failed run's own reason as one line with no full stop — the
90
+ backend's message, as the Suite table's Message column has it — or
91
+ ``""`` where the run recorded none."""
92
+ line = one_line(failure or "").rstrip(".")
93
+ return "" if line == _NO_REASON else line
94
+
95
+
96
+ #: The strip of a test whose run was cancelled (D150).
97
+ CANCELLED_SENTENCE = (
98
+ "Cancelled — no result (its earlier result was replaced when the run started)."
99
+ )
100
+
101
+ #: Between the EDITS count and what the edits do to the verdict — in the
102
+ #: strip's sentence and on the pending drawer's line under each test.
103
+ SEP = " · "
104
+
105
+
106
+ def verdict_phrase(status: str | None, saved_status: str | None) -> str:
107
+ """What the edits do to the verdict: the EDITS clause after the count,
108
+ and the pending drawer's verdict line."""
109
+ if saved_status is None:
110
+ return f"a new entry, scored {status or '…'}"
111
+ if status == saved_status:
112
+ return f"verdict stays {status}"
113
+ return f"verdict would change {saved_status} → {status or '…'}"
114
+
115
+
116
+ def unrecorded_variables(
117
+ test: TestModel | None, result: TestResult | None, diagnostics: Iterable[str]
118
+ ) -> list[str]:
119
+ """The variables ``test``'s criteria name (`tree_variables`, the rule
120
+ every backend tracks by) that ``result`` does not hold. ``[]`` without
121
+ a result: there is nothing to rerun FOR yet. ``[]`` for a FAILED one,
122
+ which holds no variables: every criterion's would count, and a rerun
123
+ asked for to score them over a simulation that fails."""
124
+ if test is None or result is None or not result.success:
125
+ return []
126
+ have = {v.name for v in result.variables}
127
+ return [
128
+ n for n in tree_variables(test.metric_tree_spec, diagnostics) if n not in have
129
+ ]
130
+
131
+
132
+ def new_variables(
133
+ test: TestModel | None, result: TestResult | None, diagnostics: Iterable[str]
134
+ ) -> int:
135
+ """How many variables only a rerun records: those of
136
+ :func:`unrecorded_variables`. A variable the result FILE holds is not
137
+ one of them — `Session.stage` re-reads the model when a criterion names
138
+ it (the owner's O2), so the result holds it by the time this counts.
139
+ 0 for a simulate-only test: nothing is ever scored there, so no
140
+ variable is one a rerun would score (never NEW_VARIABLES or
141
+ EDITS_NEW)."""
142
+ if test is not None and test.simulate_only:
143
+ return 0
144
+ return len(unrecorded_variables(test, result, diagnostics))
145
+
146
+
147
+ def _queue_it(n: int) -> str:
148
+ return f"queue the test to score {'it' if n == 1 else 'them'}"
149
+
150
+
151
+ def strip_for(
152
+ *,
153
+ queue: QueueRow | None = None,
154
+ edits: int = 0,
155
+ status: str | None = None,
156
+ saved_status: str | None = None,
157
+ has_result: bool = False,
158
+ loading: bool = False,
159
+ scoring_failed: bool = False,
160
+ simulate_only: bool = False,
161
+ fails: int = 0,
162
+ criteria: int = 0,
163
+ advisory: int = 0,
164
+ new_variables: int = 0,
165
+ refused: str | None = None,
166
+ partial: Partial | None = None,
167
+ failure: str | None = None,
168
+ ) -> Strip:
169
+ """The strip for one test. ``queue`` is its row in the simulation
170
+ queue (`queue_rows.queue_row`), None when the queue lists no item of
171
+ it; ``edits`` its pending ops; ``status`` / ``saved_status`` the
172
+ classifier's word for the comparison as edited / as saved (None while
173
+ unscored, or — saved — for an entry the loaded spec does not have;
174
+ ``reporting.status.SCORING_FAILED`` — saved — when scoring the saved
175
+ spec raised);
176
+ ``has_result`` whether the last run finished it; ``loading`` whether
177
+ the project is still being read — an unscored test is then waiting for
178
+ it, never without a result, which may not be read yet; ``fails`` /
179
+ ``criteria`` / ``advisory`` its leaves failing, in all, and failing
180
+ inside a warn; ``new_variables`` the variables its criteria name that
181
+ the last run did not record (:func:`new_variables`); ``refused`` why
182
+ the comparison refused the test (`reporting.status.refusal`), or None;
183
+ ``partial`` what its last run left when it did not finish
184
+ (`Session.partial`), or None; ``failure`` the message its comparison
185
+ carries (`TestComparison.error_message`) — for a failed run the
186
+ backend's own, read back from the run's stored result, so it is there
187
+ after the queue's row is cleared and after the tool is started again."""
188
+ S = Situation
189
+ if queue is not None and queue.state is ItemState.WAITING:
190
+ return Strip(S.QUEUED, "grey", f"Queued — {queue.words}")
191
+ if queue is not None and queue.state is ItemState.RUNNING:
192
+ where = f" on worker {queue.worker}" if queue.worker is not None else ""
193
+ if queue.cancelling:
194
+ # A stop was asked and it has not ended yet (`QueueRow`).
195
+ return Strip(S.RUNNING, "grey", f"Cancelling{where}…")
196
+ # How far its result file has got, where the backend has a live
197
+ # view (D151); its seconds, and the line that says why, where not.
198
+ if queue.progress is not None:
199
+ return Strip(S.RUNNING, "grey", f"Running{where} — {queue.progress}")
200
+ return Strip(
201
+ S.RUNNING,
202
+ "grey",
203
+ f"Running{where} — {queue.elapsed or 0:.0f} s",
204
+ note=NOT_AVAILABLE,
205
+ )
206
+ if edits:
207
+ sentence = f"{plural(edits, 'unsaved edit')}{SEP}{verdict_phrase(status, saved_status)}"
208
+ if new_variables:
209
+ new = f"{plural(new_variables, 'new variable')}: {_queue_it(new_variables)}"
210
+ return Strip(S.EDITS_NEW, "amber", f"{sentence}{SEP}{new}")
211
+ return Strip(S.EDITS, "amber", sentence)
212
+ if scoring_failed:
213
+ return Strip(S.SCORING_FAILED, "red", "Scoring failed; the tree card says why.")
214
+ if status is None and (has_result or loading):
215
+ return Strip(S.WAITING, "grey", "Scoring…")
216
+ if partial is not None and status in (None, NOT_RUN, SIM_FAIL):
217
+ # Its last run did not finish and left a partial result (D151):
218
+ # where it stopped, and that nothing of it is scored. A cancel is
219
+ # nobody's failure; a timeout and a crash are the test's.
220
+ if partial.how == CANCELLED:
221
+ return Strip(S.CANCELLED, "grey", partial.sentence)
222
+ why = _why(failure) if status == SIM_FAIL else ""
223
+ return Strip(
224
+ S.SIM_FAIL,
225
+ "red",
226
+ f"{partial.sentence}. {why} — {_FIX_IT}" if why else partial.sentence,
227
+ tip=(failure or "") if why else "",
228
+ )
229
+ if queue is not None and queue.state is ItemState.CANCELLED:
230
+ return Strip(S.CANCELLED, "grey", CANCELLED_SENTENCE)
231
+ if queue is not None and queue.state is ItemState.FAILED and not queue.started:
232
+ said = queue.message or ""
233
+ return Strip(S.START_FAILED, "red", one_line(said) or queue.words, tip=said)
234
+ # NOT RUN is the classifier's word for this situation (D145): the
235
+ # header's badge and the strip say one thing. A SIM_FAIL is a run that
236
+ # reached a verdict, and reads as one below.
237
+ if status == NOT_RUN or (not has_result and status is None):
238
+ return Strip(S.NO_RESULT, "grey", "No result yet — queue it to simulate it.")
239
+ if status == SIM_FAIL:
240
+ # The backend's own reason, not only that it failed: it was on the
241
+ # queue's row alone, and Clear finished took it away.
242
+ why = _why(failure)
243
+ if why:
244
+ return Strip(S.SIM_FAIL, "red", f"{why} — {_FIX_IT}", tip=failure or "")
245
+ return Strip(
246
+ S.SIM_FAIL,
247
+ "red",
248
+ f"The simulation failed. {_FIX_IT[0].upper()}{_FIX_IT[1:]}",
249
+ )
250
+ if refused and status == FAIL:
251
+ # Before NEW_VARIABLES: a rerun records the variable, and the test
252
+ # is refused all the same. The reason, never "accept it": accepting
253
+ # keeps the reference's comparison settings, the refusal with them.
254
+ # A reason that ends its own sentence (`… did you mean "max_value"?`)
255
+ # is not given a second stop.
256
+ stop = "" if refused.endswith(("?", "!", ".")) else "."
257
+ return Strip(S.REFUSED, "red", f"Not scored: {refused}{stop}")
258
+ if new_variables:
259
+ return Strip(
260
+ S.NEW_VARIABLES,
261
+ "amber",
262
+ f"{plural(new_variables, 'new variable')} the last run did not "
263
+ f"record: {_queue_it(new_variables)}.",
264
+ )
265
+ if status == NO_REF:
266
+ return Strip(
267
+ S.NO_REF, "amber", "No baseline yet — accept this result as the first one."
268
+ )
269
+ if status == FAIL:
270
+ if fails:
271
+ what = f"Fails {fails} of {plural(criteria, 'criterion', 'criteria')}."
272
+ else:
273
+ what = "Fails against its baseline."
274
+ return Strip(S.FAIL, "red", f"{what} {_ACCEPT_IT}")
275
+ assert status == PASS, status
276
+ if simulate_only:
277
+ # The classifier's PASS for a test whose criterion is "it ran".
278
+ return Strip(
279
+ S.SIMULATE_ONLY, "grey", "Simulate-only: it runs, and nothing is compared."
280
+ )
281
+ if advisory:
282
+ return Strip(
283
+ S.PASS,
284
+ "grey",
285
+ f"Passes against its baseline, with {plural(advisory, 'advisory failure')}.",
286
+ )
287
+ return Strip(S.PASS, "grey", "Passes against its baseline.")
@@ -0,0 +1,215 @@
1
+ """The spec as the Session holds it: the document as loaded, its content
2
+ hash, and the ops staged against it."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import copy
7
+ import hashlib
8
+ from collections import Counter
9
+ from collections.abc import Iterable
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from ..config import ConfigError
15
+ from ..discovery.patch_apply import apply_patch_to_document
16
+ from ..discovery.spec_parser import spec_document_from_bytes
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class PatchOp:
21
+ """One RFC 6902 op against one model's spec entry. ``path`` is relative
22
+ to the entry, as ``apply_patch`` reads it."""
23
+
24
+ model_id: str
25
+ op: str # "add" | "replace" | "remove"
26
+ path: str
27
+ value: Any = None
28
+ #: The ``add /metrics`` `Session.stage` prepends to a flat entry's first
29
+ #: per-leaf edit: the implicit tree written out, scoring the same. The
30
+ #: pending drawer diffs against the saved entry WITH it (`preview_base`)
31
+ #: so an edit reads as itself, not as the whole tree added; and while
32
+ #: the entry's ``metrics`` is this tree and nothing more, the ops that
33
+ #: wrote it are no edit — they are dropped, whatever else is pending
34
+ #: but an edit of the ``variable_overrides`` the tree is built from
35
+ #: (`Session.stage`). Not part of the op: not in `raw()`, not in its
36
+ #: equality.
37
+ materializes: bool = field(default=False, compare=False)
38
+ #: An ``add /metrics`` that is an EDIT of the tree its entry scores by,
39
+ #: given whole — one gesture on a folded criteria row (D152,
40
+ #: `family_rows.as_one`) — rather than a new tree: `Session.stage`
41
+ #: writes a flat entry's implicit tree out first, marked, exactly as
42
+ #: for a per-leaf op, so the preview diffs the edit against that tree
43
+ #: and says Save writes it. Not part of the op, as ``materializes``.
44
+ edits_tree: bool = field(default=False, compare=False)
45
+ #: What the op did, in words, for the preview to say above its entry's
46
+ #: diff — ``(key, sentence)`` pairs: where a later op replaces this one
47
+ #: (`Session.stage`), a sentence under the same key replaces its
48
+ #: sentence and any other is kept, so a value typed over is one line
49
+ #: and two gestures are two. Not part of the op.
50
+ notes: tuple[tuple[str, str], ...] = field(default=(), compare=False)
51
+ #: Which act of the user's staged it (`Session.stage` numbers its
52
+ #: calls): one Ctrl+click on a flat test's plot is two ops — the tree
53
+ #: written out, and the row — and one edit; so is an add over twenty
54
+ #: Indices, and a change of a folded row's twenty criteria. An op that
55
+ #: supersedes the last one keeps that one's number: a value typed over
56
+ #: is the same edit. None for an op nothing staged (a test's own list):
57
+ #: it counts as itself (`edit_counts`). Not part of the op.
58
+ gesture: int | None = field(default=None, compare=False)
59
+
60
+ def raw(self) -> dict:
61
+ """The op as ``apply_patch`` and the envelope carry it."""
62
+ raw: dict = {"op": self.op, "path": self.path}
63
+ if self.op != "remove":
64
+ raw["value"] = copy.deepcopy(self.value)
65
+ return raw
66
+
67
+
68
+ def edit_counts(pending: Iterable[PatchOp]) -> Counter[str]:
69
+ """By model, how many EDITS ``pending`` holds of its entry: the things
70
+ a person did (`PatchOp.gesture`), not the ops they took. The ONE count
71
+ — the header's ``N pending edits``, Save's caption, the Suite's Edits
72
+ cell, the Tests drawer's mark, the strip's ``N unsaved edits`` and the
73
+ preview's ``N edits`` all read it, so no two of them can disagree."""
74
+ edits = {
75
+ (op.model_id, ("op", index) if op.gesture is None else op.gesture)
76
+ for index, op in enumerate(pending)
77
+ }
78
+ return Counter(model_id for model_id, _ in edits)
79
+
80
+
81
+ def edit_count(pending: Iterable[PatchOp]) -> int:
82
+ """How many edits ``pending`` holds in all (`edit_counts`)."""
83
+ return sum(edit_counts(pending).values())
84
+
85
+
86
+ def content_hash(path: Path) -> str | None:
87
+ """SHA-256 of the file's bytes; ``None`` when it does not exist."""
88
+ try:
89
+ return hashlib.sha256(path.read_bytes()).hexdigest()
90
+ except FileNotFoundError:
91
+ return None
92
+
93
+
94
+ def json_equal(a: Any, b: Any) -> bool:
95
+ """Whether ``a`` and ``b`` are one JSON value: objects by their members
96
+ in any order, arrays in order, numbers by value (``1`` is ``1.0``), and
97
+ ``true`` is no number — Python's ``==`` calls ``True`` and ``1`` equal."""
98
+ if isinstance(a, bool) or isinstance(b, bool):
99
+ return isinstance(a, bool) and isinstance(b, bool) and a == b
100
+ if isinstance(a, dict) and isinstance(b, dict):
101
+ return a.keys() == b.keys() and all(json_equal(a[k], b[k]) for k in a)
102
+ if isinstance(a, list) and isinstance(b, list):
103
+ return len(a) == len(b) and all(json_equal(x, y) for x, y in zip(a, b))
104
+ if isinstance(a, (dict, list)) or isinstance(b, (dict, list)):
105
+ return False
106
+ return bool(a == b)
107
+
108
+
109
+ @dataclass
110
+ class SpecDocument:
111
+ path: Path
112
+ loaded: dict # as read; never mutated
113
+ content_hash: str | None # of the bytes ``loaded`` came from
114
+ pending: list[PatchOp] = field(default_factory=list)
115
+ #: ``(len(pending), document)`` — the last replay, reused while pending
116
+ #: only grows (the Session never edits an op in place; it replaces the
117
+ #: document). A rescore over every test is then one replay, not one per
118
+ #: test.
119
+ _effective_cache: tuple[int, dict] | None = field(
120
+ default=None, repr=False, compare=False
121
+ )
122
+
123
+ @classmethod
124
+ def load(cls, path: Path) -> SpecDocument:
125
+ """Read ``path`` once: the hash and the document come from the same
126
+ bytes. A missing file is the empty document; one that is no spec —
127
+ no UTF-8 text, no JSON, a non-object root, a non-list ``tests`` — is
128
+ a :class:`ConfigError` (``spec_parser.spec_document_from_bytes``,
129
+ the one decoder every command reads the file through), because the
130
+ Session must not save over a file it could not read. A Save writes
131
+ a file read with a BOM back without it."""
132
+ try:
133
+ raw = path.read_bytes()
134
+ except FileNotFoundError:
135
+ return cls(path=path, loaded={"tests": []}, content_hash=None)
136
+ except OSError as e:
137
+ raise ConfigError(f"{path}: {e}") from e
138
+ data = spec_document_from_bytes(raw, str(path))
139
+ return cls(path=path, loaded=data, content_hash=hashlib.sha256(raw).hexdigest())
140
+
141
+ def on_disk_changed(self) -> bool:
142
+ return content_hash(self.path) != self.content_hash
143
+
144
+ def grouped(self) -> list[tuple[str, list[dict]]]:
145
+ """Pending ops as ``(model_id, raw ops)`` groups: models in
146
+ first-seen order, ops in staging order."""
147
+ groups: dict[str, list[dict]] = {}
148
+ for op in self.pending:
149
+ groups.setdefault(op.model_id, []).append(op.raw())
150
+ return list(groups.items())
151
+
152
+ def saved_entry(self, model_id: str) -> dict | None:
153
+ """The model's entry as LOADED — whatever is pending — as a copy the
154
+ caller may keep; ``None`` when the loaded document has no entry for
155
+ it (a pending op may be creating one)."""
156
+ from ..discovery.spec_update import spec_entry
157
+
158
+ entry = spec_entry(self.loaded, model_id)
159
+ return copy.deepcopy(entry) if entry is not None else None
160
+
161
+ def as_saved(self, model_id: str) -> bool:
162
+ """Whether the model's entry with the pending ops applied is the
163
+ one the file holds (:func:`json_equal`): ops that put it back are no
164
+ edit of it. A model the file has no entry for is as saved while its
165
+ entry would hold its model and nothing else — an entry that says
166
+ nothing (a tree added to an annotation-only test, then removed).
167
+ The document's half of the rule: a ``metrics`` member that is the
168
+ tree the saved entry already scores by is no edit either, and only
169
+ the Session knows that tree — it takes those ops out before asking
170
+ this (`Session._its_tree_written_out`)."""
171
+ from ..discovery.spec_update import spec_entry
172
+
173
+ bare = {"model": model_id}
174
+ return json_equal(
175
+ self.entry(model_id) or bare, spec_entry(self.loaded, model_id) or bare
176
+ )
177
+
178
+ def preview_base(self, model_id: str) -> dict | None:
179
+ """What the pending drawer diffs the model's entry against: the
180
+ saved entry with the model's materializing ops applied (a new entry
181
+ starts as ``{"model": model_id}``), a copy; with none of them, the
182
+ saved entry (``None`` for a new one)."""
183
+ from ..discovery.spec_update import spec_entry
184
+
185
+ ops = [
186
+ op.raw()
187
+ for op in self.pending
188
+ if op.model_id == model_id and op.materializes
189
+ ]
190
+ if not ops:
191
+ return self.saved_entry(model_id)
192
+ data = copy.deepcopy(self.loaded)
193
+ apply_patch_to_document(data, model_id, ops)
194
+ return copy.deepcopy(spec_entry(data, model_id))
195
+
196
+ def effective(self) -> dict:
197
+ """A copy of ``loaded`` with every pending op applied."""
198
+ return copy.deepcopy(self._effective())
199
+
200
+ def entry(self, model_id: str) -> dict | None:
201
+ """The model's entry in the effective document — read it, never
202
+ edit it (it is the cached replay)."""
203
+ from ..discovery.spec_update import spec_entry
204
+
205
+ return spec_entry(self._effective(), model_id)
206
+
207
+ def _effective(self) -> dict:
208
+ n = len(self.pending)
209
+ if self._effective_cache is not None and self._effective_cache[0] == n:
210
+ return self._effective_cache[1]
211
+ data = copy.deepcopy(self.loaded)
212
+ for model_id, ops in self.grouped():
213
+ apply_patch_to_document(data, model_id, ops)
214
+ self._effective_cache = (n, data)
215
+ return data