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,130 @@
1
+ """Configuration discovery and loading.
2
+
3
+ Discovery order is the published contract: an explicit ``--config`` path, then the
4
+ environment variable, then a file in the working directory.
5
+
6
+ **There is no packaged fallback, and running with no configuration at all is an
7
+ error.** Every answer this program gives - its name, its version, each capability
8
+ and each result - is a configured answer, so with no configuration there is
9
+ nothing it can honestly say. Shipping a default would mean answering anyway, and
10
+ an answer nobody configured is exactly what a conformance baseline must never
11
+ produce: it would be the one reply in the system that no test author chose, and
12
+ it would look identical to one they did.
13
+
14
+ A source named explicitly and then found missing is an error rather than a
15
+ fall-through to the next source. Falling through would let a mistyped path
16
+ silently answer from somewhere else, which is the same failure in a smaller
17
+ place.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import sys
24
+ from collections.abc import Mapping
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any, Optional
28
+
29
+ from .errors import ConfigError
30
+ from .validation import validate
31
+
32
+ if sys.version_info >= (3, 11):
33
+ import tomllib
34
+ else:
35
+ import tomli as tomllib
36
+
37
+ ENV_VAR = "VNNLIB_TEST_SOLVER_CONFIG"
38
+ WORKING_DIRECTORY_NAME = "vnnlibTestSolver.toml"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ConfigSource:
43
+ """Where a configuration was resolved from, and how to describe that on stderr."""
44
+
45
+ path: Path
46
+ origin: str
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Solver:
51
+ """The ``[solver]`` table: the answers to the two global options."""
52
+
53
+ name: str
54
+ version: str
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Config:
59
+ """A validated configuration.
60
+
61
+ Sections consumed by later commands stay as mappings: their typed shape belongs
62
+ with the code that reads them, not here.
63
+ """
64
+
65
+ source: ConfigSource
66
+ solver: Solver
67
+ capabilities: Mapping[str, Any]
68
+ soundness: Mapping[str, Any]
69
+ injection: Mapping[str, Any]
70
+ rules: tuple[Mapping[str, Any], ...]
71
+
72
+
73
+ def resolve_config_source(explicit: Optional[str] = None) -> ConfigSource:
74
+ """Resolve the configuration file to use, in the documented precedence order.
75
+
76
+ Raises:
77
+ ConfigError: no source applies, so there is no configured answer to give.
78
+ """
79
+ if explicit is not None:
80
+ return _named_source(explicit, "--config")
81
+
82
+ from_env = os.environ.get(ENV_VAR)
83
+ if from_env:
84
+ return _named_source(from_env, f"${ENV_VAR}")
85
+
86
+ in_working_directory = Path.cwd() / WORKING_DIRECTORY_NAME
87
+ if in_working_directory.is_file():
88
+ return ConfigSource(in_working_directory, "working directory")
89
+
90
+ # The message names all three sources rather than only the last one tried,
91
+ # because a caller who has set one of them and still landed here has set the
92
+ # wrong one, and cannot tell which from a message about the working
93
+ # directory alone.
94
+ raise ConfigError(
95
+ "no configuration file found, and this solver answers only from "
96
+ f"configuration: give --config <path>, set ${ENV_VAR}, or put a "
97
+ f"{WORKING_DIRECTORY_NAME} in the working directory"
98
+ )
99
+
100
+
101
+ def _named_source(candidate: str, origin: str) -> ConfigSource:
102
+ path = Path(candidate)
103
+ if not path.is_file():
104
+ raise ConfigError(f"configuration file given by {origin} does not exist: {path}")
105
+ return ConfigSource(path, origin)
106
+
107
+
108
+ def load_config(source: ConfigSource) -> Config:
109
+ """Read, parse and validate the configuration at ``source``."""
110
+ try:
111
+ with source.path.open("rb") as handle:
112
+ raw = tomllib.load(handle)
113
+ except OSError as exc:
114
+ raise ConfigError(f"cannot read configuration file {source.path}: {exc}") from exc
115
+ except tomllib.TOMLDecodeError as exc:
116
+ raise ConfigError(
117
+ f"configuration file {source.path} is not valid TOML: {exc}"
118
+ ) from exc
119
+
120
+ validate(raw)
121
+
122
+ solver = raw["solver"]
123
+ return Config(
124
+ source=source,
125
+ solver=Solver(name=solver["name"], version=solver["version"]),
126
+ capabilities=raw.get("capabilities", {}),
127
+ soundness=raw.get("soundness", {}),
128
+ injection=raw.get("injection", {}),
129
+ rules=tuple(raw.get("rules", [])),
130
+ )
@@ -0,0 +1,67 @@
1
+ """The declarable element types, keyed by the parser's own enum member name.
2
+
3
+ Section 5.3.1 prints each variable's type in the specification's string form,
4
+ ``float32``, not in the form the parser's enum reprs it as. No reverse map
5
+ exists in either library - ``VNNLIB-CPP``'s type checker maps string to enum
6
+ and never back - so this one is maintained by hand and checked against the
7
+ enum by a test rather than trusted.
8
+
9
+ **Keyed by the member's name rather than by the member itself**, so this module
10
+ holds no ``vnnlib`` import. Only ``querymodel`` may have one, and a table the
11
+ rest of the package can read without touching the parser is what lets the
12
+ parser stay behind that one file.
13
+
14
+ Twenty-one entries: the twenty ONNX Set 1 names declarable in a query, plus the
15
+ non-ONNX ``real``. The enum carries five further members - constants and the
16
+ unknown placeholder - which no declaration can produce, so a lookup that misses
17
+ is a type the standard does not define, whatever caused it.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Mapping
23
+
24
+ DTYPE_TO_SPEC: Mapping[str, str] = {
25
+ "Real": "real",
26
+ "F16": "float16",
27
+ "F32": "float32",
28
+ "F64": "float64",
29
+ "BF16": "bfloat16",
30
+ "F8E4M3FN": "float8e4m3fn",
31
+ "F8E5M2": "float8e5m2",
32
+ "F8E4M3FNUZ": "float8e4m3fnuz",
33
+ "F8E5M2FNUZ": "float8e5m2fnuz",
34
+ "F4E2M1": "float4e2m1",
35
+ "I8": "int8",
36
+ "I16": "int16",
37
+ "I32": "int32",
38
+ "I64": "int64",
39
+ "U8": "uint8",
40
+ "U16": "uint16",
41
+ "U32": "uint32",
42
+ "U64": "uint64",
43
+ "C64": "complex64",
44
+ "C128": "complex128",
45
+ "Bool": "bool",
46
+ }
47
+
48
+ NOT_DECLARABLE = frozenset(
49
+ {
50
+ "FloatConstant",
51
+ "NegativeIntConstant",
52
+ "PositiveIntConstant",
53
+ "String",
54
+ "Unknown",
55
+ }
56
+ )
57
+ """Enum members that exist but that no ``declare-`` form can produce.
58
+
59
+ Four of them classify literals inside assertions. The fifth, ``Unknown``, is
60
+ what the pinned parser returns for an element type name it does not recognise,
61
+ silently and without raising, which is why a lookup miss has to be refused
62
+ where the declaration is read.
63
+
64
+ Recorded here rather than left implicit so that the two halves stay one list:
65
+ a test asserts that these five and the twenty-one above account for every
66
+ member of the enum, so a member added upstream cannot slip through as either.
67
+ """
@@ -0,0 +1,30 @@
1
+ """Shared exception types.
2
+
3
+ They are kept apart because they are different mistakes made by different
4
+ people: a usage error is the caller's, a configuration error is the
5
+ configuration author's, and a query error is the query author's. Distinct
6
+ types are what let each carry its own exit code and its own wording.
7
+ """
8
+
9
+
10
+ class ConfigError(Exception):
11
+ """A configuration file could not be resolved, parsed, or validated."""
12
+
13
+
14
+ class UsageError(Exception):
15
+ """Command-line arguments parsed but do not describe a usable request.
16
+
17
+ For rejections this package makes itself. A rejection argparse makes leaves
18
+ through its own error handling and never becomes one of these.
19
+ """
20
+
21
+
22
+ class QueryError(Exception):
23
+ """A query file could not be read, parsed, or understood.
24
+
25
+ Neither of the other two: the command line named a file and the
26
+ configuration said what to answer, and the fault is in the query itself.
27
+ Kept separate so that a caller debugging a fixture is not told to look at
28
+ its configuration, and so the exit code this maps to can be decided once,
29
+ where the command handles it.
30
+ """
@@ -0,0 +1,119 @@
1
+ """Deliberate misbehaviour, resolved from configuration.
2
+
3
+ A consumer's handling of a solver that returns unparseable output, exits with a
4
+ code that disagrees with its answer, writes noise on ``stderr``, or dies rather
5
+ than answering is the code least likely to be exercised and most likely to be
6
+ wrong. No correct solver produces any of it on request, so a test solver that
7
+ cannot be told to is missing the half of the interface that is hardest to test.
8
+
9
+ **Two places, one set of keys.** A ``[[rules]]`` entry carries them, because
10
+ which answer a query gets is already chosen per rule and misbehaviour belongs
11
+ with the answer it accompanies. The top-level ``[injection]`` table carries the
12
+ same four, because a rule is selected by matching a query file's name and the
13
+ ``supports`` command has no query file - so nothing hanging off a rule can ever
14
+ reach it, and the capability responses are exactly what a consumer's other
15
+ parser needs to be fed malformed.
16
+
17
+ Where both set the same control the rule wins, **per control rather than
18
+ wholesale**. Replacing the whole table would mean a rule that only wanted a
19
+ different exit code silently discarded the ``stderr`` noise configured beside
20
+ it, which is the kind of quiet subtraction that makes a fixture describe one
21
+ thing and test another.
22
+
23
+ Nothing here writes to a stream. Resolution is separated from delivery so that
24
+ what a configuration means can be tested without capturing output, and so that
25
+ the one module permitted to write to ``stdout`` stays the one that does.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from collections.abc import Mapping
31
+ from dataclasses import dataclass
32
+ from typing import Any, Optional
33
+
34
+ KEYS = frozenset({"stderr", "exit_code", "raw_stdout", "crash"})
35
+ """The four controls, named once.
36
+
37
+ ``validation`` builds both the rule schema and the ``[injection]`` schema from
38
+ this set rather than listing the names twice, so the two cannot drift apart and
39
+ then disagree about which keys exist.
40
+ """
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Injection:
45
+ """What a run should do instead of behaving correctly.
46
+
47
+ ``None`` means "not asked for" rather than "asked for nothing": an empty
48
+ ``raw_stdout`` is a real instruction to print nothing at all, and is not the
49
+ same as leaving the key out.
50
+ """
51
+
52
+ stderr: Optional[str] = None
53
+ exit_code: Optional[int] = None
54
+ raw_stdout: Optional[str] = None
55
+ crash: bool = False
56
+
57
+
58
+ NOTHING = Injection()
59
+ """The absence of any instruction, for a run nothing was configured for."""
60
+
61
+
62
+ def resolve(table: Mapping[str, Any], rule: Optional[Any] = None) -> Injection:
63
+ """Combine the ``[injection]`` table with a rule's own controls.
64
+
65
+ ``rule`` is anything carrying the four attributes - a ``Rule`` in practice.
66
+ It is typed loosely on purpose: this module is imported by ``rules`` in
67
+ neither direction, and giving it a concrete dependency on that record would
68
+ buy nothing and cost a cycle.
69
+ """
70
+ crash = _pick(table, rule, "crash")
71
+ exit_code = _pick(table, rule, "exit_code")
72
+
73
+ # A process that terminates abnormally reports no exit status, so these two
74
+ # cannot both happen. Within one table that is a configuration error, caught
75
+ # when the file loads. Across the two levels it is not: each half is valid
76
+ # where it is written, and neither validator can see the other. It is
77
+ # resolved here instead, by the rule that governs every other control -
78
+ # the more specific source wins - so that nothing is silently ignored at
79
+ # the point of delivery, where nobody would see it happen.
80
+ #
81
+ # Only a crash actually being asked for conflicts with an exit code. An
82
+ # explicit ``crash = false`` conflicts with nothing: it is a rule declining
83
+ # to crash, which is precisely what lets it override a table that does.
84
+ if crash and exit_code is not None:
85
+ if _stated_by_rule(rule, "crash"):
86
+ exit_code = None
87
+ else:
88
+ crash = False
89
+
90
+ return Injection(
91
+ stderr=_pick(table, rule, "stderr"),
92
+ exit_code=exit_code,
93
+ raw_stdout=_pick(table, rule, "raw_stdout"),
94
+ crash=bool(crash),
95
+ )
96
+
97
+
98
+ def _stated_by_rule(rule: Optional[Any], key: str) -> bool:
99
+ """Whether the rule mentioned this control at all, as opposed to leaving it out."""
100
+ return rule is not None and getattr(rule, key, None) is not None
101
+
102
+
103
+ def _pick(table: Mapping[str, Any], rule: Optional[Any], key: str) -> Any:
104
+ """The rule's value for one control if it stated one, else the table's.
105
+
106
+ Every control uses ``None`` for "not stated", which is what makes this one
107
+ comparison correct for all four. ``crash`` used to be the exception - its
108
+ absent form was ``False``, so this function compared against each key's own
109
+ absent value instead. That protected a rule which simply omits ``crash``
110
+ from overriding a table that sets it, but it also made an explicit
111
+ ``crash = false`` indistinguishable from silence, so a rule could not turn
112
+ a table's crash off. Found in review; the fix was to give ``crash`` the same
113
+ absent value as everything else rather than to special-case it here.
114
+ """
115
+ if rule is not None:
116
+ value = getattr(rule, key, None)
117
+ if value is not None:
118
+ return value
119
+ return table.get(key)
File without changes
@@ -0,0 +1,250 @@
1
+ """Reading a query file: the only module permitted to import ``vnnlib``.
2
+
3
+ Section 5.3.1 requires a satisfying assignment to name each variable, its type
4
+ and its dimensions, in the order the query file declares them. None of that is
5
+ in the configuration file and none of it may be: a configuration whose variable
6
+ metadata silently disagreed with its query would produce a confidently wrong
7
+ answer, which is the one failure a conformance baseline must never have.
8
+ Configuration supplies values; the query supplies structure.
9
+
10
+ Everything the rest of the package sees from here is a plain string, integer or
11
+ tuple. No parser object, and no name from the parser's vocabulary, crosses this
12
+ boundary. That is what makes the dependency reversible: were the package
13
+ required to be standalone, this file would read structure from configuration
14
+ instead and every other module would keep its interface.
15
+
16
+ Declaration order is the walk ``inputs``, then hidden nodes, then ``outputs``,
17
+ per network in the order the networks are declared. That is not a convention
18
+ chosen here. The grammar orders those three groups positionally, so any other
19
+ order is a parse error rather than a differently ordered parse, and the walk
20
+ reproduces the standard's own worked example exactly.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from collections.abc import Sequence
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Any, Optional
30
+
31
+ from .dtypes import DTYPE_TO_SPEC
32
+ from .errors import QueryError
33
+ from .spec import ELEMENT_TYPES, as_list
34
+
35
+ try:
36
+ # Where the public API is moving. Every name still reachable at the top
37
+ # level emits a DeprecationWarning once that move lands, including through
38
+ # a plain from-import, because a from-import falls back to the module's
39
+ # __getattr__. Trying the new location first means this file is correct on
40
+ # both sides of that migration and warns on neither.
41
+ from vnnlib.query import VNNLibException, parse_query_file
42
+ except ImportError: # pragma: no cover - depends on the installed version
43
+ # The pinned version, which has no vnnlib.query at all.
44
+ from vnnlib import VNNLibException, parse_query_file
45
+
46
+ INPUT = "input"
47
+ HIDDEN = "hidden"
48
+ OUTPUT = "output"
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Declaration:
53
+ """One declared variable, as the assignment output needs to describe it.
54
+
55
+ ``element_type`` is the specification's string form rather than the
56
+ parser's enum, so that a reader of this record never has to know the
57
+ parser exists. ``shape`` is a tuple rather than the list the parser hands
58
+ back, because that list is the caller's to mutate and this record is not.
59
+ """
60
+
61
+ name: str
62
+ element_type: str
63
+ shape: tuple[int, ...]
64
+ kind: str
65
+ network: str
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Network:
70
+ """One declared network, and what it is declared equivalent to.
71
+
72
+ ``equal_to`` decides whether a network needs an ONNX model supplied for
73
+ it; the parser reports an absent equivalence as an empty string, which is
74
+ narrowed to ``None`` here so that absence cannot be mistaken for a network
75
+ named by the empty string.
76
+
77
+ ``isometric_to`` is carried alongside it and deliberately does **not**
78
+ exempt a network from the mapping. An isomorphic pair shares a graph
79
+ structure and has different weights - the standard's motivating cases are
80
+ retraining and quantisation - so each one needs its own model file, and
81
+ section 5.3 exempts only ``equal-to`` by name. Carrying the field is what
82
+ forced that to be answered against the standard rather than discovered
83
+ missing later. The standard spells this *isomorphic*; the parser's
84
+ attribute spells it *isometric*, and they are the same concept.
85
+ """
86
+
87
+ name: str
88
+ equal_to: Optional[str]
89
+ isometric_to: Optional[str]
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class QueryModel:
94
+ """A query file, reduced to what answering about it requires."""
95
+
96
+ path: Path
97
+ networks: tuple[Network, ...]
98
+ declarations: tuple[Declaration, ...]
99
+
100
+
101
+ def read_query(path: Path) -> QueryModel:
102
+ """Read ``path`` and return its networks and declarations, in order.
103
+
104
+ Raises:
105
+ QueryError: the file cannot be read, does not parse, fails the
106
+ parser's own type checking, or declares an element type the
107
+ standard does not define.
108
+ """
109
+ _require_readable(path)
110
+
111
+ try:
112
+ parsed = parse_query_file(str(path))
113
+ except VNNLibException as exc:
114
+ raise QueryError(f"{path} is not a valid query: {_explain(exc)}") from exc
115
+
116
+ # Not every failure raises. Given a path it cannot open, the parser writes
117
+ # its own message and returns None, so a caller that goes straight for an
118
+ # attribute gets a traceback rather than a message. The readable check above
119
+ # is what normally prevents this; the guard covers whatever it does not,
120
+ # such as a file that becomes unreadable between the two.
121
+ #
122
+ # The published stub declares this function as returning a query and never
123
+ # None, so nothing warns about the branch and a type checker will not put it
124
+ # there for you. Verified by running the parser against a path that does not
125
+ # exist: it returns None.
126
+ if parsed is None:
127
+ raise QueryError(
128
+ f"the query parser could not read {path} and gave no reason; "
129
+ f"check that the file is readable and is a VNN-LIB query"
130
+ )
131
+
132
+ networks = tuple(_network(network) for network in parsed.networks)
133
+ declarations = tuple(_declarations(parsed, path))
134
+ return QueryModel(path=path, networks=networks, declarations=declarations)
135
+
136
+
137
+ def _require_readable(path: Path) -> None:
138
+ """Refuse an unreadable path here, where it can still be explained.
139
+
140
+ The parser does not raise for one. It prints a message of its own and
141
+ returns None, so refusing first is what keeps every message this package
142
+ produces in this package's wording, and keeps the parser's C++ message off
143
+ a stream a consumer is reading.
144
+ """
145
+ if not path.exists():
146
+ raise QueryError(f"query file does not exist: {path}")
147
+ if not path.is_file():
148
+ raise QueryError(f"query file is not a file: {path}")
149
+
150
+
151
+ def _network(network: Any) -> Network:
152
+ return Network(
153
+ name=network.name,
154
+ equal_to=network.equal_to or None,
155
+ isometric_to=network.isometric_to or None,
156
+ )
157
+
158
+
159
+ def _declarations(parsed: Any, path: Path) -> list[Declaration]:
160
+ """Walk every network's declarations in the order the grammar fixes."""
161
+ declarations: list[Declaration] = []
162
+ for network in parsed.networks:
163
+ groups = (
164
+ (INPUT, network.inputs),
165
+ (HIDDEN, network.hidden),
166
+ (OUTPUT, network.outputs),
167
+ )
168
+ for kind, definitions in groups:
169
+ for definition in definitions:
170
+ declarations.append(_declaration(definition, kind, network.name, path))
171
+ return declarations
172
+
173
+
174
+ def _declaration(definition: Any, kind: str, network: str, path: Path) -> Declaration:
175
+ element_type = DTYPE_TO_SPEC.get(definition.dtype.name)
176
+ if element_type is None:
177
+ # The parser accepts an element type name it does not recognise without
178
+ # raising, and reports it as its unknown placeholder. Left alone that
179
+ # produces a header line that is wrong and entirely plausible, so it is
180
+ # refused before anything can be printed. The name as written is not
181
+ # recoverable from the parsed form, which is why the message names the
182
+ # variable and the legal set instead of the offending word.
183
+ raise QueryError(
184
+ f"{path}: {kind} {definition.name!r} in network {network!r} is declared "
185
+ f"with an element type the standard does not define; element type names "
186
+ f"are lower case and case sensitive, and the standard defines "
187
+ f"{as_list(ELEMENT_TYPES)}"
188
+ )
189
+ return Declaration(
190
+ name=definition.name,
191
+ element_type=element_type,
192
+ shape=_shape(definition.shape),
193
+ kind=kind,
194
+ network=network,
195
+ )
196
+
197
+
198
+ def _shape(dimensions: Sequence[int]) -> tuple[int, ...]:
199
+ return tuple(int(dimension) for dimension in dimensions)
200
+
201
+
202
+ def _explain(exc: VNNLibException) -> str:
203
+ """Render one parser exception, whichever of its two body formats it used.
204
+
205
+ A parse error carries plain text and a semantic error carries a JSON
206
+ report, both from this one exception type. Reading either as the other
207
+ raises from inside the error handler, so which one it is has to be
208
+ established before the body is read rather than assumed.
209
+
210
+ Errors are reported in the order the parser gives them, never sorted: the
211
+ same query must produce the same message every time it is refused.
212
+ """
213
+ body = str(exc)
214
+ try:
215
+ report = json.loads(body)
216
+ except ValueError:
217
+ return body
218
+
219
+ if not isinstance(report, dict):
220
+ return body
221
+ errors = report.get("errors")
222
+ if not isinstance(errors, list) or not errors:
223
+ # A report with nothing in it should not happen: the parser raises this
224
+ # format only when it has errors to describe. Falling back to the body
225
+ # would print raw JSON at whoever is reading stderr, which is the one
226
+ # thing this function exists to avoid, so it says what is known instead.
227
+ return (
228
+ "the query failed the parser's checks, but the report carried no "
229
+ "detail about why"
230
+ )
231
+
232
+ first = _describe(errors[0])
233
+ if len(errors) == 1:
234
+ return first
235
+ return f"{first} (and {len(errors) - 1} further problem(s) in the same file)"
236
+
237
+
238
+ def _describe(error: Any) -> str:
239
+ """Turn one entry of a semantic error report into a line of prose."""
240
+ if not isinstance(error, dict):
241
+ return str(error)
242
+ parts = []
243
+ line = error.get("line")
244
+ if line is not None:
245
+ parts.append(f"line {line}")
246
+ for key in ("message", "hint"):
247
+ value = error.get(key)
248
+ if value:
249
+ parts.append(str(value))
250
+ return ": ".join(parts) if parts else str(error)