vnnlib-test-solver 2.0.0__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.
- vnnlib_test_solver/__init__.py +1 -0
- vnnlib_test_solver/assignments.py +163 -0
- vnnlib_test_solver/cli.py +504 -0
- vnnlib_test_solver/config.py +130 -0
- vnnlib_test_solver/dtypes.py +67 -0
- vnnlib_test_solver/errors.py +30 -0
- vnnlib_test_solver/injection.py +119 -0
- vnnlib_test_solver/py.typed +0 -0
- vnnlib_test_solver/querymodel.py +250 -0
- vnnlib_test_solver/rules.py +119 -0
- vnnlib_test_solver/spec.py +79 -0
- vnnlib_test_solver/supports.py +151 -0
- vnnlib_test_solver/validation.py +495 -0
- vnnlib_test_solver/verify.py +417 -0
- vnnlib_test_solver-2.0.0.dist-info/METADATA +176 -0
- vnnlib_test_solver-2.0.0.dist-info/RECORD +19 -0
- vnnlib_test_solver-2.0.0.dist-info/WHEEL +4 -0
- vnnlib_test_solver-2.0.0.dist-info/entry_points.txt +2 -0
- vnnlib_test_solver-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""Schema validation for a parsed configuration file.
|
|
2
|
+
|
|
3
|
+
Deliberately a separate pass from loading: TOML syntax and configuration meaning fail
|
|
4
|
+
for different reasons and must produce different messages. Every message names the
|
|
5
|
+
offending key and, where a value comes from a closed set, the legal set.
|
|
6
|
+
|
|
7
|
+
Values drawn from a closed set need no shape checking, because membership settles it.
|
|
8
|
+
Values a configuration author writes freely do: the response formats in section 5.4 are
|
|
9
|
+
line- and space-delimited, so a stray newline or space inside a value silently changes
|
|
10
|
+
how many answers a consumer reads. Those checks live in ``_reject_line_break`` and
|
|
11
|
+
``_reject_whitespace``, and each says what the consumer would otherwise see.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from .errors import ConfigError
|
|
20
|
+
from .injection import KEYS as INJECTION_KEYS
|
|
21
|
+
from .spec import (
|
|
22
|
+
ARITHMETIC_COMPLEXITY_THEORIES,
|
|
23
|
+
ELEMENT_TYPES,
|
|
24
|
+
HIDDEN_NODE_THEORIES,
|
|
25
|
+
MULTIPLE_INPUT_OUTPUT_THEORIES,
|
|
26
|
+
MULTIPLE_NETWORK_THEORIES,
|
|
27
|
+
MULTIPLE_NODE_COMPARISON_THEORIES,
|
|
28
|
+
REAL,
|
|
29
|
+
RESULTS,
|
|
30
|
+
as_list,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
TOP_LEVEL_KEYS = frozenset({"solver", "capabilities", "soundness", "rules", "injection"})
|
|
34
|
+
SOLVER_KEYS = frozenset({"name", "version"})
|
|
35
|
+
SOUNDNESS_KEYS = frozenset({"sound-for"})
|
|
36
|
+
RULE_KEYS = (
|
|
37
|
+
frozenset({"match", "result", "assignments", "model_element_types", "delay_seconds"})
|
|
38
|
+
| INJECTION_KEYS
|
|
39
|
+
)
|
|
40
|
+
"""The keys a ``[[rules]]`` entry may carry.
|
|
41
|
+
|
|
42
|
+
Built from the injection key set rather than repeating its four names, so the
|
|
43
|
+
per-rule schema and the ``[injection]`` schema cannot come to disagree about
|
|
44
|
+
which controls exist. ``delay_seconds`` is deliberately not one of them: it is
|
|
45
|
+
about how a deadline is met, which only ``verify`` has.
|
|
46
|
+
"""
|
|
47
|
+
OPERATOR_KEYS = frozenset({"name", "element-types"})
|
|
48
|
+
|
|
49
|
+
MAX_EXIT_CODE = 255
|
|
50
|
+
"""The largest exit status a configuration may ask for.
|
|
51
|
+
|
|
52
|
+
A process exit status is not an arbitrary integer. On a Unix-like system it
|
|
53
|
+
carries **eight bits**: the parent reads it through ``WEXITSTATUS``, so 256
|
|
54
|
+
arrives as 0 and a configuration asking a run to fail would report that it
|
|
55
|
+
succeeded. Windows carries the full 32 bits, which is why this is invisible on
|
|
56
|
+
a Windows machine - measured, not assumed, and measured only because the
|
|
57
|
+
consuming process runner is POSIX-only and Windows is where this was written.
|
|
58
|
+
|
|
59
|
+
Bounded to the range every platform can represent, for the reason
|
|
60
|
+
``MAX_WAIT_SECONDS`` is bounded below the platform's own ceiling: a
|
|
61
|
+
configuration that means one thing on one machine and the opposite on another
|
|
62
|
+
is not a baseline. Refusing a value out of range turns a silent lie into a
|
|
63
|
+
message.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
MAX_WAIT_SECONDS = 2**31 - 1
|
|
67
|
+
"""The longest wait this package will accept, in seconds - about 68 years.
|
|
68
|
+
|
|
69
|
+
``time.sleep`` raises ``OverflowError`` above roughly 2**63 nanoseconds, and the
|
|
70
|
+
exact ceiling is a property of the platform's clock rather than of anything the
|
|
71
|
+
standard says. Picking a fixed bound well below it keeps the refusal identical on
|
|
72
|
+
every machine, which an inherited platform limit would not: a configuration that
|
|
73
|
+
loads on one machine and raises on another is not a baseline.
|
|
74
|
+
|
|
75
|
+
It lives here rather than with the code that waits because the configuration's
|
|
76
|
+
``delay_seconds`` and the command line's ``--timeout`` both need it, and this is
|
|
77
|
+
the module both can import without a cycle.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
THEORY_KEYS = {
|
|
81
|
+
"hidden-node-theories": HIDDEN_NODE_THEORIES,
|
|
82
|
+
"multiple-input-output-theories": MULTIPLE_INPUT_OUTPUT_THEORIES,
|
|
83
|
+
"multiple-network-theories": MULTIPLE_NETWORK_THEORIES,
|
|
84
|
+
"multiple-node-comparison-theories": MULTIPLE_NODE_COMPARISON_THEORIES,
|
|
85
|
+
"arithmetic-complexity-theories": ARITHMETIC_COMPLEXITY_THEORIES,
|
|
86
|
+
}
|
|
87
|
+
VERSION_PAIR_KEYS = ("onnx-opset-versions", "vnnlib-versions")
|
|
88
|
+
BOOLEAN_KEYS = ("optimised-disjunctive-reasoning", "serialise-assignments")
|
|
89
|
+
CAPABILITY_KEYS = (
|
|
90
|
+
frozenset(THEORY_KEYS)
|
|
91
|
+
| frozenset(VERSION_PAIR_KEYS)
|
|
92
|
+
| frozenset(BOOLEAN_KEYS)
|
|
93
|
+
| frozenset({"onnx-element-types", "onnx-operators"})
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def validate(raw: Mapping[str, Any]) -> None:
|
|
98
|
+
"""Raise ConfigError on the first schema violation found."""
|
|
99
|
+
_reject_unknown_keys(raw, TOP_LEVEL_KEYS, "top level")
|
|
100
|
+
_validate_solver(raw)
|
|
101
|
+
_validate_capabilities(raw)
|
|
102
|
+
_validate_soundness(raw)
|
|
103
|
+
_validate_injection(raw)
|
|
104
|
+
_validate_rules(raw)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def soundness_warnings(
|
|
108
|
+
capabilities: Mapping[str, Any], soundness: Mapping[str, Any]
|
|
109
|
+
) -> tuple[str, ...]:
|
|
110
|
+
"""Report element types reported without a matching claim of sound analysis.
|
|
111
|
+
|
|
112
|
+
Section 5.4.1 asks a solver to report an element type only where there is
|
|
113
|
+
strong reason to believe its analysis is sound for that type, and to report
|
|
114
|
+
only ``real`` otherwise. This is a warning and not an error, because a
|
|
115
|
+
configuration claiming what it cannot soundly analyse is exactly the
|
|
116
|
+
condition callers need to be able to trigger on purpose.
|
|
117
|
+
|
|
118
|
+
**``real`` is exempt, and reporting it is never warned about.** It is not one
|
|
119
|
+
claim among the twenty-one: it is what section 5.4.1 tells a solver to report
|
|
120
|
+
*instead* of the others when it cannot promise sound reasoning over floating
|
|
121
|
+
point at all. A configuration reporting ``real`` alone and claiming soundness
|
|
122
|
+
for nothing is doing exactly what the clause asks, so warning at it would be
|
|
123
|
+
warning about the safe choice - and the noise would fall on precisely the
|
|
124
|
+
configurations that were most careful.
|
|
125
|
+
|
|
126
|
+
The reasoning behind that, which is the part worth not re-deriving: networks
|
|
127
|
+
run over floating point, floating point is far harder to reason about than
|
|
128
|
+
real arithmetic, and many solvers simply assume real arithmetic instead. That
|
|
129
|
+
assumption is unsound and can return a wrong answer on a floating-point edge
|
|
130
|
+
case, so the standard added ``real`` as the way a solver declares it is doing
|
|
131
|
+
that. A solver that cannot reason soundly over floating point should report
|
|
132
|
+
``real`` and nothing else - and must not report ``float32`` or ``float64``,
|
|
133
|
+
which is the case this warning exists to catch.
|
|
134
|
+
|
|
135
|
+
Returned rather than printed, so the validation pass stays free of output and
|
|
136
|
+
can be tested without capturing a stream. Order follows the configuration, so
|
|
137
|
+
the message is identical between runs.
|
|
138
|
+
"""
|
|
139
|
+
reported = capabilities.get("onnx-element-types", [])
|
|
140
|
+
claimed = set(soundness.get("sound-for", []))
|
|
141
|
+
unclaimed = [name for name in reported if name != REAL and name not in claimed]
|
|
142
|
+
if not unclaimed:
|
|
143
|
+
return ()
|
|
144
|
+
return (
|
|
145
|
+
f"[capabilities] onnx-element-types reports {', '.join(unclaimed)}, which "
|
|
146
|
+
f"[soundness] sound-for does not claim; section 5.4.1 asks a solver to "
|
|
147
|
+
f"report only element types it can analyse soundly",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _reject_unknown_keys(
|
|
152
|
+
table: Mapping[str, Any], permitted: frozenset[str], where: str
|
|
153
|
+
) -> None:
|
|
154
|
+
unknown = sorted(set(table) - permitted)
|
|
155
|
+
if unknown:
|
|
156
|
+
raise ConfigError(
|
|
157
|
+
f"unknown configuration key {unknown[0]!r} at {where}; "
|
|
158
|
+
f"permitted keys are {as_list(permitted)}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _require_table(raw: Mapping[str, Any], key: str) -> Mapping[str, Any]:
|
|
163
|
+
value = raw.get(key)
|
|
164
|
+
if value is None:
|
|
165
|
+
raise ConfigError(f"missing required configuration table [{key}]")
|
|
166
|
+
if not isinstance(value, dict):
|
|
167
|
+
raise ConfigError(f"[{key}] must be a table, not {_kind(value)}")
|
|
168
|
+
return value
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _validate_solver(raw: Mapping[str, Any]) -> None:
|
|
172
|
+
solver = _require_table(raw, "solver")
|
|
173
|
+
_reject_unknown_keys(solver, SOLVER_KEYS, "[solver]")
|
|
174
|
+
for key in sorted(SOLVER_KEYS):
|
|
175
|
+
if key not in solver:
|
|
176
|
+
raise ConfigError(f"[solver] is missing required key {key!r}")
|
|
177
|
+
if not isinstance(solver[key], str):
|
|
178
|
+
raise ConfigError(
|
|
179
|
+
f"[solver] {key} must be a string, not {_kind(solver[key])}"
|
|
180
|
+
)
|
|
181
|
+
if not solver[key]:
|
|
182
|
+
raise ConfigError(f"[solver] {key} must not be empty")
|
|
183
|
+
# Spaces are allowed here and nowhere else: section 5.2 says the name may
|
|
184
|
+
# contain them. A line break is still refused, because the answer is one line.
|
|
185
|
+
_reject_line_break(solver[key], f"[solver] {key}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _validate_capabilities(raw: Mapping[str, Any]) -> None:
|
|
189
|
+
if "capabilities" not in raw:
|
|
190
|
+
return
|
|
191
|
+
capabilities = _require_table(raw, "capabilities")
|
|
192
|
+
_reject_unknown_keys(capabilities, CAPABILITY_KEYS, "[capabilities]")
|
|
193
|
+
|
|
194
|
+
for key, legal in sorted(THEORY_KEYS.items()):
|
|
195
|
+
for identifier in _string_list(capabilities, key, "[capabilities]"):
|
|
196
|
+
if identifier not in legal:
|
|
197
|
+
raise ConfigError(
|
|
198
|
+
f"[capabilities] {key} contains unknown theory identifier "
|
|
199
|
+
f"{identifier!r}; the standard defines {as_list(legal)}"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
for element_type in _string_list(
|
|
203
|
+
capabilities, "onnx-element-types", "[capabilities]"
|
|
204
|
+
):
|
|
205
|
+
if element_type not in ELEMENT_TYPES:
|
|
206
|
+
raise ConfigError(
|
|
207
|
+
f"[capabilities] onnx-element-types contains unknown element type "
|
|
208
|
+
f"{element_type!r}; the standard defines {as_list(ELEMENT_TYPES)}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
for key in VERSION_PAIR_KEYS:
|
|
212
|
+
versions = _string_list(capabilities, key, "[capabilities]")
|
|
213
|
+
if key in capabilities and len(versions) != 2:
|
|
214
|
+
raise ConfigError(
|
|
215
|
+
f"[capabilities] {key} must be exactly two version strings, "
|
|
216
|
+
f"minimum then maximum; found {len(versions)}"
|
|
217
|
+
)
|
|
218
|
+
for version in versions:
|
|
219
|
+
if not version:
|
|
220
|
+
raise ConfigError(
|
|
221
|
+
f"[capabilities] {key} must not contain an empty version; the "
|
|
222
|
+
f"response is two lines, and an empty one is a blank line that "
|
|
223
|
+
f"a consumer reads as a malformed response rather than as a "
|
|
224
|
+
f"version"
|
|
225
|
+
)
|
|
226
|
+
# A version identifier is one whole line of the response, so anything
|
|
227
|
+
# that splits or pads a line changes what the consumer reads back.
|
|
228
|
+
_reject_whitespace(version, f"[capabilities] {key}")
|
|
229
|
+
|
|
230
|
+
for key in BOOLEAN_KEYS:
|
|
231
|
+
if key in capabilities and not isinstance(capabilities[key], bool):
|
|
232
|
+
raise ConfigError(
|
|
233
|
+
f"[capabilities] {key} must be true or false, "
|
|
234
|
+
f"not {_kind(capabilities[key])}"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
_validate_operators(capabilities)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _validate_operators(capabilities: Mapping[str, Any]) -> None:
|
|
241
|
+
operators = capabilities.get("onnx-operators", [])
|
|
242
|
+
if not isinstance(operators, list):
|
|
243
|
+
raise ConfigError(
|
|
244
|
+
f"[capabilities] onnx-operators must be an array of tables, "
|
|
245
|
+
f"not {_kind(operators)}"
|
|
246
|
+
)
|
|
247
|
+
for index, operator in enumerate(operators):
|
|
248
|
+
where = f"[[capabilities.onnx-operators]] entry {index + 1}"
|
|
249
|
+
if not isinstance(operator, dict):
|
|
250
|
+
raise ConfigError(f"{where} must be a table, not {_kind(operator)}")
|
|
251
|
+
_reject_unknown_keys(operator, OPERATOR_KEYS, where)
|
|
252
|
+
name = operator.get("name")
|
|
253
|
+
if not isinstance(name, str) or not name:
|
|
254
|
+
raise ConfigError(f"{where} is missing a non-empty 'name'")
|
|
255
|
+
# The response puts the operator name and its element types on one line,
|
|
256
|
+
# separated by spaces. Whitespace inside the name is therefore not a
|
|
257
|
+
# cosmetic problem: it is read back as a name plus an extra element type,
|
|
258
|
+
# or as two operators, and nothing anywhere reports an error.
|
|
259
|
+
_reject_whitespace(name, f"{where} name")
|
|
260
|
+
for element_type in _string_list(operator, "element-types", where):
|
|
261
|
+
if element_type not in ELEMENT_TYPES:
|
|
262
|
+
raise ConfigError(
|
|
263
|
+
f"{where} lists unknown element type {element_type!r} for operator "
|
|
264
|
+
f"{name!r}; the standard defines {as_list(ELEMENT_TYPES)}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _validate_soundness(raw: Mapping[str, Any]) -> None:
|
|
269
|
+
if "soundness" not in raw:
|
|
270
|
+
return
|
|
271
|
+
soundness = _require_table(raw, "soundness")
|
|
272
|
+
_reject_unknown_keys(soundness, SOUNDNESS_KEYS, "[soundness]")
|
|
273
|
+
for element_type in _string_list(soundness, "sound-for", "[soundness]"):
|
|
274
|
+
if element_type not in ELEMENT_TYPES:
|
|
275
|
+
raise ConfigError(
|
|
276
|
+
f"[soundness] sound-for contains unknown element type "
|
|
277
|
+
f"{element_type!r}; the standard defines {as_list(ELEMENT_TYPES)}"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _validate_injection(raw: Mapping[str, Any]) -> None:
|
|
282
|
+
"""Check the top-level table that misbehaves whichever command runs.
|
|
283
|
+
|
|
284
|
+
The same four controls a rule carries, checked by the same function, because
|
|
285
|
+
two copies of one check are two things that can come to disagree about what
|
|
286
|
+
a valid configuration is.
|
|
287
|
+
"""
|
|
288
|
+
if "injection" not in raw:
|
|
289
|
+
return
|
|
290
|
+
table = _require_table(raw, "injection")
|
|
291
|
+
_reject_unknown_keys(table, INJECTION_KEYS, "[injection]")
|
|
292
|
+
_validate_injection_keys(table, "[injection]")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _validate_rules(raw: Mapping[str, Any]) -> None:
|
|
296
|
+
rules = raw.get("rules", [])
|
|
297
|
+
if not isinstance(rules, list):
|
|
298
|
+
raise ConfigError(f"[[rules]] must be an array of tables, not {_kind(rules)}")
|
|
299
|
+
for index, rule in enumerate(rules):
|
|
300
|
+
where = f"[[rules]] entry {index + 1}"
|
|
301
|
+
if not isinstance(rule, dict):
|
|
302
|
+
raise ConfigError(f"{where} must be a table, not {_kind(rule)}")
|
|
303
|
+
_reject_unknown_keys(rule, RULE_KEYS, where)
|
|
304
|
+
|
|
305
|
+
match = rule.get("match")
|
|
306
|
+
if not isinstance(match, str) or not match:
|
|
307
|
+
raise ConfigError(f"{where} is missing a non-empty 'match' pattern")
|
|
308
|
+
|
|
309
|
+
result = rule.get("result")
|
|
310
|
+
if result is None:
|
|
311
|
+
raise ConfigError(f"{where} is missing a 'result'")
|
|
312
|
+
if result not in RESULTS:
|
|
313
|
+
raise ConfigError(
|
|
314
|
+
f"{where} has unknown result {result!r}; the standard defines "
|
|
315
|
+
f"{', '.join(RESULTS)}"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
_validate_rule_injection(rule, where)
|
|
319
|
+
_validate_assignments(rule, where)
|
|
320
|
+
_validate_model_element_types(rule, where)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _validate_rule_injection(rule: Mapping[str, Any], where: str) -> None:
|
|
324
|
+
_validate_injection_keys(rule, where)
|
|
325
|
+
if "delay_seconds" in rule and not _is_integer(rule["delay_seconds"]):
|
|
326
|
+
raise ConfigError(
|
|
327
|
+
f"{where} delay_seconds must be an integer, not "
|
|
328
|
+
f"{_kind(rule['delay_seconds'])}"
|
|
329
|
+
)
|
|
330
|
+
if rule.get("delay_seconds", 0) < 0:
|
|
331
|
+
raise ConfigError(f"{where} delay_seconds must not be negative")
|
|
332
|
+
# An unbounded delay is not merely a long wait: above the platform's clock
|
|
333
|
+
# limit the sleep raises, and an unhandled exception is a traceback on stderr
|
|
334
|
+
# rather than a message. Refused here, where it can still be explained.
|
|
335
|
+
if rule.get("delay_seconds", 0) > MAX_WAIT_SECONDS:
|
|
336
|
+
raise ConfigError(
|
|
337
|
+
f"{where} delay_seconds must be at most {MAX_WAIT_SECONDS}; "
|
|
338
|
+
f"{rule['delay_seconds']} is longer than this package can wait"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _validate_injection_keys(table: Mapping[str, Any], where: str) -> None:
|
|
343
|
+
"""Check the four deliberate-misbehaviour controls, wherever they appear.
|
|
344
|
+
|
|
345
|
+
``stderr`` and ``raw_stdout`` are exempt from the shape checks every other
|
|
346
|
+
string value goes through. Producing output a consumer cannot parse is the
|
|
347
|
+
entire purpose of these two, so refusing a line break inside one would
|
|
348
|
+
refuse the feature.
|
|
349
|
+
"""
|
|
350
|
+
if "exit_code" in table:
|
|
351
|
+
if not _is_integer(table["exit_code"]):
|
|
352
|
+
raise ConfigError(
|
|
353
|
+
f"{where} exit_code must be an integer, not {_kind(table['exit_code'])}"
|
|
354
|
+
)
|
|
355
|
+
if not 0 <= table["exit_code"] <= MAX_EXIT_CODE:
|
|
356
|
+
raise ConfigError(
|
|
357
|
+
f"{where} exit_code must be between 0 and {MAX_EXIT_CODE}; "
|
|
358
|
+
f"{table['exit_code']} is outside what a process exit status can "
|
|
359
|
+
f"carry, and on a Unix-like system it would arrive at the caller "
|
|
360
|
+
f"with its low eight bits taken - {table['exit_code'] & 0xFF} in "
|
|
361
|
+
f"this case"
|
|
362
|
+
)
|
|
363
|
+
for key in ("stderr", "raw_stdout"):
|
|
364
|
+
if key in table and not isinstance(table[key], str):
|
|
365
|
+
raise ConfigError(f"{where} {key} must be a string, not {_kind(table[key])}")
|
|
366
|
+
if "crash" in table and not isinstance(table["crash"], bool):
|
|
367
|
+
raise ConfigError(
|
|
368
|
+
f"{where} crash must be true or false, not {_kind(table['crash'])}"
|
|
369
|
+
)
|
|
370
|
+
# A process killed by a signal carries no exit status, so a configuration
|
|
371
|
+
# asking for both is asking for two things only one of which can happen.
|
|
372
|
+
# Refused rather than silently resolved: honouring one would leave the other
|
|
373
|
+
# a key that was accepted, validated and then did nothing.
|
|
374
|
+
if table.get("crash") and "exit_code" in table:
|
|
375
|
+
raise ConfigError(
|
|
376
|
+
f"{where} sets both crash and exit_code, and only one of them can "
|
|
377
|
+
f"happen: a process that terminates abnormally reports no exit status "
|
|
378
|
+
f"at all. Set one or the other"
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _validate_assignments(rule: Mapping[str, Any], where: str) -> None:
|
|
383
|
+
assignments = rule.get("assignments", {})
|
|
384
|
+
if not isinstance(assignments, dict):
|
|
385
|
+
raise ConfigError(
|
|
386
|
+
f"{where} assignments must be a table keyed by variable name, "
|
|
387
|
+
f"not {_kind(assignments)}"
|
|
388
|
+
)
|
|
389
|
+
for variable, values in sorted(assignments.items()):
|
|
390
|
+
if not isinstance(values, list):
|
|
391
|
+
raise ConfigError(
|
|
392
|
+
f"{where} assignment for {variable!r} must be an array of values, "
|
|
393
|
+
f"not {_kind(values)}"
|
|
394
|
+
)
|
|
395
|
+
if not values:
|
|
396
|
+
raise ConfigError(f"{where} assignment for {variable!r} is empty")
|
|
397
|
+
for value in values:
|
|
398
|
+
if not _is_integer(value) and not isinstance(value, float):
|
|
399
|
+
raise ConfigError(
|
|
400
|
+
f"{where} assignment for {variable!r} contains a non-numeric "
|
|
401
|
+
f"value {value!r}"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _validate_model_element_types(rule: Mapping[str, Any], where: str) -> None:
|
|
406
|
+
"""Check the types a rule claims the supplied model files expose.
|
|
407
|
+
|
|
408
|
+
This package performs no ONNX parsing and never opens a model file, so
|
|
409
|
+
section 5.3.2's second error condition has nothing to read the model's
|
|
410
|
+
types out of. The configuration states them instead, keyed by the variable
|
|
411
|
+
they belong to, and the query supplies the declared type they are compared
|
|
412
|
+
against. That is the same split the assignment values use: configuration
|
|
413
|
+
claims, the query decides what the claim is measured against, and neither
|
|
414
|
+
can silently agree with the other about something the query does not say.
|
|
415
|
+
|
|
416
|
+
``real`` is accepted here even though no ONNX model can expose it. It is
|
|
417
|
+
not a type a model has, so a configuration naming it is describing
|
|
418
|
+
something that cannot happen - but section 5.3.2's closing paragraph
|
|
419
|
+
leaves input the standard does not cover to the solver's discretion, and
|
|
420
|
+
refusing this would be inventing a rule to enforce rather than reading one.
|
|
421
|
+
A mismatch is a mismatch whichever of the twenty-one names is on each side.
|
|
422
|
+
"""
|
|
423
|
+
types = rule.get("model_element_types", {})
|
|
424
|
+
if not isinstance(types, dict):
|
|
425
|
+
raise ConfigError(
|
|
426
|
+
f"{where} model_element_types must be a table keyed by variable name, "
|
|
427
|
+
f"not {_kind(types)}"
|
|
428
|
+
)
|
|
429
|
+
for variable, element_type in sorted(types.items()):
|
|
430
|
+
if not variable:
|
|
431
|
+
raise ConfigError(
|
|
432
|
+
f"{where} model_element_types has an entry with an empty variable name"
|
|
433
|
+
)
|
|
434
|
+
if not isinstance(element_type, str):
|
|
435
|
+
raise ConfigError(
|
|
436
|
+
f"{where} model_element_types gives {variable!r} a "
|
|
437
|
+
f"{_kind(element_type)} rather than an element type name"
|
|
438
|
+
)
|
|
439
|
+
if element_type not in ELEMENT_TYPES:
|
|
440
|
+
raise ConfigError(
|
|
441
|
+
f"{where} model_element_types gives {variable!r} unknown element type "
|
|
442
|
+
f"{element_type!r}; the standard defines {as_list(ELEMENT_TYPES)}"
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _string_list(table: Mapping[str, Any], key: str, where: str) -> list[str]:
|
|
447
|
+
value = table.get(key, [])
|
|
448
|
+
if not isinstance(value, list):
|
|
449
|
+
raise ConfigError(
|
|
450
|
+
f"{where} {key} must be an array of strings, not {_kind(value)}"
|
|
451
|
+
)
|
|
452
|
+
for entry in value:
|
|
453
|
+
if not isinstance(entry, str):
|
|
454
|
+
raise ConfigError(
|
|
455
|
+
f"{where} {key} must contain only strings; found {_kind(entry)}"
|
|
456
|
+
)
|
|
457
|
+
return value
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _reject_line_break(value: str, what: str) -> None:
|
|
461
|
+
"""Refuse a value that would turn one line of output into several.
|
|
462
|
+
|
|
463
|
+
Every response format in sections 5.3 and 5.4 is line-delimited, so a newline
|
|
464
|
+
inside a value is not a formatting nicety: one answer arrives as two, and the
|
|
465
|
+
consumer has no way to tell that is what happened.
|
|
466
|
+
"""
|
|
467
|
+
if "\n" in value or "\r" in value:
|
|
468
|
+
raise ConfigError(
|
|
469
|
+
f"{what} must not contain a line break; it is emitted as one line, and "
|
|
470
|
+
f"an embedded newline would be read back as more than one answer"
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _reject_whitespace(value: str, what: str) -> None:
|
|
475
|
+
"""Refuse a value that is emitted as a single whitespace-delimited token."""
|
|
476
|
+
_reject_line_break(value, what)
|
|
477
|
+
if any(character.isspace() for character in value):
|
|
478
|
+
raise ConfigError(
|
|
479
|
+
f"{what} must not contain whitespace: {value!r} would be read back as "
|
|
480
|
+
f"more than one value, because the response separates values with spaces"
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _is_integer(value: Any) -> bool:
|
|
485
|
+
"""Report whether a value is a TOML integer, excluding booleans.
|
|
486
|
+
|
|
487
|
+
``bool`` is a subclass of ``int`` in Python, so a bare ``isinstance`` check reads
|
|
488
|
+
``delay_seconds = true`` as the integer 1 and accepts it. Every integer-valued
|
|
489
|
+
configuration key goes through here so that trap is guarded in exactly one place.
|
|
490
|
+
"""
|
|
491
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _kind(value: Any) -> str:
|
|
495
|
+
return type(value).__name__
|