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 @@
|
|
|
1
|
+
__version__ = "2.0.0"
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""The section 5.3.1 command-line assignment format.
|
|
2
|
+
|
|
3
|
+
The one place in this package where the output is compared byte for byte
|
|
4
|
+
rather than merely parsed, so every rule below is separately testable and none
|
|
5
|
+
of them is cosmetic::
|
|
6
|
+
|
|
7
|
+
A float32 [2,2]
|
|
8
|
+
0.5
|
|
9
|
+
0.3
|
|
10
|
+
0.4
|
|
11
|
+
0.2
|
|
12
|
+
B int32 [1]
|
|
13
|
+
-1
|
|
14
|
+
|
|
15
|
+
Variables appear in the order the query declares them, across every network.
|
|
16
|
+
The dimension form has no spaces inside the brackets. Values are row-major,
|
|
17
|
+
one per line. The type is the specification's string form and it is the type
|
|
18
|
+
the query *declares*: the standard's own example prints ``Real`` for four
|
|
19
|
+
variables declared ``float32``, which is either an inconsistency in the
|
|
20
|
+
example or a distinction between declared and analysis types that nothing
|
|
21
|
+
else in the standard defines. The client confirmed the declared-type reading,
|
|
22
|
+
and the one other implementation of this interface prints the declared type
|
|
23
|
+
as well.
|
|
24
|
+
|
|
25
|
+
Structure comes from the query and values come from configuration. A
|
|
26
|
+
configuration that also described the variables could disagree with the query
|
|
27
|
+
it was pointed at, and produce output that is well formed, plausible and
|
|
28
|
+
wrong. That is the failure this split exists to make impossible.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from collections.abc import Mapping, Sequence
|
|
34
|
+
from typing import Union
|
|
35
|
+
|
|
36
|
+
from .errors import ConfigError
|
|
37
|
+
from .querymodel import Declaration, QueryModel
|
|
38
|
+
|
|
39
|
+
Value = Union[int, float]
|
|
40
|
+
|
|
41
|
+
OPEN_BRACKET = "["
|
|
42
|
+
CLOSE_BRACKET = "]"
|
|
43
|
+
DIMENSION_SEPARATOR = ","
|
|
44
|
+
"""Section 5.3.1's dimension form is [2,2]: no spaces anywhere inside it.
|
|
45
|
+
|
|
46
|
+
Named rather than written inline because the separator and the brackets are
|
|
47
|
+
the whole of a rule that a byte comparison exists to enforce, and a space
|
|
48
|
+
added to any of them is invisible in a diff of the code and glaring in a diff
|
|
49
|
+
of the output.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render(model: QueryModel, assignments: Mapping[str, Sequence[Value]]) -> str:
|
|
54
|
+
"""Return the assignment block for ``model``, terminated by a newline.
|
|
55
|
+
|
|
56
|
+
Every declared variable appears, in declaration order. The mapping supplies
|
|
57
|
+
only values, keyed by variable name; nothing about which variables exist or
|
|
58
|
+
what shape they have is read from it.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ConfigError: the supplied values do not fit the query - a variable is
|
|
62
|
+
missing, a variable is named that the query does not declare, or a
|
|
63
|
+
value list is not the length the declared shape requires.
|
|
64
|
+
"""
|
|
65
|
+
_reject_mismatch(model, assignments)
|
|
66
|
+
lines = []
|
|
67
|
+
for declaration in model.declarations:
|
|
68
|
+
lines.append(header(declaration))
|
|
69
|
+
lines.extend(_format(value) for value in assignments[declaration.name])
|
|
70
|
+
return "".join(f"{line}\n" for line in lines)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def header(declaration: Declaration) -> str:
|
|
74
|
+
"""Render one variable's header line: name, declared type, dimensions."""
|
|
75
|
+
return f"{declaration.name} {declaration.element_type} {dimensions(declaration)}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def dimensions(declaration: Declaration) -> str:
|
|
79
|
+
"""Render a shape as section 5.3.1 writes it: [2,2], no internal spaces."""
|
|
80
|
+
inner = DIMENSION_SEPARATOR.join(str(d) for d in declaration.shape)
|
|
81
|
+
return f"{OPEN_BRACKET}{inner}{CLOSE_BRACKET}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def size(declaration: Declaration) -> int:
|
|
85
|
+
"""The number of values a declaration's shape requires, row-major.
|
|
86
|
+
|
|
87
|
+
A shape with no dimensions holds one value, which is what an empty product
|
|
88
|
+
gives, so this needs no special case.
|
|
89
|
+
"""
|
|
90
|
+
total = 1
|
|
91
|
+
for dimension in declaration.shape:
|
|
92
|
+
total *= dimension
|
|
93
|
+
return total
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _reject_mismatch(
|
|
97
|
+
model: QueryModel, assignments: Mapping[str, Sequence[Value]]
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Refuse values that do not fit the query, before a byte is emitted.
|
|
100
|
+
|
|
101
|
+
All three mismatches are configuration errors rather than query errors: the
|
|
102
|
+
query parsed and means what it says, and it is the values pointed at it
|
|
103
|
+
that do not fit.
|
|
104
|
+
|
|
105
|
+
**A partial assignment is refused rather than printed.** A rule that
|
|
106
|
+
supplies values is claiming to give a satisfying assignment, and one that
|
|
107
|
+
omits a declared variable is not one. Printing it would produce a block
|
|
108
|
+
that is well formed, shorter than the standard requires, and impossible for
|
|
109
|
+
a consumer to tell apart from a complete one.
|
|
110
|
+
"""
|
|
111
|
+
declared = {declaration.name: declaration for declaration in model.declarations}
|
|
112
|
+
|
|
113
|
+
# Sorted, because these are set differences and the message must not depend
|
|
114
|
+
# on iteration order. Declaration order would read better but cannot be
|
|
115
|
+
# recovered for a name the query never declared.
|
|
116
|
+
unknown = sorted(set(assignments) - set(declared))
|
|
117
|
+
if unknown:
|
|
118
|
+
raise ConfigError(
|
|
119
|
+
f"the rule assigns a value to {unknown[0]!r}, which {model.path} does not "
|
|
120
|
+
f"declare; a rule supplies values, and the query decides which variables "
|
|
121
|
+
f"exist"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
missing = sorted(set(declared) - set(assignments))
|
|
125
|
+
if missing:
|
|
126
|
+
raise ConfigError(
|
|
127
|
+
f"the rule assigns no value to {missing[0]!r}, which {model.path} declares; "
|
|
128
|
+
f"an assignment covers every declared variable or a rule supplies none at "
|
|
129
|
+
f"all, because a partial assignment is not a satisfying assignment"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
for name, declaration in declared.items():
|
|
133
|
+
expected = size(declaration)
|
|
134
|
+
supplied = len(assignments[name])
|
|
135
|
+
if supplied != expected:
|
|
136
|
+
raise ConfigError(
|
|
137
|
+
f"the rule assigns {supplied} value(s) to {name!r}, which "
|
|
138
|
+
f"{model.path} declares as {dimensions(declaration)} and so requires "
|
|
139
|
+
f"{expected}; values are row-major, one per position"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _format(value: Value) -> str:
|
|
144
|
+
"""Render one value as the shortest string that round-trips to it.
|
|
145
|
+
|
|
146
|
+
``str`` of a TOML integer gives no decimal point and ``str`` of a TOML
|
|
147
|
+
float keeps one, so ``-1`` and ``1.0`` both survive as the standard's
|
|
148
|
+
example writes them, and the integer/decimal distinction the configuration
|
|
149
|
+
chose is preserved. The standard specifies no numeric format, so nothing
|
|
150
|
+
here reformats beyond that.
|
|
151
|
+
|
|
152
|
+
**This is not the same as reproducing what the author typed**, and the
|
|
153
|
+
difference is worth stating in the one package whose whole claim is control
|
|
154
|
+
over its own bytes. Python's float repr is the shortest string that
|
|
155
|
+
round-trips, so a value written in scientific notation is normalised:
|
|
156
|
+
``1e10`` renders as ``10000000000.0`` and ``1.5e-8`` as ``1.5e-08``. That
|
|
157
|
+
property is what a baseline actually needs - it is identical on every
|
|
158
|
+
platform and every run, where "as typed" would depend on the spelling
|
|
159
|
+
someone happened to choose. ``inf``, ``-inf`` and ``nan`` pass through as
|
|
160
|
+
those literals. A test that needs an exact byte sequence rather than a
|
|
161
|
+
numerically equal one uses ``raw_stdout``.
|
|
162
|
+
"""
|
|
163
|
+
return str(value)
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""Console entry point: command dispatch per section 5.1, global options per 5.2.
|
|
2
|
+
|
|
3
|
+
Results go to stdout; the configuration note, warnings and every error go to
|
|
4
|
+
stderr, which section 5.1 requires. Neither the name, the version nor any
|
|
5
|
+
capability is held in code: all of them are answers, and answers come from
|
|
6
|
+
configuration.
|
|
7
|
+
|
|
8
|
+
Everything bound for stdout goes through ``emit``, which writes bytes. This is
|
|
9
|
+
the only module that writes to stdout at all, so that rule is enforceable by
|
|
10
|
+
reading one file.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from typing import Any, NoReturn, Optional
|
|
20
|
+
|
|
21
|
+
from .assignments import render as render_assignment
|
|
22
|
+
from .config import (
|
|
23
|
+
ENV_VAR,
|
|
24
|
+
WORKING_DIRECTORY_NAME,
|
|
25
|
+
Config,
|
|
26
|
+
load_config,
|
|
27
|
+
resolve_config_source,
|
|
28
|
+
)
|
|
29
|
+
from .errors import ConfigError, QueryError, UsageError
|
|
30
|
+
from .injection import Injection, resolve
|
|
31
|
+
from .querymodel import QueryModel, read_query
|
|
32
|
+
from .rules import Rule, select
|
|
33
|
+
from .spec import SAT
|
|
34
|
+
from .supports import (
|
|
35
|
+
CAPABILITIES,
|
|
36
|
+
MULTIPLE_INPUT_OUTPUT,
|
|
37
|
+
MULTIPLE_INPUT_OUTPUT_ALIAS,
|
|
38
|
+
render,
|
|
39
|
+
response_shape,
|
|
40
|
+
)
|
|
41
|
+
from .validation import soundness_warnings
|
|
42
|
+
from .verify import (
|
|
43
|
+
VERIFY_COMMAND,
|
|
44
|
+
VERIFY_OPTIONS,
|
|
45
|
+
VerifyRequest,
|
|
46
|
+
add_arguments,
|
|
47
|
+
answer,
|
|
48
|
+
check_element_types,
|
|
49
|
+
check_network_mapping,
|
|
50
|
+
check_soundness,
|
|
51
|
+
request_from,
|
|
52
|
+
)
|
|
53
|
+
from .verify import warnings as verify_warnings
|
|
54
|
+
|
|
55
|
+
PROGRAM = "vnnlibTestSolver"
|
|
56
|
+
DEBUG_ENV_VAR = "VNNLIB_TEST_SOLVER_DEBUG"
|
|
57
|
+
|
|
58
|
+
SUPPORTS_COMMAND = "supports"
|
|
59
|
+
CAPABILITIES_COMMAND = "capabilities"
|
|
60
|
+
|
|
61
|
+
GLOBAL_OPTIONS = ("--config", "--name", "--version")
|
|
62
|
+
|
|
63
|
+
EXIT_OK = 0
|
|
64
|
+
# The standard defines no exit codes. A usage mistake (bad flags, no command,
|
|
65
|
+
# unknown capability) and a configuration mistake (bad TOML, an unsound claim)
|
|
66
|
+
# are different conditions and carry different codes.
|
|
67
|
+
EXIT_USAGE_ERROR = 1
|
|
68
|
+
EXIT_CONFIG_ERROR = 2
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _UsageErrorParser(argparse.ArgumentParser):
|
|
72
|
+
"""Exits with EXIT_USAGE_ERROR rather than argparse's own hard-coded 2.
|
|
73
|
+
|
|
74
|
+
A mutually-exclusive-group conflict (``--name --version``, or two capability
|
|
75
|
+
flags at once) is caught inside argparse itself and never reaches main() -
|
|
76
|
+
the base ``error()`` always calls ``self.exit(2, ...)``. Overriding it here,
|
|
77
|
+
on the top-level parser, is what lets every usage mistake share one code:
|
|
78
|
+
``add_subparsers()`` defaults new sub-parsers to the parent's own class, so
|
|
79
|
+
``supports`` and its hidden alias inherit this without being told to.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
83
|
+
# argparse resolves any unambiguous prefix to its full option, which
|
|
84
|
+
# would answer --onnx-e as --onnx-element-types. Section 5.4 defines
|
|
85
|
+
# eleven exact spellings and this package publishes exactly those, so an
|
|
86
|
+
# abbreviation is an option that does not exist, not a shorthand.
|
|
87
|
+
kwargs.setdefault("allow_abbrev", False)
|
|
88
|
+
super().__init__(*args, **kwargs)
|
|
89
|
+
|
|
90
|
+
def error(self, message: str) -> NoReturn:
|
|
91
|
+
self.print_usage(sys.stderr)
|
|
92
|
+
self.exit(EXIT_USAGE_ERROR, f"{self.prog}: error: {message}\n")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def emit(text: str) -> None:
|
|
96
|
+
"""Write text to stdout as bytes, with no line-ending translation.
|
|
97
|
+
|
|
98
|
+
``print`` and ``sys.stdout`` are text-mode streams, and text mode on Windows
|
|
99
|
+
turns every newline into a carriage return and a newline. Nothing here asks
|
|
100
|
+
for that, and section 5.3.1 output is compared byte for byte: bytes that
|
|
101
|
+
depend on the machine that produced them are not a baseline. A consumer
|
|
102
|
+
splitting the result line on newlines would also get a trailing carriage
|
|
103
|
+
return inside the token, so ``sat`` would not equal ``sat``.
|
|
104
|
+
|
|
105
|
+
Writing through the underlying binary buffer is what makes the bytes the
|
|
106
|
+
same everywhere. The text layer is flushed first so that nothing it holds
|
|
107
|
+
can appear after bytes written past it.
|
|
108
|
+
|
|
109
|
+
stderr is deliberately left in text mode. It carries diagnostic prose rather
|
|
110
|
+
than a format the standard specifies, and argparse writes some of it through
|
|
111
|
+
a stream this code does not own.
|
|
112
|
+
"""
|
|
113
|
+
sys.stdout.flush()
|
|
114
|
+
sys.stdout.buffer.write(text.encode("utf-8"))
|
|
115
|
+
sys.stdout.buffer.flush()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def emit_line(text: str) -> None:
|
|
119
|
+
"""Emit one line, terminated by a single LF on every platform."""
|
|
120
|
+
emit(f"{text}\n")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def inject_stderr(injection: Injection) -> None:
|
|
124
|
+
"""Write the configured noise, before the answer rather than after it.
|
|
125
|
+
|
|
126
|
+
Ordering is a decision here rather than something implied. Writing first is
|
|
127
|
+
what a solver logging its progress would do, it is the order the consuming
|
|
128
|
+
interface's own stub solvers use, and it is the arrangement that actually
|
|
129
|
+
proves the two streams do not interfere: noise written before an answer has
|
|
130
|
+
to survive the answer being written after it, on a different stream, without
|
|
131
|
+
either being corrupted.
|
|
132
|
+
|
|
133
|
+
For ``verify`` this happens before the configured delay is waited out, so a
|
|
134
|
+
caller reading both streams sees the noise while the solver is still
|
|
135
|
+
pretending to think, rather than in one burst at the end.
|
|
136
|
+
"""
|
|
137
|
+
if injection.stderr is not None:
|
|
138
|
+
print(injection.stderr, file=sys.stderr)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def deliver(text: str, injection: Injection) -> int:
|
|
142
|
+
"""Write the answer, honour any crash, and return the exit code.
|
|
143
|
+
|
|
144
|
+
``raw_stdout`` **replaces** the answer rather than adding to it. That is
|
|
145
|
+
what makes a result line that is not one of the four, a truncated capability
|
|
146
|
+
response, and no output at all reachable from configuration, which is the
|
|
147
|
+
whole point of the control - and it is one rule with no special cases rather
|
|
148
|
+
than a list of what it does and does not override.
|
|
149
|
+
|
|
150
|
+
A crash happens **after** the bytes are written and flushed, so that both a
|
|
151
|
+
crash carrying output and a crash carrying none are configurable: the second
|
|
152
|
+
is an empty ``raw_stdout`` beside it. The reverse order would make the first
|
|
153
|
+
unreachable.
|
|
154
|
+
"""
|
|
155
|
+
# Bound to a local rather than tested through a property, so that the
|
|
156
|
+
# None case narrows for the type checker in the same place a reader sees
|
|
157
|
+
# it narrow.
|
|
158
|
+
replacement = injection.raw_stdout
|
|
159
|
+
emit(text if replacement is None else replacement)
|
|
160
|
+
if injection.crash:
|
|
161
|
+
_crash()
|
|
162
|
+
return injection.exit_code if injection.exit_code is not None else EXIT_OK
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _crash() -> NoReturn:
|
|
166
|
+
"""Terminate abnormally rather than exiting.
|
|
167
|
+
|
|
168
|
+
``os.abort`` raises ``SIGABRT`` on a Unix-like system, which is what the
|
|
169
|
+
consuming process runner distinguishes from a normal exit: it reports the
|
|
170
|
+
child as not having exited normally and carries no exit status. On Windows
|
|
171
|
+
there are no real signals and the process ends with the operating system's
|
|
172
|
+
own fast-fail status instead. Both were measured on this machine rather than
|
|
173
|
+
read about, and the test that covers this asserts the right one per platform
|
|
174
|
+
rather than merely a non-zero code.
|
|
175
|
+
|
|
176
|
+
Both streams are flushed first. ``os.abort`` bypasses interpreter shutdown
|
|
177
|
+
entirely, so anything still buffered is simply lost, and a crash that also
|
|
178
|
+
silently dropped the output configured beside it would be testing something
|
|
179
|
+
other than what the configuration says.
|
|
180
|
+
"""
|
|
181
|
+
sys.stdout.flush()
|
|
182
|
+
sys.stderr.flush()
|
|
183
|
+
os.abort()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
187
|
+
parser = _UsageErrorParser(
|
|
188
|
+
prog=PROGRAM,
|
|
189
|
+
description=(
|
|
190
|
+
"A configurable test solver implementing the VNN-LIB command-line interface."
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
parser.add_argument(
|
|
194
|
+
"--config",
|
|
195
|
+
metavar="PATH",
|
|
196
|
+
help=(
|
|
197
|
+
"configuration file to answer from; otherwise "
|
|
198
|
+
f"${ENV_VAR}, then ./{WORKING_DIRECTORY_NAME}. There is no default: "
|
|
199
|
+
"with none of the three, the run is refused"
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
globals_group = parser.add_mutually_exclusive_group()
|
|
203
|
+
globals_group.add_argument(
|
|
204
|
+
"--name", action="store_true", help="print the solver's full name"
|
|
205
|
+
)
|
|
206
|
+
globals_group.add_argument(
|
|
207
|
+
"--version", action="store_true", help="print the solver version"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
commands = parser.add_subparsers(dest="command", metavar="<command>")
|
|
211
|
+
# Every sub-parser is built through add_parser, which copies the parent's own
|
|
212
|
+
# class: each one inherits the usage exit code and the refusal of abbreviated
|
|
213
|
+
# options without being told to.
|
|
214
|
+
add_arguments(
|
|
215
|
+
commands.add_parser(
|
|
216
|
+
VERIFY_COMMAND,
|
|
217
|
+
help="answer a query about a network",
|
|
218
|
+
description="Answer one VNN-LIB query, per standard section 5.3.",
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
_add_capability_flags(
|
|
222
|
+
commands.add_parser(
|
|
223
|
+
SUPPORTS_COMMAND,
|
|
224
|
+
help="report whether the solver supports a capability",
|
|
225
|
+
description="Report one capability, per standard section 5.4.",
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
# Section 5.1 names this command 'capabilities' while section 5.4 defines it as
|
|
229
|
+
# 'supports'. Both are accepted so that neither reading of the standard breaks a
|
|
230
|
+
# consumer; the alias is given no help text, so only one spelling is documented.
|
|
231
|
+
_add_capability_flags(commands.add_parser(CAPABILITIES_COMMAND))
|
|
232
|
+
return parser
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _add_capability_flags(command: argparse.ArgumentParser) -> None:
|
|
236
|
+
"""Declare the eleven capability flags, of which exactly one may be given."""
|
|
237
|
+
group = command.add_mutually_exclusive_group()
|
|
238
|
+
for capability in CAPABILITIES:
|
|
239
|
+
group.add_argument(
|
|
240
|
+
capability,
|
|
241
|
+
dest="capability",
|
|
242
|
+
action="store_const",
|
|
243
|
+
const=capability,
|
|
244
|
+
help=response_shape(capability),
|
|
245
|
+
)
|
|
246
|
+
group.add_argument(
|
|
247
|
+
MULTIPLE_INPUT_OUTPUT_ALIAS,
|
|
248
|
+
dest="capability",
|
|
249
|
+
action="store_const",
|
|
250
|
+
const=MULTIPLE_INPUT_OUTPUT,
|
|
251
|
+
help=argparse.SUPPRESS,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
256
|
+
parser = build_parser()
|
|
257
|
+
args, unrecognised = parser.parse_known_args(argv)
|
|
258
|
+
|
|
259
|
+
if unrecognised:
|
|
260
|
+
return _report_unrecognised(parser, args, unrecognised)
|
|
261
|
+
|
|
262
|
+
if args.command is not None and (args.name or args.version):
|
|
263
|
+
print(
|
|
264
|
+
f"{PROGRAM}: --name and --version are global options and cannot be "
|
|
265
|
+
f"combined with the {args.command} command",
|
|
266
|
+
file=sys.stderr,
|
|
267
|
+
)
|
|
268
|
+
return EXIT_USAGE_ERROR
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
source = resolve_config_source(args.config)
|
|
272
|
+
config = load_config(source)
|
|
273
|
+
except ConfigError as exc:
|
|
274
|
+
print(f"{PROGRAM}: {exc}", file=sys.stderr)
|
|
275
|
+
return EXIT_CONFIG_ERROR
|
|
276
|
+
|
|
277
|
+
if os.environ.get(DEBUG_ENV_VAR):
|
|
278
|
+
print(
|
|
279
|
+
f"{PROGRAM}: configuration read from {source.origin}: {source.path}",
|
|
280
|
+
file=sys.stderr,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# A soundness claim the configuration contradicts is a property of the file, not
|
|
284
|
+
# of the command asked for, so it is reported whichever command runs. It is a
|
|
285
|
+
# warning: the run still answers, and stdout is untouched.
|
|
286
|
+
for warning in soundness_warnings(config.capabilities, config.soundness):
|
|
287
|
+
print(f"{PROGRAM}: warning: {warning}", file=sys.stderr)
|
|
288
|
+
|
|
289
|
+
# Resolved once, here, so that every path which delivers an answer takes
|
|
290
|
+
# the same controls from the same place. A run that is refused never gets
|
|
291
|
+
# this far, which is deliberate: a solver that cannot report its own errors
|
|
292
|
+
# because a configuration told it to report something else is not a baseline.
|
|
293
|
+
injection = resolve(config.injection)
|
|
294
|
+
|
|
295
|
+
if args.name:
|
|
296
|
+
inject_stderr(injection)
|
|
297
|
+
return deliver(f"{config.solver.name}\n", injection)
|
|
298
|
+
|
|
299
|
+
if args.version:
|
|
300
|
+
inject_stderr(injection)
|
|
301
|
+
return deliver(f"{config.solver.version}\n", injection)
|
|
302
|
+
|
|
303
|
+
if args.command == VERIFY_COMMAND:
|
|
304
|
+
return _run_verify(args, config)
|
|
305
|
+
|
|
306
|
+
if args.command is not None:
|
|
307
|
+
return _run_supports(args, config, injection)
|
|
308
|
+
|
|
309
|
+
parser.print_usage(sys.stderr)
|
|
310
|
+
print(f"{PROGRAM}: no global option or command given", file=sys.stderr)
|
|
311
|
+
return EXIT_USAGE_ERROR
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _run_verify(args: argparse.Namespace, config: Config) -> int:
|
|
315
|
+
try:
|
|
316
|
+
request = request_from(args)
|
|
317
|
+
except UsageError as exc:
|
|
318
|
+
print(f"{PROGRAM}: {exc}", file=sys.stderr)
|
|
319
|
+
return EXIT_USAGE_ERROR
|
|
320
|
+
|
|
321
|
+
for warning in verify_warnings(request):
|
|
322
|
+
print(f"{PROGRAM}: warning: {warning}", file=sys.stderr)
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
rule = select(config, request.query)
|
|
326
|
+
except ConfigError as exc:
|
|
327
|
+
print(f"{PROGRAM}: {exc}", file=sys.stderr)
|
|
328
|
+
return EXIT_CONFIG_ERROR
|
|
329
|
+
|
|
330
|
+
# Everything that can refuse the run happens here, before the result is
|
|
331
|
+
# printed and before any delay is waited out, so that a run which cannot be
|
|
332
|
+
# answered fails with nothing on stdout rather than with a result line
|
|
333
|
+
# already committed to it. Section 5.1 puts errors on stderr; a half-written
|
|
334
|
+
# answer is not something a consumer can be expected to unpick.
|
|
335
|
+
try:
|
|
336
|
+
model = _query_model_for(request, rule)
|
|
337
|
+
if model is not None:
|
|
338
|
+
# Section 5.3.2's three conditions, in the order the clause lists
|
|
339
|
+
# them. The order is fixed rather than incidental: a run that gets
|
|
340
|
+
# two of them wrong must report the same one every time, or a test
|
|
341
|
+
# asserting which condition it triggered depends on nothing.
|
|
342
|
+
check_network_mapping(request, model)
|
|
343
|
+
check_element_types(model, rule.model_element_types)
|
|
344
|
+
check_soundness(model, config.soundness.get("sound-for"))
|
|
345
|
+
assignment = _assignment_for(model, rule)
|
|
346
|
+
except (QueryError, UsageError) as exc:
|
|
347
|
+
print(f"{PROGRAM}: {exc}", file=sys.stderr)
|
|
348
|
+
return EXIT_USAGE_ERROR
|
|
349
|
+
except ConfigError as exc:
|
|
350
|
+
print(f"{PROGRAM}: {exc}", file=sys.stderr)
|
|
351
|
+
return EXIT_CONFIG_ERROR
|
|
352
|
+
|
|
353
|
+
# Nothing above this line can be reached by a run that will be refused, so
|
|
354
|
+
# this is where deliberate misbehaviour starts: the noise goes out before
|
|
355
|
+
# the solver spends its configured thinking time, not after.
|
|
356
|
+
injection = resolve(config.injection, rule)
|
|
357
|
+
inject_stderr(injection)
|
|
358
|
+
|
|
359
|
+
# Section 5.3.1: the result is the first line of stdout, and the assignment
|
|
360
|
+
# block follows it when there is one.
|
|
361
|
+
#
|
|
362
|
+
# The result is asked for after the block is built, not before, because a
|
|
363
|
+
# rule whose delay outlasts the caller's timeout answers timed-out however
|
|
364
|
+
# it was configured, and an assignment printed under that would contradict
|
|
365
|
+
# the line above it. Building it either way keeps a configuration that does
|
|
366
|
+
# not fit its query failing identically whether or not the clock ran out.
|
|
367
|
+
#
|
|
368
|
+
# The delay is waited out even when the output is about to be replaced. The
|
|
369
|
+
# delay is how long the solver claims to think and the replacement is what
|
|
370
|
+
# it prints, so they are independent, and keeping them so is what lets a
|
|
371
|
+
# caller test a deadline being reached and unparseable output arriving in
|
|
372
|
+
# the same run.
|
|
373
|
+
result = answer(request, rule)
|
|
374
|
+
text = f"{result}\n"
|
|
375
|
+
if assignment and result == SAT:
|
|
376
|
+
text += assignment
|
|
377
|
+
return deliver(text, injection)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _query_model_for(request: VerifyRequest, rule: Rule) -> Optional[QueryModel]:
|
|
381
|
+
"""Read the query, but only when the run gives a reason to open it.
|
|
382
|
+
|
|
383
|
+
Which result a rule gives is decided by the query file's name, so a bare
|
|
384
|
+
invocation is answerable without the file existing at all. That is not an
|
|
385
|
+
oversight: it is what lets a caller exercise result handling, rule
|
|
386
|
+
selection and deadline behaviour without maintaining a corpus of real
|
|
387
|
+
queries and real models, and it is published as a property of this binary.
|
|
388
|
+
|
|
389
|
+
Three things ask for the file to be opened, and they are one policy rather
|
|
390
|
+
than three rules: the run has handed the solver something whose other half
|
|
391
|
+
is in the query. A ``--network`` argument gives it a mapping to check
|
|
392
|
+
against the declarations, which is section 5.3.2's first error condition.
|
|
393
|
+
Assignment values give it half an assignment, whose other half - names,
|
|
394
|
+
types, dimensions and order - is in the query. Model element types give it
|
|
395
|
+
a claim about what the models expose, which means nothing until it is set
|
|
396
|
+
beside what the query declares, and which would otherwise be a
|
|
397
|
+
configuration key that silently did nothing.
|
|
398
|
+
|
|
399
|
+
Section 5.3.2's third condition, unsound analysis without consent, is
|
|
400
|
+
deliberately **not** on that list even though it needs the query too. It is
|
|
401
|
+
driven by a configuration table rather than by anything the run supplies,
|
|
402
|
+
so honouring it unconditionally would make a real query file mandatory for
|
|
403
|
+
every invocation of every configuration that mentions soundness. Each
|
|
404
|
+
entry above is opted into by the rule or the command line that asks for it,
|
|
405
|
+
which is why none of them changes what an existing configuration does.
|
|
406
|
+
|
|
407
|
+
*Assumption*, recorded as an open question: applied to the letter, section
|
|
408
|
+
5.3.2 would have every invocation read the query, since the declarations
|
|
409
|
+
are the only source of the required set. That reading would remove the
|
|
410
|
+
affordance above and make a real query file mandatory for every test anyone
|
|
411
|
+
writes against this binary. Both directions of the error stay triggerable
|
|
412
|
+
on demand under the narrower reading, which is the reason a test solver
|
|
413
|
+
carries the condition at all.
|
|
414
|
+
"""
|
|
415
|
+
wants_assignment = bool(rule.assignments) and rule.result == SAT
|
|
416
|
+
if not request.networks and not wants_assignment and not rule.model_element_types:
|
|
417
|
+
return None
|
|
418
|
+
return read_query(request.query)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _assignment_for(model: Optional[QueryModel], rule: Rule) -> str:
|
|
422
|
+
"""Return the assignment block to print after the result, or nothing.
|
|
423
|
+
|
|
424
|
+
Section 5.3.1 attaches the assignment to ``sat`` and to no other result, so
|
|
425
|
+
a rule carrying values for a result that is not ``sat`` prints none of them.
|
|
426
|
+
They are not an error: a configuration is free to describe a satisfying
|
|
427
|
+
assignment for a query it also has a rule answering ``unsat`` for, and
|
|
428
|
+
refusing that would be policing something the standard leaves alone.
|
|
429
|
+
"""
|
|
430
|
+
if model is None or not rule.assignments or rule.result != SAT:
|
|
431
|
+
return ""
|
|
432
|
+
return render_assignment(model, rule.assignments)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _run_supports(args: argparse.Namespace, config: Config, injection: Injection) -> int:
|
|
436
|
+
if args.capability is None:
|
|
437
|
+
print(
|
|
438
|
+
f"{PROGRAM}: {args.command} needs exactly one capability; the standard "
|
|
439
|
+
f"defines {', '.join(CAPABILITIES)}",
|
|
440
|
+
file=sys.stderr,
|
|
441
|
+
)
|
|
442
|
+
return EXIT_USAGE_ERROR
|
|
443
|
+
|
|
444
|
+
try:
|
|
445
|
+
# Named 'response' rather than 'answer' so it cannot be mistaken for the
|
|
446
|
+
# verify command's answer(), which is imported into this module.
|
|
447
|
+
response = render(args.capability, config)
|
|
448
|
+
except ConfigError as exc:
|
|
449
|
+
print(f"{PROGRAM}: {exc}", file=sys.stderr)
|
|
450
|
+
return EXIT_CONFIG_ERROR
|
|
451
|
+
|
|
452
|
+
inject_stderr(injection)
|
|
453
|
+
# render() already terminates each line, so the text is delivered whole
|
|
454
|
+
# rather than a line at a time.
|
|
455
|
+
return deliver(response, injection)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _report_unrecognised(
|
|
459
|
+
parser: argparse.ArgumentParser,
|
|
460
|
+
args: argparse.Namespace,
|
|
461
|
+
unrecognised: Sequence[str],
|
|
462
|
+
) -> int:
|
|
463
|
+
"""Report the first unrecognised token, in the terms of wherever it appeared.
|
|
464
|
+
|
|
465
|
+
Each command draws its arguments from a different closed set, so the message
|
|
466
|
+
names the set the token was measured against: capabilities after supports,
|
|
467
|
+
section 5.3's options after verify. argparse would otherwise report every one
|
|
468
|
+
of them as an unrecognised argument, which says nothing about the set at all.
|
|
469
|
+
"""
|
|
470
|
+
offender = unrecognised[0]
|
|
471
|
+
|
|
472
|
+
if args.command is None:
|
|
473
|
+
parser.print_usage(sys.stderr)
|
|
474
|
+
message = f"unrecognised option {offender!r}"
|
|
475
|
+
elif offender in GLOBAL_OPTIONS:
|
|
476
|
+
message = (
|
|
477
|
+
f"{offender} is a global option and must be given before the "
|
|
478
|
+
f"{args.command} command"
|
|
479
|
+
)
|
|
480
|
+
elif args.command == VERIFY_COMMAND:
|
|
481
|
+
message = _unrecognised_for_verify(offender)
|
|
482
|
+
else:
|
|
483
|
+
message = (
|
|
484
|
+
f"unknown capability {offender!r}; the standard defines "
|
|
485
|
+
f"{', '.join(CAPABILITIES)}"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
print(f"{PROGRAM}: {message}", file=sys.stderr)
|
|
489
|
+
return EXIT_USAGE_ERROR
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _unrecognised_for_verify(offender: str) -> str:
|
|
493
|
+
"""Distinguish an option verify does not define from a second query file.
|
|
494
|
+
|
|
495
|
+
Section 5.3 gives verify one positional argument, so a token that does not
|
|
496
|
+
look like an option is a second file rather than a misspelling, and saying so
|
|
497
|
+
is more use than listing the options it is not.
|
|
498
|
+
"""
|
|
499
|
+
if offender.startswith("-"):
|
|
500
|
+
return (
|
|
501
|
+
f"unknown option {offender!r} for the {VERIFY_COMMAND} command; the "
|
|
502
|
+
f"standard defines {', '.join(VERIFY_OPTIONS)}"
|
|
503
|
+
)
|
|
504
|
+
return f"{VERIFY_COMMAND} takes exactly one query file and {offender!r} is a second"
|