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,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)
|