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,119 @@
|
|
|
1
|
+
"""Rule matching: which configured answer applies to a query file.
|
|
2
|
+
|
|
3
|
+
The ``[[rules]]`` array is what makes this a test solver rather than a solver.
|
|
4
|
+
An answer is chosen by looking the query file up in the configuration, never by
|
|
5
|
+
examining anything the file says, which is the whole of the arrangement: one
|
|
6
|
+
installed binary, three configuration files, three answers.
|
|
7
|
+
|
|
8
|
+
**First match wins**, in the order the rules are written. The alternative - the
|
|
9
|
+
most specific pattern wins - needs a definition of specificity for two arbitrary
|
|
10
|
+
globs, and every such definition is a surprise waiting for whoever writes the
|
|
11
|
+
second rule. Written order is a rule a reader can apply by eye.
|
|
12
|
+
|
|
13
|
+
**No matching rule is an error, not a default.** Silence in a configuration file
|
|
14
|
+
is not a claim, and a conformance baseline that invents an answer is worse than
|
|
15
|
+
one that refuses to give it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import fnmatch
|
|
21
|
+
from collections.abc import Mapping, Sequence
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Optional
|
|
25
|
+
|
|
26
|
+
from .config import Config
|
|
27
|
+
from .errors import ConfigError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Rule:
|
|
32
|
+
"""One configured answer, built from a validated ``[[rules]]`` entry.
|
|
33
|
+
|
|
34
|
+
Every key the schema permits is read here rather than at the point it is
|
|
35
|
+
first used, so this record and the validated schema stay one list that can
|
|
36
|
+
be compared by eye.
|
|
37
|
+
|
|
38
|
+
``model_element_types`` is what the configuration claims the supplied model
|
|
39
|
+
files expose, keyed by variable name. It is the only source this package
|
|
40
|
+
has for section 5.3.2's second error condition, since nothing here opens an
|
|
41
|
+
ONNX file.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
match: str
|
|
45
|
+
result: str
|
|
46
|
+
assignments: Mapping[str, Sequence[float]]
|
|
47
|
+
model_element_types: Mapping[str, str]
|
|
48
|
+
delay_seconds: int
|
|
49
|
+
stderr: Optional[str]
|
|
50
|
+
exit_code: Optional[int]
|
|
51
|
+
raw_stdout: Optional[str]
|
|
52
|
+
crash: Optional[bool]
|
|
53
|
+
"""``None`` when the rule does not mention it, which is not the same as
|
|
54
|
+
``False``.
|
|
55
|
+
|
|
56
|
+
An explicit ``crash = false`` is a rule saying *do not* crash, and it has to
|
|
57
|
+
be able to override a ``[injection]`` table that says otherwise. Storing
|
|
58
|
+
both as ``False`` made those two indistinguishable and silently lost the
|
|
59
|
+
override - found in review, not by a test.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def select(config: Config, query: Path) -> Rule:
|
|
64
|
+
"""Return the first configured rule matching ``query``.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ConfigError: no rule matches, so the configuration has no answer to give.
|
|
68
|
+
"""
|
|
69
|
+
for entry in config.rules:
|
|
70
|
+
if matches(entry["match"], query):
|
|
71
|
+
return _rule(entry)
|
|
72
|
+
raise ConfigError(_no_match_message(config, query))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def matches(pattern: str, query: Path) -> bool:
|
|
76
|
+
"""Compare one pattern against the query file's name.
|
|
77
|
+
|
|
78
|
+
``fnmatchcase`` rather than ``fnmatch``: the latter folds case through
|
|
79
|
+
``os.path.normcase``, which does nothing on POSIX and lower-cases on
|
|
80
|
+
Windows. The same configuration and the same query would then answer
|
|
81
|
+
differently on different machines, and a conformance baseline that is
|
|
82
|
+
platform-dependent is not a baseline.
|
|
83
|
+
|
|
84
|
+
The file's name rather than the path it was given by, so a configuration
|
|
85
|
+
keeps working from whichever directory a caller happens to run in.
|
|
86
|
+
"""
|
|
87
|
+
return fnmatch.fnmatchcase(query.name, pattern)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _no_match_message(config: Config, query: Path) -> str:
|
|
91
|
+
"""Say what was matched, against what, so a near miss is diagnosable.
|
|
92
|
+
|
|
93
|
+
A pattern written against a path rather than a file name fails silently
|
|
94
|
+
otherwise, and looks like the rule being ignored.
|
|
95
|
+
"""
|
|
96
|
+
if not config.rules:
|
|
97
|
+
return (
|
|
98
|
+
f"no rule matches query file {query.name!r}: {config.source.path} "
|
|
99
|
+
f"defines no rules at all, so it has no answer to give"
|
|
100
|
+
)
|
|
101
|
+
patterns = ", ".join(repr(entry["match"]) for entry in config.rules)
|
|
102
|
+
return (
|
|
103
|
+
f"no rule matches query file {query.name!r}; a pattern is compared against "
|
|
104
|
+
f"the file name alone, and {config.source.path} defines {patterns}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _rule(entry: Mapping[str, Any]) -> Rule:
|
|
109
|
+
return Rule(
|
|
110
|
+
match=entry["match"],
|
|
111
|
+
result=entry["result"],
|
|
112
|
+
assignments=entry.get("assignments", {}),
|
|
113
|
+
model_element_types=entry.get("model_element_types", {}),
|
|
114
|
+
delay_seconds=entry.get("delay_seconds", 0),
|
|
115
|
+
stderr=entry.get("stderr"),
|
|
116
|
+
exit_code=entry.get("exit_code"),
|
|
117
|
+
raw_stdout=entry.get("raw_stdout"),
|
|
118
|
+
crash=entry.get("crash"),
|
|
119
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Validation targets transcribed from the standard.
|
|
2
|
+
|
|
3
|
+
Theory identifier sets are section 4.1; element type names are the ONNX Set 1 names
|
|
4
|
+
declarable in a query, plus the non-ONNX ``real``. Sets are frozen because nothing may
|
|
5
|
+
extend them at runtime, and every message that names one sorts it first: unordered
|
|
6
|
+
iteration in emitted output would break determinism.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
HIDDEN_NODE_THEORIES = frozenset({"NH", "H"})
|
|
12
|
+
MULTIPLE_INPUT_OUTPUT_THEORIES = frozenset({"SIO", "MIO"})
|
|
13
|
+
MULTIPLE_NETWORK_THEORIES = frozenset({"SNET", "MNET", "MENET", "MINET"})
|
|
14
|
+
MULTIPLE_NODE_COMPARISON_THEORIES = frozenset({"SNC", "MNC"})
|
|
15
|
+
ARITHMETIC_COMPLEXITY_THEORIES = frozenset({"BND", "OUTC", "LIN", "POLY"})
|
|
16
|
+
|
|
17
|
+
REAL = "real"
|
|
18
|
+
"""The one element type the standard defines that ONNX does not.
|
|
19
|
+
|
|
20
|
+
Named for the reason TIMED_OUT and SAT are: section 5.3.2 exempts a
|
|
21
|
+
declaration written in this type from two of its three error conditions **by
|
|
22
|
+
name**, so this package now acts on the value rather than only listing it
|
|
23
|
+
among the others. A literal repeated in three places is free to drift away
|
|
24
|
+
from the set it is supposed to belong to.
|
|
25
|
+
|
|
26
|
+
Writing a declaration in this type is how section 3.5 says a user consents to
|
|
27
|
+
analysis that may be unsound under floating point, which is why the exemption
|
|
28
|
+
exists at all: there is nothing left to warn them about.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
ELEMENT_TYPES = frozenset(
|
|
32
|
+
{
|
|
33
|
+
REAL,
|
|
34
|
+
"float16",
|
|
35
|
+
"float32",
|
|
36
|
+
"float64",
|
|
37
|
+
"bfloat16",
|
|
38
|
+
"float8e4m3fn",
|
|
39
|
+
"float8e5m2",
|
|
40
|
+
"float8e4m3fnuz",
|
|
41
|
+
"float8e5m2fnuz",
|
|
42
|
+
"float4e2m1",
|
|
43
|
+
"int8",
|
|
44
|
+
"int16",
|
|
45
|
+
"int32",
|
|
46
|
+
"int64",
|
|
47
|
+
"uint8",
|
|
48
|
+
"uint16",
|
|
49
|
+
"uint32",
|
|
50
|
+
"uint64",
|
|
51
|
+
"complex64",
|
|
52
|
+
"complex128",
|
|
53
|
+
"bool",
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
TIMED_OUT = "timed-out"
|
|
58
|
+
"""Section 5.3.1's answer for a solver that ran out of the time it was given.
|
|
59
|
+
|
|
60
|
+
Named because it is the one result this package produces without being asked
|
|
61
|
+
for it, when a rule's delay outlasts the caller's timeout. The other three are
|
|
62
|
+
only ever quoted from configuration.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
SAT = "sat"
|
|
66
|
+
"""Section 5.3.1's answer for a query the solver found satisfiable.
|
|
67
|
+
|
|
68
|
+
Named for the same reason TIMED_OUT is: this package acts on it rather than
|
|
69
|
+
only quoting it back, because section 5.3.1 attaches the assignment block to
|
|
70
|
+
this result and to no other. A literal in two places is a literal that can
|
|
71
|
+
drift apart from the set below.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
RESULTS = (TIMED_OUT, "unknown", "unsat", SAT)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def as_list(values: frozenset[str]) -> str:
|
|
78
|
+
"""Render a set for an error message in a stable order."""
|
|
79
|
+
return ", ".join(sorted(values))
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""The supports command: capability responses, per standard section 5.4.
|
|
2
|
+
|
|
3
|
+
Eleven mandatory capabilities in four distinct response shapes: a pair of version
|
|
4
|
+
strings, a newline-separated list, one line per ONNX operator, and a boolean
|
|
5
|
+
literal. Only two of the eleven are booleans, so each shape is rendered on its
|
|
6
|
+
own terms rather than through one generic path.
|
|
7
|
+
|
|
8
|
+
Every answer comes from configuration. A capability the configuration does not
|
|
9
|
+
report is an error rather than a guess: a conformance baseline that invents an
|
|
10
|
+
answer is worse than one that refuses to give it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .config import Config
|
|
19
|
+
from .errors import ConfigError
|
|
20
|
+
|
|
21
|
+
OPSET_VERSIONS = "--onnx-opset-versions"
|
|
22
|
+
ELEMENT_TYPES = "--onnx-element-types"
|
|
23
|
+
OPERATORS = "--onnx-operators"
|
|
24
|
+
VNNLIB_VERSIONS = "--vnnlib-versions"
|
|
25
|
+
HIDDEN_NODE = "--hidden-node-theories"
|
|
26
|
+
MULTIPLE_INPUT_OUTPUT = "--multiple-input-output-theories"
|
|
27
|
+
MULTIPLE_NETWORK = "--multiple-network-theories"
|
|
28
|
+
MULTIPLE_NODE_COMPARISON = "--multiple-node-comparison-theories"
|
|
29
|
+
ARITHMETIC_COMPLEXITY = "--arithmetic-complexity-theories"
|
|
30
|
+
OPTIMISED_DISJUNCTIVE_REASONING = "--optimised-disjunctive-reasoning"
|
|
31
|
+
SERIALISE_ASSIGNMENTS = "--serialise-assignments"
|
|
32
|
+
|
|
33
|
+
CAPABILITIES = (
|
|
34
|
+
OPSET_VERSIONS,
|
|
35
|
+
ELEMENT_TYPES,
|
|
36
|
+
OPERATORS,
|
|
37
|
+
VNNLIB_VERSIONS,
|
|
38
|
+
HIDDEN_NODE,
|
|
39
|
+
MULTIPLE_INPUT_OUTPUT,
|
|
40
|
+
MULTIPLE_NETWORK,
|
|
41
|
+
MULTIPLE_NODE_COMPARISON,
|
|
42
|
+
ARITHMETIC_COMPLEXITY,
|
|
43
|
+
OPTIMISED_DISJUNCTIVE_REASONING,
|
|
44
|
+
SERIALISE_ASSIGNMENTS,
|
|
45
|
+
)
|
|
46
|
+
"""Every capability, in the order section 5.4 introduces them.
|
|
47
|
+
|
|
48
|
+
A tuple rather than a set: this order reaches stderr in the message naming the
|
|
49
|
+
legal capabilities, and unordered iteration in emitted output is not permitted.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
MULTIPLE_INPUT_OUTPUT_ALIAS = "--multiple-inputs-output-theories"
|
|
53
|
+
"""Section 5.4.2 spells the flag one way in its heading and another, with an
|
|
54
|
+
extra 's', in its own example. Both are accepted; the heading spelling is the
|
|
55
|
+
documented one."""
|
|
56
|
+
|
|
57
|
+
VERSION_PAIR_CAPABILITIES = (OPSET_VERSIONS, VNNLIB_VERSIONS)
|
|
58
|
+
LIST_CAPABILITIES = (
|
|
59
|
+
ELEMENT_TYPES,
|
|
60
|
+
HIDDEN_NODE,
|
|
61
|
+
MULTIPLE_INPUT_OUTPUT,
|
|
62
|
+
MULTIPLE_NETWORK,
|
|
63
|
+
MULTIPLE_NODE_COMPARISON,
|
|
64
|
+
ARITHMETIC_COMPLEXITY,
|
|
65
|
+
)
|
|
66
|
+
BOOLEAN_CAPABILITIES = (OPTIMISED_DISJUNCTIVE_REASONING, SERIALISE_ASSIGNMENTS)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def config_key(capability: str) -> str:
|
|
70
|
+
"""The [capabilities] key that answers a capability flag.
|
|
71
|
+
|
|
72
|
+
Every flag in section 5.4 is its configuration key with the option dashes
|
|
73
|
+
removed, so the published flag and the documented key cannot drift apart.
|
|
74
|
+
"""
|
|
75
|
+
return capability[2:]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def response_shape(capability: str) -> str:
|
|
79
|
+
"""Describe a capability's response shape, for the command's own help."""
|
|
80
|
+
if capability in VERSION_PAIR_CAPABILITIES:
|
|
81
|
+
return "two version strings, minimum then maximum"
|
|
82
|
+
if capability in BOOLEAN_CAPABILITIES:
|
|
83
|
+
return "true or false"
|
|
84
|
+
if capability == OPERATORS:
|
|
85
|
+
return "one operator per line, with the element types it supports"
|
|
86
|
+
if capability == ELEMENT_TYPES:
|
|
87
|
+
return "one element type per line"
|
|
88
|
+
return "one theory identifier per line"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def render(capability: str, config: Config) -> str:
|
|
92
|
+
"""Return the exact stdout text answering one capability.
|
|
93
|
+
|
|
94
|
+
The trailing newline is part of the answer. A capability configured as an
|
|
95
|
+
empty list renders as the empty string: supporting nothing is an answer, and
|
|
96
|
+
a blank line would not be one.
|
|
97
|
+
"""
|
|
98
|
+
reported = _reported(capability, config)
|
|
99
|
+
|
|
100
|
+
if capability in LIST_CAPABILITIES:
|
|
101
|
+
return _lines(reported)
|
|
102
|
+
|
|
103
|
+
if capability in VERSION_PAIR_CAPABILITIES:
|
|
104
|
+
# Two lines, minimum then maximum. Configuration validation has already
|
|
105
|
+
# rejected any count other than two, so the shape is guaranteed here.
|
|
106
|
+
return _lines(reported)
|
|
107
|
+
|
|
108
|
+
if capability == OPERATORS:
|
|
109
|
+
return _operator_lines(reported)
|
|
110
|
+
|
|
111
|
+
if capability in BOOLEAN_CAPABILITIES:
|
|
112
|
+
# The specification's literals, not Python's: bool renders as True/False.
|
|
113
|
+
return "true\n" if reported else "false\n"
|
|
114
|
+
|
|
115
|
+
# Unreachable through the command line, where the flags are built from
|
|
116
|
+
# CAPABILITIES and a test asserts the four shapes above partition it. Kept so
|
|
117
|
+
# that adding a twelfth capability fails loudly rather than silently.
|
|
118
|
+
raise ConfigError(f"{capability} has no defined response shape")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _reported(capability: str, config: Config) -> Any:
|
|
122
|
+
key = config_key(capability)
|
|
123
|
+
if key not in config.capabilities:
|
|
124
|
+
raise ConfigError(
|
|
125
|
+
f"the configuration does not report {capability}; add {key!r} under "
|
|
126
|
+
f"[capabilities] in {config.source.path}"
|
|
127
|
+
)
|
|
128
|
+
return config.capabilities[key]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _operator_lines(operators: Sequence[Mapping[str, Any]]) -> str:
|
|
132
|
+
"""One line per operator: its name, then the element types it is supported for.
|
|
133
|
+
|
|
134
|
+
An operator configured with no element types is emitted bare. Section 5.4.1
|
|
135
|
+
states that where the list is empty, the solver is assumed to support the
|
|
136
|
+
operator for all element types it reports through --onnx-element-types, so
|
|
137
|
+
the bare line is the standard's own way of saying "all of them", not "none".
|
|
138
|
+
|
|
139
|
+
That assumption is the reader's to make, which is why the line is left bare
|
|
140
|
+
rather than expanded: expanding it would answer the same question with
|
|
141
|
+
different bytes and settle on a consumer's behalf something the standard
|
|
142
|
+
leaves to their parser.
|
|
143
|
+
"""
|
|
144
|
+
return _lines(
|
|
145
|
+
" ".join([operator["name"], *operator.get("element-types", [])])
|
|
146
|
+
for operator in operators
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _lines(values: Iterable[str]) -> str:
|
|
151
|
+
return "".join(f"{value}\n" for value in values)
|