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.
@@ -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__