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
dstf/storage/accept.py ADDED
@@ -0,0 +1,216 @@
1
+ """Accepting a run's results as baselines — the one function the CLI's two
2
+ ``--accept`` paths, its interactive review and the authoring tool's Accept
3
+ call."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+
9
+ from ..backends.common.result_variables import criterion_variables, tracks
10
+ from ..backends.types import TestResult
11
+ from ..comparison.comparator import nothing_scored, refused_base_tolerance
12
+ from ..discovery.test_registry import TestModel
13
+ from .batch_manifest import unfinished_run_models
14
+ from .ref_index import RefIndex
15
+ from .reference_store import ReferenceStore
16
+
17
+ #: A baseline file larger than this many bytes — 5 MB — is said by the accept
18
+ #: that wrote it (D153). A constant, not a key: the remedy it names is the key.
19
+ LARGE_BASELINE_BYTES = 5_000_000
20
+
21
+
22
+ def large_baseline_warning(store: ReferenceStore, model_id: str) -> str | None:
23
+ """The sentence for ``model_id``'s reference file when it is larger than
24
+ `LARGE_BASELINE_BYTES`, else ``None``. A warning: the baseline IS stored.
25
+ The size is the file's, and its counts are read back from it — only for
26
+ a file that is over, so an accept of small baselines reads nothing."""
27
+ test_id = store.index.get_id(model_id)
28
+ if test_id is None:
29
+ return None
30
+ ref_file = store.ref_dir / RefIndex.ref_filename(test_id)
31
+ try:
32
+ size = ref_file.stat().st_size
33
+ except OSError:
34
+ return None
35
+ if size <= LARGE_BASELINE_BYTES:
36
+ return None
37
+ reference = store.get_reference(model_id) or {}
38
+ points = len(reference.get("time", []))
39
+ n_vars = len(reference.get("variables", []))
40
+ return (
41
+ f"{ref_file.name} ({model_id}) is {size / 1e6:.1f} MB ({points:,} points "
42
+ f"× {n_vars:,} variable{'' if n_vars == 1 else 's'}) — set "
43
+ '"baseline_points" for this test to store fewer.'
44
+ )
45
+
46
+
47
+ #: How many names a sentence that lists them spells out
48
+ #: (:func:`lost_variables_warnings`, :func:`simulate_only_note`).
49
+ _NAMED = 6
50
+
51
+
52
+ def _named(names: list[str]) -> str:
53
+ """Up to `_NAMED` of ``names``, then ``and N more``."""
54
+ named = ", ".join(names[:_NAMED])
55
+ if len(names) > _NAMED:
56
+ named += f" and {len(names) - _NAMED} more"
57
+ return named
58
+
59
+
60
+ def simulate_only_note(model_ids: list[str]) -> str | None:
61
+ """``Simulate-only, nothing to store: MyLib.Tank`` for the tests an
62
+ accept passed over because they declare nothing to score
63
+ (``TestModel.is_simulate_only``), else ``None``. Neither a warning nor a
64
+ skip: such a test has no baseline to store, and its accept did not fail
65
+ — but `Accepted 0 test baselines` with no word of the test read as an
66
+ accept that did nothing, for no reason."""
67
+ if not model_ids:
68
+ return None
69
+ return f"Simulate-only, nothing to store: {_named(model_ids)}"
70
+
71
+
72
+ def _variable_names(reference: dict | None) -> list[str]:
73
+ return [v.get("name") or "" for v in (reference or {}).get("variables", [])]
74
+
75
+
76
+ def lost_variables_warnings(
77
+ store: ReferenceStore, test: TestModel, held_before: list[str]
78
+ ) -> list[str]:
79
+ """The sentences for a baseline just written that no longer holds a
80
+ variable the one it replaced held (``held_before``, its names); none
81
+ when it lost nothing. A warning: the baseline IS stored — accepting is
82
+ how a variable is dropped on purpose — but a run that LOST one rewrote
83
+ the baseline without it, and nothing said so.
84
+
85
+ Two sentences, since a variable leaves a baseline for two reasons, and
86
+ each is said of its own names. One the test still tracks — a UnitTests
87
+ variable, a ``variables`` pattern, a criterion's variable — the run did
88
+ not deliver: ``the accepted result does not hold it``. One it no longer
89
+ tracks was taken out on purpose, and the run may well still write it:
90
+ ``the test no longer tracks it`` — that half read ``the accepted result
91
+ does not hold it`` too, of a result file that held it."""
92
+ model_id = test.model_id
93
+ test_id = store.index.get_id(model_id)
94
+ if test_id is None:
95
+ return []
96
+ holds = set(_variable_names(store.get_reference(model_id)))
97
+ lost = [name for name in held_before if name and name not in holds]
98
+ tracked = tracks(test)
99
+ by_criterion = set(criterion_variables(test, store.config.diagnostic_variables))
100
+ asked = [name for name in lost if tracked(name) or name in by_criterion]
101
+ dropped = [name for name in lost if name not in asked]
102
+
103
+ def said(names: list[str], why: str) -> str:
104
+ return (
105
+ f"{RefIndex.ref_filename(test_id)} ({model_id}) no longer holds "
106
+ f"{_named(names)} — {why} {'it' if len(names) == 1 else 'them'}."
107
+ )
108
+
109
+ return [
110
+ *([said(asked, "the accepted result does not hold")] if asked else []),
111
+ *([said(dropped, "the test no longer tracks")] if dropped else []),
112
+ ]
113
+
114
+
115
+ def accept_refusal(
116
+ store: ReferenceStore, test: TestModel, result: TestResult | None
117
+ ) -> str | None:
118
+ """Why the comparison REFUSES ``test`` — its base tolerance is no
119
+ number, or its result holds nothing it tracks — or ``None``: the
120
+ comparison's own decisions (`refused_base_tolerance`, `nothing_scored`),
121
+ asked of the test and its stored reference as ``compare_one`` asks
122
+ them. A test with no successful result is not refused: it has its own
123
+ reason."""
124
+ if result is None or not result.success:
125
+ return None
126
+ refused = refused_base_tolerance(
127
+ test, store.get_reference(test.model_id), store.config.tolerance
128
+ )
129
+ if refused is not None:
130
+ return refused.error_message
131
+ return nothing_scored(test, result)
132
+
133
+
134
+ def accept_run_results(
135
+ store: ReferenceStore,
136
+ tests: list[TestModel],
137
+ results: dict[str, TestResult],
138
+ work_dir: Path,
139
+ ) -> tuple[int, list[tuple[str, str]], list[str], list[str]]:
140
+ """:meth:`ReferenceStore.accept_results` plus two refusals, two warnings
141
+ and a note.
142
+
143
+ A test the batch manifest in ``work_dir`` still records as pending was
144
+ in flight when a run was aborted: it has no recorded verdict, and its
145
+ truncated result file may still parse. Acceptance requires an
146
+ authoritative successful run, so it is skipped with the reason, never
147
+ stored. Deliberately NOT keyed on the comparison verdict — a test that
148
+ ran fine and now compares FAILED is exactly the drift case accepting
149
+ exists to re-baseline.
150
+
151
+ A test the comparison REFUSES (`accept_refusal`) is skipped with the
152
+ refusal's sentence: its result was scored against nothing, and the
153
+ baseline written would carry — or keep — a base no comparison reads.
154
+ Its reference file is not touched.
155
+
156
+ A baseline file just written that is larger than `LARGE_BASELINE_BYTES`
157
+ is said once (`large_baseline_warning`): it IS stored, and counts as
158
+ stored. Every accept path prints what this returns, so the CLI and the
159
+ tool say the same sentence. So is a baseline that no longer holds a
160
+ variable the one it replaced held (`lost_variables_warnings`).
161
+
162
+ A simulate-only test whose run completed is neither stored nor skipped:
163
+ it has no baseline to store. The tests passed over for that are named
164
+ in one line (`simulate_only_note`), which every accept path prints too.
165
+
166
+ Returns ``(stored, skipped, warnings, notes)``: how many baselines were
167
+ written; ``accept_results``'s skips with the refusals appended — the
168
+ refused tests in the order given, then the unfinished ones in model
169
+ order; the warnings in the order written: per test, what it lost, then
170
+ its size; and the lines that are neither — the simulate-only tests'.
171
+ """
172
+ unfinished = unfinished_run_models(work_dir) & {t.model_id for t in tests}
173
+ refused = {
174
+ t.model_id: reason
175
+ for t in tests
176
+ if t.model_id not in unfinished
177
+ and (reason := accept_refusal(store, t, results.get(t.model_id))) is not None
178
+ }
179
+ accepted = [
180
+ t for t in tests if t.model_id not in unfinished and t.model_id not in refused
181
+ ]
182
+ # What each baseline holds now: read before the accept replaces it.
183
+ held_before = {
184
+ t.model_id: _variable_names(store.get_reference(t.model_id)) for t in accepted
185
+ }
186
+ stored, skipped, simulate_only = store.accept_results(accepted, results)
187
+ by_model = {t.model_id: t for t in accepted}
188
+ note = simulate_only_note(simulate_only)
189
+ return (
190
+ len(stored),
191
+ [
192
+ *skipped,
193
+ *refused.items(),
194
+ *(
195
+ (
196
+ model_id,
197
+ f"last run did not complete for this test (aborted or "
198
+ f"cancelled while it was in flight; {work_dir} records no "
199
+ f"verdict for it) — re-run before accepting",
200
+ )
201
+ for model_id in sorted(unfinished)
202
+ ),
203
+ ],
204
+ [
205
+ warning
206
+ for model_id in stored
207
+ for warning in (
208
+ *lost_variables_warnings(
209
+ store, by_model[model_id], held_before[model_id]
210
+ ),
211
+ large_baseline_warning(store, model_id),
212
+ )
213
+ if warning is not None
214
+ ],
215
+ [note] if note is not None else [],
216
+ )
@@ -0,0 +1,43 @@
1
+ """The `Baseline` value type shared by the store and its role modules.
2
+
3
+ A baseline is one named set of reference trajectories: a ref file's
4
+ ``"primary"`` (synthesized from the flat on-disk fields by
5
+ :func:`~dstf.storage.reference_store._extract_baselines`) or one soft_check
6
+ read from ``soft_checks/ref_NNNN/<name>.json`` (D66). It lives in its own
7
+ module so both :mod:`dstf.storage.reference_store` and
8
+ :mod:`dstf.storage.roles` can depend on it without depending on each other.
9
+ """
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+ #: Name of a reference file's one built-in baseline.
14
+ PRIMARY_BASELINE = "primary"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Baseline:
19
+ """A single named baseline: a ref file's primary, or one soft_check.
20
+
21
+ See docs/architecture.md "Multiple named baselines (D66)".
22
+ """
23
+
24
+ #: Baseline name (e.g. ``"primary"``, ``"experiment"``, ``"analytical"``).
25
+ name: str
26
+ #: Shared time vector (all variables in this baseline share one time grid).
27
+ time: list[float]
28
+ #: Variable entries: ``[{"index", "name", "expression", "values"}, ...]``.
29
+ #: ``name`` is the variable's name in the result file and the only key;
30
+ #: ``expression`` is the label beside it (see ``VariableResult``).
31
+ variables: list[dict]
32
+ #: Diagnostic variable entries (same shape as ``variables``).
33
+ diagnostics: list[dict] = field(default_factory=list)
34
+ #: Simulation parameters that produced this baseline.
35
+ simulation: dict = field(default_factory=dict)
36
+ #: Comparison configuration (tolerance, variable_overrides).
37
+ comparison: dict = field(default_factory=dict)
38
+ #: Statistics captured at baseline-acceptance time.
39
+ statistics: dict = field(default_factory=dict)
40
+ #: Provenance metadata: origin, captured_at, backend, os, notes,
41
+ #: citation, plus arbitrary user metadata. For a ref file's primary this
42
+ #: is synthesized from ``date_added`` / ``last_updated``.
43
+ provenance: dict = field(default_factory=dict)
@@ -0,0 +1,204 @@
1
+ """The per-run batch manifest: test_key ↔ model_id, and the run's results.
2
+
3
+ Lives in :mod:`dstf.storage` with the other on-disk record keepers — it is a
4
+ JSON file in the work dir, written through the same atomic primitive as the
5
+ reference files. The backends package re-exports it (``from dstf.backends
6
+ import BatchManifest``) because every backend's ``run_tests`` returns one.
7
+
8
+ ``TestRunResult`` is imported only for typing: keeping it out of the runtime
9
+ import graph is what lets ``dstf.storage.batch_manifest`` be imported first,
10
+ before ``dstf.backends`` has run its package ``__init__``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import threading
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING
21
+
22
+ from .ref_index import _atomic_write_text
23
+ from .run_stats import write_run_stats
24
+
25
+ if TYPE_CHECKING:
26
+ from ..backends.types import TestRunResult
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ #: A run that was stopped on request (the simulation queue's cancel). It is
31
+ #: recorded, not left pending: a pending entry falls through to the
32
+ #: status.json cross-check, where an earlier run's "failed" row for the same
33
+ #: model would be applied to a test nobody failed.
34
+ CANCELLED_RUN_STATUS = "cancelled"
35
+
36
+ #: ``last_run_status`` values that mean "this run recorded what became of
37
+ #: this test". Only these make a manifest entry authoritative for a reader.
38
+ RECORDED_RUN_STATUSES = ("passed", "failed", "timed_out", CANCELLED_RUN_STATUS)
39
+
40
+ #: Stamped on every in-scope entry when a run starts, and overwritten the
41
+ #: instant that test's result lands. An entry still saying ``pending`` when a
42
+ #: reader arrives means a run began and never reached a verdict for it — an
43
+ #: abort, a crash, a Ctrl-C — so the reader must NOT treat it as authoritative
44
+ #: and must fall back to the status.json cross-check. Without this stamp a
45
+ #: PREVIOUS run's ``passed`` survived ``assign_test_keys``' carry-forward into
46
+ #: an aborted run's manifest and reported green over a partial ``.mat``.
47
+ PENDING_RUN_STATUS = "pending"
48
+
49
+ #: The two statuses of a last run that left NO result — it never finished the
50
+ #: test, or was told to stop. Whatever file such a run left is partial, and
51
+ #: is neither read nor accepted.
52
+ NO_RESULT_RUN_STATUSES = (PENDING_RUN_STATUS, CANCELLED_RUN_STATUS)
53
+
54
+
55
+ @dataclass
56
+ class BatchManifest:
57
+ """Maps test keys to model IDs (and optionally ref IDs) and tracks run results."""
58
+
59
+ work_dir: Path
60
+ manifest: dict[str, dict] # test_NNNN -> {"model_id": ..., "ref_id": ...}
61
+ results: list[TestRunResult] = field(default_factory=list)
62
+ #: Serializes the read-modify-save in :meth:`record_result`, which every
63
+ #: dispatch thread calls as its test finishes.
64
+ _write_lock: threading.Lock = field(
65
+ default_factory=threading.Lock, repr=False, compare=False
66
+ )
67
+
68
+ def test_dir(self, test_key: str) -> Path:
69
+ """Per-test subdirectory for all simulation artifacts."""
70
+ return self.work_dir / test_key
71
+
72
+ def model_id(self, test_key: str) -> str:
73
+ """Get the model ID for a test key."""
74
+ return self.manifest[test_key]["model_id"]
75
+
76
+ def save(self) -> Path:
77
+ path = self.work_dir / "batch_manifest.json"
78
+ # review 2026-07-06 finding 66: atomic replace — a torn write made
79
+ # every subsequent run/compare crash at startup until the file was
80
+ # hand-deleted.
81
+ _atomic_write_text(path, json.dumps(self.manifest, indent=2))
82
+ return path
83
+
84
+ def record_result(self, result: TestRunResult) -> None:
85
+ """Stamp one test's verdict into its manifest entry and persist NOW.
86
+
87
+ The run's per-test outcome has to outlive the process that produced
88
+ it: a later ``compare`` holds no run results, so without this it can
89
+ only read the ``.mat`` on disk — and a partial/stale one from a failed
90
+ run reads back as a success (D93 follow-on). ``last_run_status`` is
91
+ that record, in status.json's vocabulary
92
+ (``passed`` / ``failed`` / ``timed_out``), plus
93
+ :data:`CANCELLED_RUN_STATUS` for a run that was stopped on request.
94
+
95
+ Written per result rather than in a run epilogue **because the
96
+ epilogue is exactly what an abort skips**: Ctrl-C or an exception in
97
+ the dispatch loop would leave every entry at
98
+ :data:`PENDING_RUN_STATUS`, discarding verdicts the run had already
99
+ reached. Each save is a small atomic replace, on the same order of
100
+ cost as the status.json write the progress reporter already does per
101
+ state change.
102
+
103
+ Entries this run produced no result for are untouched, which is what
104
+ makes ``--merge`` and ``--rerun`` reports honest about the tests they
105
+ did not re-run.
106
+ """
107
+ if result.cancelled:
108
+ status = CANCELLED_RUN_STATUS
109
+ elif result.timed_out:
110
+ status = "timed_out"
111
+ else:
112
+ status = "passed" if result.success else "failed"
113
+ with self._write_lock:
114
+ for entry in self.manifest.values():
115
+ if entry["model_id"] == result.model_id:
116
+ entry["last_run_status"] = status
117
+ self.save()
118
+ # The run's walls and statistics, beside the result they describe
119
+ # (storage.run_stats). After the verdict is saved and outside its
120
+ # lock: the file is the test's own, and its write never raises.
121
+ write_run_stats(self.test_dir(result.test_key), result)
122
+
123
+ def mark_pending(self, model_ids: set[str]) -> None:
124
+ """Stamp :data:`PENDING_RUN_STATUS` on the entries a run is about to
125
+ (re-)run, so a stale verdict cannot survive into it.
126
+
127
+ ``assign_test_keys`` carries every prior entry forward, so without
128
+ this a previous run's ``passed`` would still be sitting on a test that
129
+ this run fails and then aborts before recording — and the reader would
130
+ trust it. Entries outside *model_ids* keep their own last verdict.
131
+
132
+ Does not save: the caller (``_prepare_run``) saves the manifest once
133
+ the keys are assigned.
134
+ """
135
+ for entry in self.manifest.values():
136
+ if entry["model_id"] in model_ids:
137
+ entry["last_run_status"] = PENDING_RUN_STATUS
138
+
139
+ def enrich_ref_ids(self, ref_index) -> None:
140
+ """Add ref_id to each entry using the reference index."""
141
+ for _test_key, entry in self.manifest.items():
142
+ ref_id = ref_index.get_id(entry["model_id"])
143
+ entry["ref_id"] = f"ref_{ref_id}" if ref_id else None
144
+ self.save()
145
+
146
+ @classmethod
147
+ def load(cls, path: Path) -> BatchManifest:
148
+ try:
149
+ data = json.loads(path.read_text(encoding="utf-8"))
150
+ if not isinstance(data, dict):
151
+ raise ValueError(f"expected a JSON object, got {type(data).__name__}")
152
+ except (ValueError, OSError) as exc:
153
+ # review 2026-07-06 finding 66: a corrupt manifest must not crash
154
+ # startup. Quarantine it (keeping the bytes for forensics) and
155
+ # start from an empty manifest — every caller already treats a
156
+ # missing manifest as empty, so test keys are simply re-assigned
157
+ # on the next run. (JSONDecodeError is a ValueError subclass.)
158
+ corrupt = path.with_name(f"{path.name}.corrupt")
159
+ where = ""
160
+ try:
161
+ path.replace(corrupt)
162
+ where = f"; moved to {corrupt.name}"
163
+ except OSError:
164
+ pass
165
+ logger.warning(
166
+ "Corrupt batch manifest %s (%s)%s — starting with an empty manifest",
167
+ path,
168
+ exc,
169
+ where,
170
+ )
171
+ return cls(work_dir=path.parent, manifest={})
172
+ # Handle legacy format: test_key -> model_id string
173
+ normalized = {}
174
+ for key, val in data.items():
175
+ if isinstance(val, str):
176
+ normalized[key] = {"model_id": val, "ref_id": None}
177
+ else:
178
+ normalized[key] = val
179
+ return cls(work_dir=path.parent, manifest=normalized)
180
+
181
+
182
+ def unfinished_run_models(work_dir: Path) -> set[str]:
183
+ """Model IDs whose last run started them and never reached a verdict.
184
+
185
+ A manifest entry still reading :data:`PENDING_RUN_STATUS` means a run
186
+ stamped it at startup and was aborted (Ctrl-C, crash, killed worker pool)
187
+ before that test finished; one reading :data:`CANCELLED_RUN_STATUS` was
188
+ stopped on request. The accept path uses this: blessing such a
189
+ test's on-disk ``.mat`` would bake in whatever the simulator had flushed
190
+ at the moment it died, and a truncated result file can still parse.
191
+
192
+ Entries with no field at all are NOT included — a manifest written before
193
+ the field existed says nothing about completeness, so those keep the
194
+ pre-existing status.json-guard behaviour (old data, not old code).
195
+ Returns an empty set when the work dir has no manifest.
196
+ """
197
+ manifest_path = work_dir / "batch_manifest.json"
198
+ if not manifest_path.exists():
199
+ return set()
200
+ return {
201
+ entry["model_id"]
202
+ for entry in BatchManifest.load(manifest_path).manifest.values()
203
+ if entry.get("last_run_status") in NO_RESULT_RUN_STATUSES
204
+ }