dynamic-systems-testing-framework 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""In-memory index over ref_NNNN.json files, plus the atomic-write primitive.
|
|
2
|
+
|
|
3
|
+
The ref files are the source of truth — there is no persistent manifest.
|
|
4
|
+
:class:`RefIndex` builds the model_id ↔ test_id mapping by scanning them and
|
|
5
|
+
owns ID allocation (O_EXCL claim + per-partition high-water mark) so IDs are
|
|
6
|
+
never reused. :func:`_atomic_write_text` lives here because ID allocation is
|
|
7
|
+
its most safety-critical caller; every other JSON write in the package uses it
|
|
8
|
+
too.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Pattern matching ref_NNNN.json filenames
|
|
23
|
+
_REF_FILE_PATTERN = re.compile(r"^ref_(\d{4,})\.json$")
|
|
24
|
+
|
|
25
|
+
#: Per-partition high-water mark for allocated test IDs. A dot-file, so the
|
|
26
|
+
#: ``ref_*.json`` scan glob never picks it up.
|
|
27
|
+
_ID_HIGH_WATER_FILE = ".id_high_water.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _atomic_write_text(path: Path, text: str, *, newline: str = "\n") -> None:
|
|
31
|
+
"""Atomically replace ``path`` with ``text``.
|
|
32
|
+
|
|
33
|
+
review 2026-07-06 (finding 31): all reference writes were plain
|
|
34
|
+
``write_text`` — a Ctrl-C mid-accept corrupted the baseline, the next
|
|
35
|
+
scan silently dropped it, and ``next_id()`` re-allocated its ID to a
|
|
36
|
+
different model. Same tmp-file + replace pattern as
|
|
37
|
+
``dashboard_render._atomic_write`` (unique tmp name in the same
|
|
38
|
+
directory, retry loop for Windows file locking), except this variant
|
|
39
|
+
always raises on failure — a silently stale baseline is a
|
|
40
|
+
data-integrity bug, not a cosmetic one.
|
|
41
|
+
|
|
42
|
+
``newline`` is passed through to :func:`open` and defaults to ``"\\n"``,
|
|
43
|
+
NOT to :func:`open`'s ``None``: with ``None`` Python
|
|
44
|
+
translates ``\\n`` to ``os.linesep``, so a baseline accepted on Windows
|
|
45
|
+
landed CRLF and the same file accepted from WSL landed LF. The reference
|
|
46
|
+
tree is a surface other people version-control; a cross-platform accept
|
|
47
|
+
turned a one-value edit into a 6,881-line whole-file diff, hiding the
|
|
48
|
+
real change inside it. Every JSON write in the package therefore gets LF
|
|
49
|
+
on every platform, and a new caller cannot reintroduce the bug by
|
|
50
|
+
forgetting a keyword. The one CSV caller still passes ``""``
|
|
51
|
+
explicitly, which is now belt-and-braces rather than load-bearing:
|
|
52
|
+
neither ``""`` nor ``"\\n"`` translates on write, so the ``\\r\\n`` that
|
|
53
|
+
:mod:`csv` embeds survives either way. It stays because it states the
|
|
54
|
+
intent at the call site and because it is what keeps that caller correct
|
|
55
|
+
if this default is ever revisited.
|
|
56
|
+
"""
|
|
57
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
58
|
+
try:
|
|
59
|
+
with tmp.open("w", encoding="utf-8", newline=newline) as f:
|
|
60
|
+
f.write(text)
|
|
61
|
+
last_err: OSError | None = None
|
|
62
|
+
for delay in (0, 0.05, 0.1, 0.2, 0.5):
|
|
63
|
+
if delay:
|
|
64
|
+
time.sleep(delay)
|
|
65
|
+
try:
|
|
66
|
+
tmp.replace(path)
|
|
67
|
+
return
|
|
68
|
+
except OSError as e:
|
|
69
|
+
last_err = e
|
|
70
|
+
assert last_err is not None
|
|
71
|
+
raise last_err
|
|
72
|
+
finally:
|
|
73
|
+
try:
|
|
74
|
+
if tmp.exists():
|
|
75
|
+
tmp.unlink()
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RefIndex:
|
|
81
|
+
"""In-memory index mapping model IDs to ref files.
|
|
82
|
+
|
|
83
|
+
Built by scanning ref_NNNN.json files in the reference directory.
|
|
84
|
+
No persistent manifest file — the ref files are the source of truth.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, ref_dir: Path):
|
|
88
|
+
self.ref_dir = ref_dir
|
|
89
|
+
self._by_model: dict[str, str] = {} # model_id -> test_id
|
|
90
|
+
self._by_id: dict[str, dict] = {} # test_id -> {model_id, status}
|
|
91
|
+
self._loaded = False
|
|
92
|
+
|
|
93
|
+
def _scan(self):
|
|
94
|
+
"""Scan ref files and build the in-memory index."""
|
|
95
|
+
self._by_model.clear()
|
|
96
|
+
self._by_id.clear()
|
|
97
|
+
|
|
98
|
+
if not self.ref_dir.exists():
|
|
99
|
+
self._loaded = True
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
for ref_file in sorted(self.ref_dir.glob("ref_*.json")):
|
|
103
|
+
m = _REF_FILE_PATTERN.match(ref_file.name)
|
|
104
|
+
if not m:
|
|
105
|
+
continue
|
|
106
|
+
test_id = m.group(1)
|
|
107
|
+
try:
|
|
108
|
+
# Whole-file read + parse. Only model_id/status are used, and
|
|
109
|
+
# they sit at the top, but json has no partial-parse entry
|
|
110
|
+
# point — a header-only read is idea #64, not done here.
|
|
111
|
+
text = ref_file.read_text(encoding="utf-8")
|
|
112
|
+
data = json.loads(text)
|
|
113
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
114
|
+
logger.warning("Skipping unreadable ref file %s: %s", ref_file.name, e)
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
model_id = data.get("model_id")
|
|
118
|
+
if not model_id:
|
|
119
|
+
logger.warning("Ref file %s has no model_id — skipping", ref_file.name)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
status = data.get("status", "active")
|
|
123
|
+
self._by_id[test_id] = {"model_id": model_id, "status": status}
|
|
124
|
+
if status != "obsolete":
|
|
125
|
+
existing = self._by_model.get(model_id)
|
|
126
|
+
if existing is not None and existing != test_id:
|
|
127
|
+
# review 2026-07-06 (finding 36): two active ref files
|
|
128
|
+
# claim the same model_id. Keep the LOWEST-numbered one —
|
|
129
|
+
# deterministic, and the oldest is the established
|
|
130
|
+
# baseline — and warn loudly instead of silent last-wins.
|
|
131
|
+
keep = min(existing, test_id, key=int)
|
|
132
|
+
drop = max(existing, test_id, key=int)
|
|
133
|
+
logger.warning(
|
|
134
|
+
"Duplicate model_id %r in %s and %s — keeping the "
|
|
135
|
+
"lowest-numbered file (%s); mark the other obsolete "
|
|
136
|
+
"or delete it.",
|
|
137
|
+
model_id,
|
|
138
|
+
RefIndex.ref_filename(keep),
|
|
139
|
+
RefIndex.ref_filename(drop),
|
|
140
|
+
RefIndex.ref_filename(keep),
|
|
141
|
+
)
|
|
142
|
+
self._by_model[model_id] = keep
|
|
143
|
+
else:
|
|
144
|
+
self._by_model[model_id] = test_id
|
|
145
|
+
|
|
146
|
+
self._loaded = True
|
|
147
|
+
|
|
148
|
+
def ensure_loaded(self) -> None:
|
|
149
|
+
"""Scan now if not yet scanned. Lazy by default; `Session.open`
|
|
150
|
+
calls it before its project is live, so the UI loop — which reads
|
|
151
|
+
the index the moment the project is set — never scans (every ref
|
|
152
|
+
file read whole) and never races the open thread's scan."""
|
|
153
|
+
if not self._loaded:
|
|
154
|
+
self._scan()
|
|
155
|
+
|
|
156
|
+
def get_id(self, model_id: str) -> str | None:
|
|
157
|
+
"""Look up the numeric ID for a model. Returns None if not found."""
|
|
158
|
+
self.ensure_loaded()
|
|
159
|
+
return self._by_model.get(model_id)
|
|
160
|
+
|
|
161
|
+
def get_model_id(self, test_id: str) -> str | None:
|
|
162
|
+
"""Look up the model ID for a numeric test ID."""
|
|
163
|
+
self.ensure_loaded()
|
|
164
|
+
entry = self._by_id.get(test_id)
|
|
165
|
+
if entry and entry["status"] != "obsolete":
|
|
166
|
+
return entry["model_id"]
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def _read_high_water(self) -> int:
|
|
170
|
+
"""Return the persisted high-water mark, or 0 when there is none.
|
|
171
|
+
|
|
172
|
+
Absent / unreadable file → 0, i.e. fall back to scan-only allocation.
|
|
173
|
+
Partitions written before the high-water file existed keep working.
|
|
174
|
+
"""
|
|
175
|
+
hw_file = self.ref_dir / _ID_HIGH_WATER_FILE
|
|
176
|
+
try:
|
|
177
|
+
data = json.loads(hw_file.read_text(encoding="utf-8"))
|
|
178
|
+
return int(data["high_water"])
|
|
179
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError):
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
def _bump_high_water(self, test_id: str) -> None:
|
|
183
|
+
"""Raise the persisted high-water mark to cover ``test_id``.
|
|
184
|
+
|
|
185
|
+
Concurrent bumps are last-writer-wins on the atomic replace, so a
|
|
186
|
+
plain read-check-write could REGRESS the mark: P1 (claimed 0005)
|
|
187
|
+
reads 4, P2 (claimed 0006) writes 6, P1 overwrites with 5 (A5
|
|
188
|
+
review finding 1; a write-then-verify retry does not close it —
|
|
189
|
+
the higher writer can verify before the lower writer's overwrite).
|
|
190
|
+
Instead every bump folds in a FRESH disk scan: a competitor
|
|
191
|
+
O_EXCL-claims its ref file BEFORE bumping (register's claim
|
|
192
|
+
ordering), so any mark we could clobber corresponds to a ref file
|
|
193
|
+
already visible on disk, and writing ``max(claimed, scan, stored)``
|
|
194
|
+
makes last-writer-wins harmless.
|
|
195
|
+
"""
|
|
196
|
+
value = int(test_id)
|
|
197
|
+
try:
|
|
198
|
+
self.ref_dir.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
scan_max = max(
|
|
200
|
+
(
|
|
201
|
+
int(m.group(1))
|
|
202
|
+
for m in (
|
|
203
|
+
_REF_FILE_PATTERN.match(p.name)
|
|
204
|
+
for p in self.ref_dir.glob("ref_*.json")
|
|
205
|
+
)
|
|
206
|
+
if m
|
|
207
|
+
),
|
|
208
|
+
default=0,
|
|
209
|
+
)
|
|
210
|
+
target = max(value, scan_max)
|
|
211
|
+
if self._read_high_water() >= target:
|
|
212
|
+
return
|
|
213
|
+
_atomic_write_text(
|
|
214
|
+
self.ref_dir / _ID_HIGH_WATER_FILE,
|
|
215
|
+
json.dumps({"high_water": target}, indent=2) + "\n",
|
|
216
|
+
)
|
|
217
|
+
except OSError as e:
|
|
218
|
+
# Losing the mark only costs us the "never reused" guarantee for
|
|
219
|
+
# this partition — never a failed accept.
|
|
220
|
+
logger.warning("Could not persist test-ID high-water mark: %s", e)
|
|
221
|
+
|
|
222
|
+
def next_id(self) -> str:
|
|
223
|
+
"""Return the next available numeric ID.
|
|
224
|
+
|
|
225
|
+
IDs are never reused: the next ID clears both the highest ref file
|
|
226
|
+
currently on disk *and* the persisted high-water mark, so deleting the
|
|
227
|
+
highest-numbered ref (``manifest cleanup`` after it was marked
|
|
228
|
+
obsolete) does not hand its ID to a different model in a later run.
|
|
229
|
+
"""
|
|
230
|
+
self.ensure_loaded()
|
|
231
|
+
max_id = max((int(k) for k in self._by_id), default=0)
|
|
232
|
+
return f"{max(max_id, self._read_high_water()) + 1:04d}"
|
|
233
|
+
|
|
234
|
+
def register(self, model_id: str) -> str:
|
|
235
|
+
"""Register a model and return its ID. Reuses existing ID if already registered.
|
|
236
|
+
|
|
237
|
+
review 2026-07-06 (findings 32+33): new IDs are claimed by
|
|
238
|
+
*exclusively creating* the ref file on disk (``open(..., "x")``).
|
|
239
|
+
A stale in-memory index (e.g. after ``cleanup_obsolete`` freed the
|
|
240
|
+
highest ID) or a concurrent ``--accept`` in another process could
|
|
241
|
+
both hand out an already-taken ID — the O_EXCL create makes the
|
|
242
|
+
claim atomic; on collision we rescan and move to the next free ID
|
|
243
|
+
instead of silently overwriting another model's baseline.
|
|
244
|
+
"""
|
|
245
|
+
existing = self.get_id(model_id)
|
|
246
|
+
if existing is not None:
|
|
247
|
+
return existing
|
|
248
|
+
|
|
249
|
+
self.ref_dir.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
candidate = int(self.next_id())
|
|
251
|
+
while True:
|
|
252
|
+
test_id = f"{candidate:04d}"
|
|
253
|
+
ref_file = self.ref_dir / self.ref_filename(test_id)
|
|
254
|
+
try:
|
|
255
|
+
with open(ref_file, "x", encoding="utf-8") as f:
|
|
256
|
+
# Minimal valid claim stub — store_reference atomically
|
|
257
|
+
# replaces it with the full baseline immediately after.
|
|
258
|
+
json.dump(
|
|
259
|
+
{"model_id": model_id, "test_id": test_id, "status": "active"},
|
|
260
|
+
f,
|
|
261
|
+
)
|
|
262
|
+
except FileExistsError:
|
|
263
|
+
# ID already taken on disk (concurrent writer, or a file our
|
|
264
|
+
# stale index missed). Rescan, then move past the collision —
|
|
265
|
+
# the max() guard guarantees progress even when the colliding
|
|
266
|
+
# file is unreadable and therefore invisible to next_id().
|
|
267
|
+
self._scan()
|
|
268
|
+
claimed = self.get_id(model_id)
|
|
269
|
+
if claimed is not None:
|
|
270
|
+
return claimed # another process registered this model
|
|
271
|
+
candidate = max(candidate + 1, int(self.next_id()))
|
|
272
|
+
continue
|
|
273
|
+
self._by_id[test_id] = {"model_id": model_id, "status": "active"}
|
|
274
|
+
self._by_model[model_id] = test_id
|
|
275
|
+
self._bump_high_water(test_id)
|
|
276
|
+
return test_id
|
|
277
|
+
|
|
278
|
+
def active_tests(self) -> dict[str, str]:
|
|
279
|
+
"""Return dict of test_id -> model_id for all active (non-obsolete, non-skip) tests."""
|
|
280
|
+
self.ensure_loaded()
|
|
281
|
+
return {
|
|
282
|
+
tid: entry["model_id"]
|
|
283
|
+
for tid, entry in self._by_id.items()
|
|
284
|
+
if entry["status"] not in ("obsolete",)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
def all_tests(self) -> dict[str, dict]:
|
|
288
|
+
"""Return dict of test_id -> {model_id, status} for all tests."""
|
|
289
|
+
self.ensure_loaded()
|
|
290
|
+
return dict(self._by_id)
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def ref_filename(test_id: str) -> str:
|
|
294
|
+
"""Reference filename for a given numeric test ID."""
|
|
295
|
+
return f"ref_{test_id}.json"
|