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,493 @@
|
|
|
1
|
+
"""Applying a validated patch set to ``test_spec.json``.
|
|
2
|
+
|
|
3
|
+
The one read-modify-write both ``dstf spec-update`` and the authoring
|
|
4
|
+
Session's ``save`` go through: materialize the implicit tree where a
|
|
5
|
+
per-leaf op needs one, apply each model's ops to its entry, refuse a base
|
|
6
|
+
tolerance the patch made no number, refuse a key nothing reads, run the
|
|
7
|
+
D66 validator on every touched tree, refuse a criterion the patch left
|
|
8
|
+
unscorable (:func:`refuse_newly_unscorable` — the delta rule), then write
|
|
9
|
+
once. Nothing is written unless every group passes.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import copy
|
|
15
|
+
import json
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from ..backends.types import is_glob_pattern
|
|
23
|
+
from ..config import Config
|
|
24
|
+
from ..storage.ref_index import _atomic_write_text
|
|
25
|
+
from .patch_apply import PatchError, apply_patch_to_document, load_spec_document
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from ..storage.reference_store import ReferenceStore
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def spec_entry(data: dict, model_id: str) -> dict | None:
|
|
32
|
+
"""The first entry for ``model_id`` — first wins, as ``apply_patch`` and
|
|
33
|
+
discovery agree — or ``None``."""
|
|
34
|
+
for e in data.get("tests", []):
|
|
35
|
+
if isinstance(e, dict) and e.get("model") == model_id:
|
|
36
|
+
return e
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_literal_variable(pattern: str) -> bool:
|
|
41
|
+
"""A tracked-variable entry that names one variable. The resolver's rule:
|
|
42
|
+
``*`` and ``?`` are wildcards, a Modelica array index ``[1]`` is not."""
|
|
43
|
+
return not is_glob_pattern(pattern)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def unknown_override_modes(entry: dict) -> dict[str, str]:
|
|
47
|
+
"""``{variable: mode}`` for every flat override naming a ``mode`` no
|
|
48
|
+
metric is registered under. The flat path fails such a variable loudly
|
|
49
|
+
(a typo must not change the algorithm); ``synthesize_implicit_tree``
|
|
50
|
+
would fall back to nrmse, so a tree must not be written for it."""
|
|
51
|
+
from ..comparison.registry import metric
|
|
52
|
+
|
|
53
|
+
overrides = (entry.get("comparison") or {}).get("variable_overrides") or {}
|
|
54
|
+
return {
|
|
55
|
+
var: str(ov["mode"])
|
|
56
|
+
for var, ov in overrides.items()
|
|
57
|
+
if isinstance(ov, dict) and ov.get("mode") and metric(str(ov["mode"])) is None
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def implicit_tree_op(entry: dict, *, variables: Sequence[str] | None = None) -> dict:
|
|
62
|
+
"""The ``add /metrics`` op that materializes the implicit tree a
|
|
63
|
+
flat-override entry scores with: one leaf per tracked variable, params
|
|
64
|
+
from ``comparison.variable_overrides``, wrapped in ``and`` (see
|
|
65
|
+
``synthesize_implicit_tree``). ``variables`` replaces the entry's own
|
|
66
|
+
list: the authoring Session passes the names the flat path actually
|
|
67
|
+
scored, which for a test tracked by its ``.mo`` annotation are not in
|
|
68
|
+
the spec at all (``spec-update``, with no result in hand, can only use
|
|
69
|
+
the entry's). Raises :class:`PatchError` when there are no variables to
|
|
70
|
+
build leaves from."""
|
|
71
|
+
from ..comparison.tree_spec import spec_to_raw, synthesize_implicit_tree
|
|
72
|
+
|
|
73
|
+
variables = (
|
|
74
|
+
list(variables) if variables is not None else entry.get("variables") or []
|
|
75
|
+
)
|
|
76
|
+
if not variables:
|
|
77
|
+
raise PatchError(
|
|
78
|
+
f"cannot materialize an implicit metrics tree for "
|
|
79
|
+
f"{entry.get('model')!r}: the entry declares no tracked variables "
|
|
80
|
+
"to build leaves from"
|
|
81
|
+
)
|
|
82
|
+
globs = [v for v in variables if not is_literal_variable(v)]
|
|
83
|
+
if globs:
|
|
84
|
+
raise PatchError(
|
|
85
|
+
f"cannot materialize an implicit metrics tree for "
|
|
86
|
+
f"{entry.get('model')!r}: {globs!r} are patterns, and a leaf per "
|
|
87
|
+
"pattern would score a sentinel where the flat path scored the "
|
|
88
|
+
"variables the result resolved"
|
|
89
|
+
)
|
|
90
|
+
bad_modes = unknown_override_modes(entry)
|
|
91
|
+
if bad_modes:
|
|
92
|
+
raise PatchError(
|
|
93
|
+
f"cannot materialize an implicit metrics tree for "
|
|
94
|
+
f"{entry.get('model')!r}: {bad_modes!r} name a mode no metric is "
|
|
95
|
+
"registered under; the flat path fails them, a tree would score nrmse"
|
|
96
|
+
)
|
|
97
|
+
overrides = (entry.get("comparison") or {}).get("variable_overrides") or {}
|
|
98
|
+
tree = synthesize_implicit_tree(variables, variable_overrides=overrides)
|
|
99
|
+
# A copy: a leaf's params are its override's own values (a `points`
|
|
100
|
+
# table, a tolerance pair), and an op that shared them with the entry it
|
|
101
|
+
# was read from would change when a later op edits that override.
|
|
102
|
+
return {"op": "add", "path": "/metrics", "value": copy.deepcopy(spec_to_raw(tree))}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def materialize_implicit_metrics(
|
|
106
|
+
entry: dict | None, patch: Sequence[dict]
|
|
107
|
+
) -> list[dict]:
|
|
108
|
+
"""``patch`` with :func:`implicit_tree_op` prepended when some op edits
|
|
109
|
+
under ``/metrics/`` and neither the entry nor the patch itself supplies
|
|
110
|
+
the tree.
|
|
111
|
+
|
|
112
|
+
review 2026-07-06 finding 41: per-leaf ops target
|
|
113
|
+
``/metrics/children/N/...``, but a flat-override entry has no ``metrics``
|
|
114
|
+
block and ``patch_apply`` refuses to auto-create an array parent. A
|
|
115
|
+
missing entry is left to ``apply_patch`` (which creates it and then
|
|
116
|
+
reports the real failure)."""
|
|
117
|
+
ops = list(patch)
|
|
118
|
+
if not needs_materialization(entry, ops):
|
|
119
|
+
return ops
|
|
120
|
+
assert entry is not None
|
|
121
|
+
return [implicit_tree_op(entry), *ops]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def has_metrics(entry: dict | None) -> bool:
|
|
125
|
+
"""Whether ``entry`` has a ``metrics`` tree of its own. ``"metrics":
|
|
126
|
+
null`` is none — and is no entry a file may hold: the parser refuses it,
|
|
127
|
+
and so does every write (:func:`validate_entry_metric_tree`). Every "has
|
|
128
|
+
it a tree" question — here and in the authoring Session — asks this,
|
|
129
|
+
and none tests the key: it is asked of a patched entry before that
|
|
130
|
+
entry is checked."""
|
|
131
|
+
return entry is not None and entry.get("metrics") is not None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def needs_materialization(entry: dict | None, patch: Sequence[dict]) -> bool:
|
|
135
|
+
"""Some op edits under ``/metrics/`` and neither the entry nor the patch
|
|
136
|
+
itself supplies the tree (a missing entry is left to ``apply_patch``)."""
|
|
137
|
+
if entry is None or has_metrics(entry):
|
|
138
|
+
return False
|
|
139
|
+
edits_under = any(
|
|
140
|
+
isinstance(op, dict) and str(op.get("path", "")).startswith("/metrics/")
|
|
141
|
+
for op in patch
|
|
142
|
+
)
|
|
143
|
+
supplies_tree = any(
|
|
144
|
+
isinstance(op, dict) and op.get("path") == "/metrics" for op in patch
|
|
145
|
+
)
|
|
146
|
+
return edits_under and not supplies_tree
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def validate_entry_metric_tree(
|
|
150
|
+
data: dict, model_id: str, config: Config, store: ReferenceStore | None = None
|
|
151
|
+
) -> None:
|
|
152
|
+
"""When the patched entry has a ``metrics`` key, parse it as the spec
|
|
153
|
+
parser does — a ``null`` there is refused, like any other value that is
|
|
154
|
+
no tree — and run the D66 validator against the reference store's
|
|
155
|
+
baseline roles. Skipped when the entry has no such key (implicit AND).
|
|
156
|
+
``store`` saves the caller that already holds one (the Session) a
|
|
157
|
+
rescan of the whole partition per call."""
|
|
158
|
+
entry = spec_entry(data, model_id)
|
|
159
|
+
if entry is None or "metrics" not in entry:
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
from ..comparison.tree_spec import MetricSpecError, parse_metric_tree
|
|
163
|
+
from ..comparison.validator import role_lookup_from_store, validate_tree
|
|
164
|
+
from ..storage.reference_store import ReferenceStore
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
tree = parse_metric_tree(entry["metrics"])
|
|
168
|
+
except MetricSpecError as e:
|
|
169
|
+
# The reason drops the path ("metrics.window: The window must end
|
|
170
|
+
# after ..."): the tool shows the reason beside the row it names.
|
|
171
|
+
raise PatchError(
|
|
172
|
+
f"patched metric tree is invalid: {e}", reason=e.message
|
|
173
|
+
) from e
|
|
174
|
+
except Exception as e:
|
|
175
|
+
raise PatchError(f"patched metric tree is invalid: {e}", reason=str(e)) from e
|
|
176
|
+
|
|
177
|
+
if store is None:
|
|
178
|
+
store = ReferenceStore(config)
|
|
179
|
+
lookup = role_lookup_from_store(store, model_id)
|
|
180
|
+
errors = validate_tree(tree, lookup)
|
|
181
|
+
if errors:
|
|
182
|
+
msg = "; ".join(str(e) for e in errors)
|
|
183
|
+
raise PatchError(
|
|
184
|
+
f"validator rejected the patched tree: {msg}",
|
|
185
|
+
# Each message is a sentence that ends in its own full stop.
|
|
186
|
+
reason=" ".join(e.message for e in errors),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class UnscorableCriterion(PatchError):
|
|
191
|
+
"""A write refused by :func:`refuse_newly_unscorable`. ``reason`` is the
|
|
192
|
+
criterion and what it lacks — ``range · pipe.T[1]: needs at least one of
|
|
193
|
+
min_value / max_value`` — and ``str()`` leads with where it is."""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True)
|
|
197
|
+
class Unscorable:
|
|
198
|
+
"""One criterion of an entry that can never be scored."""
|
|
199
|
+
|
|
200
|
+
#: What it IS — its metric, variable, baseline, window and params as
|
|
201
|
+
#: sorted JSON — and nothing of where: the same text for a flat
|
|
202
|
+
#: override and for the leaf its entry's implicit tree gives it.
|
|
203
|
+
content: str
|
|
204
|
+
#: Where it is, as a parse error says it: ``metrics.children[1]``.
|
|
205
|
+
place: str
|
|
206
|
+
#: ``range · pipe.T[1]: needs at least one of min_value / max_value``.
|
|
207
|
+
reason: str
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _dotted(pointer: str) -> str:
|
|
211
|
+
"""A leaf's JSON-pointer path as the spec parser names a node:
|
|
212
|
+
``/metrics/children/3/children/0`` → ``metrics.children[3].children[0]``."""
|
|
213
|
+
parts = pointer.strip("/").split("/")
|
|
214
|
+
out = parts[0]
|
|
215
|
+
for part in parts[1:]:
|
|
216
|
+
out += f"[{part}]" if part.isdigit() else f".{part}"
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def unscorable_criteria(entry: dict | None) -> list[Unscorable]:
|
|
221
|
+
"""Every criterion of ``entry`` whose mode cannot be built, as the
|
|
222
|
+
comparison builds it: a ``range`` with neither bound, a ``scalar`` with
|
|
223
|
+
no quantity, a tolerance that is no number or pair. Each parses, and
|
|
224
|
+
then fails at every compare.
|
|
225
|
+
|
|
226
|
+
What SCORES is what is looked at. With a ``metrics`` tree that is its
|
|
227
|
+
leaves, and the flat ``comparison.variable_overrides`` are not checked
|
|
228
|
+
at all: under a tree they score nothing (``comparator``: the tree
|
|
229
|
+
"fully replaces" them), so one that could not be built there fails
|
|
230
|
+
nothing — the tool itself leaves the overrides beside the tree it
|
|
231
|
+
writes out. Without a tree it is the overrides. A tree that does not
|
|
232
|
+
parse and an override naming a mode nothing is registered under are
|
|
233
|
+
said by their own checks, not here."""
|
|
234
|
+
from ..comparison.modes import resolve_mode
|
|
235
|
+
from ..comparison.registry import DEFAULT_METRIC
|
|
236
|
+
from ..comparison.tree_eval import build_leaf_mode
|
|
237
|
+
from ..comparison.tree_spec import (
|
|
238
|
+
LeafSpec,
|
|
239
|
+
MetricSpecError,
|
|
240
|
+
_walk_leaves_with_paths,
|
|
241
|
+
parse_metric_tree,
|
|
242
|
+
spec_to_raw,
|
|
243
|
+
)
|
|
244
|
+
from ..comparison.types import DEFAULT_TOLERANCE
|
|
245
|
+
|
|
246
|
+
def found(leaf: LeafSpec, place: str, why: Exception) -> Unscorable:
|
|
247
|
+
return Unscorable(
|
|
248
|
+
content=json.dumps(spec_to_raw(leaf), sort_keys=True, default=str),
|
|
249
|
+
place=place,
|
|
250
|
+
reason=f"{leaf.metric} · {leaf.variable}: {why}",
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if entry is None:
|
|
254
|
+
return []
|
|
255
|
+
out: list[Unscorable] = []
|
|
256
|
+
if has_metrics(entry):
|
|
257
|
+
try:
|
|
258
|
+
tree = parse_metric_tree(entry["metrics"])
|
|
259
|
+
except (MetricSpecError, ValueError, TypeError, AttributeError):
|
|
260
|
+
return []
|
|
261
|
+
for leaf, path in _walk_leaves_with_paths(tree, "/metrics"):
|
|
262
|
+
try:
|
|
263
|
+
build_leaf_mode(leaf, DEFAULT_TOLERANCE)
|
|
264
|
+
except ValueError as e:
|
|
265
|
+
out.append(found(leaf, _dotted(path), e))
|
|
266
|
+
return out
|
|
267
|
+
comparison = entry.get("comparison")
|
|
268
|
+
overrides = (
|
|
269
|
+
comparison.get("variable_overrides") if isinstance(comparison, dict) else None
|
|
270
|
+
)
|
|
271
|
+
if not isinstance(overrides, dict):
|
|
272
|
+
return []
|
|
273
|
+
unknown = unknown_override_modes(entry)
|
|
274
|
+
for variable, override in overrides.items():
|
|
275
|
+
if not isinstance(override, dict) or variable in unknown:
|
|
276
|
+
continue # a mode nothing is registered under fails at compare
|
|
277
|
+
try:
|
|
278
|
+
resolve_mode(override, override.get("tolerance", DEFAULT_TOLERANCE))
|
|
279
|
+
except ValueError as e:
|
|
280
|
+
# As the leaf `synthesize_implicit_tree` makes of it, so that a
|
|
281
|
+
# patch which writes the entry's tree out changes nothing here.
|
|
282
|
+
params = {k: v for k, v in override.items() if k != "mode"}
|
|
283
|
+
leaf = LeafSpec(
|
|
284
|
+
str(override.get("mode") or DEFAULT_METRIC), variable, params
|
|
285
|
+
)
|
|
286
|
+
place = f"comparison.variable_overrides[{variable}]"
|
|
287
|
+
out.append(found(leaf, place, e))
|
|
288
|
+
return out
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
#: The key a leaf of the entry AS IT WAS is marked with, on a copy the
|
|
292
|
+
#: patch is replayed over (:func:`_places_after`): its value is the place it
|
|
293
|
+
#: had. No spec key (a NUL), and never in a document that is kept.
|
|
294
|
+
_WAS = "\0was"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _leaf_dicts(entry: dict | None) -> dict[str, dict]:
|
|
298
|
+
"""``{place: the raw dict}`` of the leaves of the entry's ``metrics``
|
|
299
|
+
tree, places spelled as :func:`unscorable_criteria` spells them
|
|
300
|
+
(``metrics.children[1]``); none for an entry with no tree. Whatever is
|
|
301
|
+
no object is passed over: it is no criterion."""
|
|
302
|
+
out: dict[str, dict] = {}
|
|
303
|
+
if not isinstance(entry, dict):
|
|
304
|
+
return out
|
|
305
|
+
|
|
306
|
+
def walk(node: object, pointer: str) -> None:
|
|
307
|
+
if not isinstance(node, dict):
|
|
308
|
+
return
|
|
309
|
+
children = node.get("children")
|
|
310
|
+
if "combinator" in node and isinstance(children, list):
|
|
311
|
+
for i, child in enumerate(children):
|
|
312
|
+
walk(child, f"{pointer}/children/{i}")
|
|
313
|
+
elif "metric" in node:
|
|
314
|
+
out[_dotted(pointer)] = node
|
|
315
|
+
|
|
316
|
+
walk(entry.get("metrics"), "/metrics")
|
|
317
|
+
return out
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _places_after(
|
|
321
|
+
before: dict | None, ops: Sequence[dict], model_id: str
|
|
322
|
+
) -> dict[str, str]:
|
|
323
|
+
"""``{place after the patch: the place it had before}`` for every leaf
|
|
324
|
+
of ``before``'s tree that is still a leaf of the entry's tree once
|
|
325
|
+
``ops`` are applied: the same object, wherever it is now and whatever
|
|
326
|
+
was added to it or taken from it. A leaf an op removed, or wrote over
|
|
327
|
+
whole, is in no place after; one the patch added had no place before.
|
|
328
|
+
|
|
329
|
+
Found by replaying the patch over a marked copy: an op names a path,
|
|
330
|
+
so a mark inside a leaf rides with it through every op that does not
|
|
331
|
+
replace that leaf.
|
|
332
|
+
|
|
333
|
+
A flat override is not followed. It cannot move: its place is its
|
|
334
|
+
variable, and its content names that variable, so an equal one can
|
|
335
|
+
only stand where it stood — kept or written over, the count in
|
|
336
|
+
:func:`refuse_newly_unscorable` says the same of it."""
|
|
337
|
+
if before is None:
|
|
338
|
+
return {}
|
|
339
|
+
marked = copy.deepcopy(before)
|
|
340
|
+
for place, leaf in _leaf_dicts(marked).items():
|
|
341
|
+
leaf[_WAS] = place
|
|
342
|
+
document = {"tests": [marked]}
|
|
343
|
+
apply_patch_to_document(document, model_id, list(ops))
|
|
344
|
+
after = spec_entry(document, model_id)
|
|
345
|
+
return {
|
|
346
|
+
place: leaf[_WAS] for place, leaf in _leaf_dicts(after).items() if _WAS in leaf
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def refuse_newly_unscorable(
|
|
351
|
+
before: dict | None, ops: Sequence[dict], after: dict | None, model_id: str
|
|
352
|
+
) -> None:
|
|
353
|
+
"""THE rule for a criterion that can never be scored, applied where a
|
|
354
|
+
FILE is written (``spec-update`` and the tool's Save, both through
|
|
355
|
+
:func:`apply_validated_patches`): the write is refused only for a
|
|
356
|
+
criterion that is unscorable in ``after`` — ``before`` with ``ops``
|
|
357
|
+
applied — and was not already there, with the same content, in
|
|
358
|
+
``before``.
|
|
359
|
+
|
|
360
|
+
So an entry that already holds one — written by hand, or by an earlier
|
|
361
|
+
version of the tool — takes every other edit, the edit that fills it
|
|
362
|
+
and the edit that removes it; two of them do not block each other. A
|
|
363
|
+
patch that adds one, or edits one and leaves it unscorable, is refused:
|
|
364
|
+
that is the write that could know better.
|
|
365
|
+
|
|
366
|
+
"Already there" is said of the criterion, not of a count. One that is
|
|
367
|
+
still the object it was (:func:`_places_after`) with the content it had
|
|
368
|
+
is the one that was there, wherever a removed sibling has shifted it.
|
|
369
|
+
Any other unscorable criterion of ``after`` — one the patch added, or
|
|
370
|
+
wrote over — is the one that was there only if an equal one of
|
|
371
|
+
``before`` is GONE from the criteria that score: a move (a remove and
|
|
372
|
+
an add), a leaf wrapped in a group, the entry's implicit tree written
|
|
373
|
+
out over its flat override. Counted alone — which this replaces — a
|
|
374
|
+
patch that filled the bare ``range`` an entry held and added an equal
|
|
375
|
+
bare one beside it was written: one before, one after.
|
|
376
|
+
|
|
377
|
+
Refusing every unscorable criterion of the patched entry — the rule
|
|
378
|
+
before that — refused the patch that fixed one of two, and every edit
|
|
379
|
+
of an entry that held one it did not touch."""
|
|
380
|
+
found = unscorable_criteria(after)
|
|
381
|
+
if not found:
|
|
382
|
+
return
|
|
383
|
+
was = {item.place: item.content for item in unscorable_criteria(before)}
|
|
384
|
+
came_from = _places_after(before, ops, model_id)
|
|
385
|
+
# The leaves of `before` that are still leaves of `after`. Every other
|
|
386
|
+
# unscorable criterion of `before` stands for one equal one of `after`:
|
|
387
|
+
# a leaf that is gone, and each flat override — its content names its
|
|
388
|
+
# variable, so the equal one can only be the one in its place.
|
|
389
|
+
still_there = set(came_from.values())
|
|
390
|
+
gone = Counter(
|
|
391
|
+
content for place, content in was.items() if place not in still_there
|
|
392
|
+
)
|
|
393
|
+
for item in found:
|
|
394
|
+
if was.get(came_from.get(item.place, "")) == item.content:
|
|
395
|
+
continue # untouched, or only shifted
|
|
396
|
+
if gone[item.content] > 0:
|
|
397
|
+
gone[item.content] -= 1
|
|
398
|
+
continue # taken out and put back: moved, wrapped, written out
|
|
399
|
+
raise UnscorableCriterion(
|
|
400
|
+
f"patched entry is invalid: tests[{model_id}].{item.place}: {item.reason}",
|
|
401
|
+
reason=item.reason,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def validate_entry_keys(data: dict, model_id: str) -> None:
|
|
406
|
+
"""The patched entry holds no key nothing reads (``check_entry_keys``,
|
|
407
|
+
the spec parser's own check)."""
|
|
408
|
+
from .spec_parser import EntryKeyError, check_entry_keys
|
|
409
|
+
|
|
410
|
+
entry = spec_entry(data, model_id)
|
|
411
|
+
if entry is None:
|
|
412
|
+
return
|
|
413
|
+
try:
|
|
414
|
+
check_entry_keys(entry, model_id)
|
|
415
|
+
except EntryKeyError as e:
|
|
416
|
+
raise PatchError(f"patched entry is invalid: {e}", reason=e.message) from e
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _base_tolerance(entry: dict | None) -> object:
|
|
420
|
+
"""The entry's ``comparison.tolerance`` as written, or ``None``."""
|
|
421
|
+
comparison = (entry or {}).get("comparison")
|
|
422
|
+
return comparison.get("tolerance") if isinstance(comparison, dict) else None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _same_base(before: object, after: object) -> bool:
|
|
426
|
+
"""Whether the patch left the base as it was: the same JSON text, keys
|
|
427
|
+
sorted. Not ``==``: Python's ``True == 1`` at any depth (``[1] → [true]``
|
|
428
|
+
is a change), and a NaN is not equal to itself (writing the NaN already
|
|
429
|
+
there is none) — as text, a bool is ``true`` and a NaN is ``NaN``."""
|
|
430
|
+
|
|
431
|
+
def text(value: object) -> str:
|
|
432
|
+
return json.dumps(value, sort_keys=True, allow_nan=True)
|
|
433
|
+
|
|
434
|
+
return text(before) == text(after)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def validate_entry_base_tolerance(data: dict, model_id: str, before: object) -> None:
|
|
438
|
+
"""The patched entry's base tolerance is ONE finite number, or unset —
|
|
439
|
+
when the patch changed it from ``before``. The comparison refuses a test
|
|
440
|
+
whose base is anything else; a patch must not write one. One already
|
|
441
|
+
there is the comparison's to refuse, not this patch's: refusing it here
|
|
442
|
+
would leave no patch able to edit the entry, or remove it."""
|
|
443
|
+
from ..comparison.sides import base_tolerance_refused, is_base_tolerance
|
|
444
|
+
|
|
445
|
+
after = _base_tolerance(spec_entry(data, model_id))
|
|
446
|
+
if after is None or is_base_tolerance(after) or _same_base(before, after):
|
|
447
|
+
return
|
|
448
|
+
text = base_tolerance_refused("test_spec.json comparison.tolerance", after)
|
|
449
|
+
raise PatchError(text, reason=text)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def validate_entry_plots(data: dict, model_id: str) -> None:
|
|
453
|
+
"""The patched entry's ``plots`` extras, when it has the key, parse."""
|
|
454
|
+
from .plots import PlotsSpecError, parse_plots
|
|
455
|
+
|
|
456
|
+
entry = spec_entry(data, model_id)
|
|
457
|
+
if entry is None or "plots" not in entry:
|
|
458
|
+
return
|
|
459
|
+
try:
|
|
460
|
+
parse_plots(entry["plots"], path=f"tests[{model_id}].plots")
|
|
461
|
+
except PlotsSpecError as e:
|
|
462
|
+
raise PatchError(f"patched plots are invalid: {e}", reason=str(e)) from e
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def apply_validated_patches(
|
|
466
|
+
spec_path: Path,
|
|
467
|
+
groups: Sequence[tuple[str, Sequence[dict]]],
|
|
468
|
+
config: Config,
|
|
469
|
+
store: ReferenceStore | None = None,
|
|
470
|
+
) -> list[tuple[str, list[dict]]]:
|
|
471
|
+
"""Apply every group's ops to its model's entry in one read-modify-write.
|
|
472
|
+
|
|
473
|
+
Per group: materialize, apply, validate. The file is written once,
|
|
474
|
+
atomically and LF (D122), only after every group passed. Returns the ops
|
|
475
|
+
actually applied per group — materialization included — so a caller can
|
|
476
|
+
say what it did."""
|
|
477
|
+
if not groups:
|
|
478
|
+
return [] # nothing to apply; the file is not rewritten
|
|
479
|
+
data = load_spec_document(spec_path)
|
|
480
|
+
applied: list[tuple[str, list[dict]]] = []
|
|
481
|
+
for model_id, ops in groups:
|
|
482
|
+
full = materialize_implicit_metrics(spec_entry(data, model_id), ops)
|
|
483
|
+
was = copy.deepcopy(spec_entry(data, model_id))
|
|
484
|
+
apply_patch_to_document(data, model_id, full)
|
|
485
|
+
validate_entry_base_tolerance(data, model_id, _base_tolerance(was))
|
|
486
|
+
validate_entry_keys(data, model_id)
|
|
487
|
+
validate_entry_metric_tree(data, model_id, config, store)
|
|
488
|
+
refuse_newly_unscorable(was, full, spec_entry(data, model_id), model_id)
|
|
489
|
+
validate_entry_plots(data, model_id)
|
|
490
|
+
applied.append((model_id, full))
|
|
491
|
+
spec_path.parent.mkdir(parents=True, exist_ok=True)
|
|
492
|
+
_atomic_write_text(spec_path, json.dumps(data, indent=2) + "\n")
|
|
493
|
+
return applied
|