stelling 0.1.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.
- stelling/__init__.py +36 -0
- stelling/__main__.py +120 -0
- stelling/_bound_spelling.py +185 -0
- stelling/_cvc5_driver.py +160 -0
- stelling/_jax_compat.py +1620 -0
- stelling/_optional.py +144 -0
- stelling/_tripwire/__init__.py +306 -0
- stelling/_tripwire/_adapter_jax.py +704 -0
- stelling/_tripwire/_probe.py +47 -0
- stelling/_tripwire/plugin.py +395 -0
- stelling/_tripwire/record.py +494 -0
- stelling/_tripwire/report.py +481 -0
- stelling/affine.py +1193 -0
- stelling/census.py +130 -0
- stelling/contracts.py +1022 -0
- stelling/coverage.py +260 -0
- stelling/exactness.py +279 -0
- stelling/fidelity.py +247 -0
- stelling/harness.py +65 -0
- stelling/interval.py +2535 -0
- stelling/ir.py +756 -0
- stelling/obligation.py +2590 -0
- stelling/overflow.py +89 -0
- stelling/preconditions.py +468 -0
- stelling/propagate.py +8237 -0
- stelling/py.typed +0 -0
- stelling/reproduce.py +1865 -0
- stelling/smt.py +755 -0
- stelling/solvers.py +2607 -0
- stelling/vacuity.py +201 -0
- stelling/verdict.py +1589 -0
- stelling-0.1.0.dist-info/METADATA +410 -0
- stelling-0.1.0.dist-info/RECORD +36 -0
- stelling-0.1.0.dist-info/WHEEL +4 -0
- stelling-0.1.0.dist-info/entry_points.txt +2 -0
- stelling-0.1.0.dist-info/licenses/LICENSE +201 -0
stelling/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Nicholas Ehsan Roy
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Harness-driven verification for JAX scientific computing.
|
|
5
|
+
|
|
6
|
+
``stelling`` traces verification harnesses to jaxprs and discharges them with
|
|
7
|
+
SMT solvers, abstract interpretation, and a region-aware fuzzer. See
|
|
8
|
+
``design/founding.md`` in the repository for the roadmap.
|
|
9
|
+
|
|
10
|
+
Everything heavier than the standard library — ``jax`` and the SMT backends —
|
|
11
|
+
is an optional extra, imported on first use:
|
|
12
|
+
|
|
13
|
+
* ``pip install "stelling[jax]"`` — tracing harnesses
|
|
14
|
+
* ``pip install "stelling[z3]"`` — Z3 backend
|
|
15
|
+
* ``pip install "stelling[cvc5]"`` — cvc5 backend
|
|
16
|
+
* ``pip install "stelling[all]"`` — everything
|
|
17
|
+
|
|
18
|
+
``python -m stelling`` reports which of these are importable and runs a
|
|
19
|
+
one-formula smoke test against each installed solver.
|
|
20
|
+
|
|
21
|
+
The jax-free IR lives in :mod:`stelling.ir`; jax itself is touched only
|
|
22
|
+
inside :mod:`stelling._jax_compat`, the designated churn boundary.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from stelling._optional import OptionalDependencyError, available, require
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"OptionalDependencyError",
|
|
33
|
+
"__version__",
|
|
34
|
+
"available",
|
|
35
|
+
"require",
|
|
36
|
+
]
|
stelling/__main__.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Nicholas Ehsan Roy
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""``python -m stelling`` — report optional dependencies, smoke-test solvers.
|
|
5
|
+
|
|
6
|
+
Exit status is nonzero only if an *installed* solver fails its smoke test
|
|
7
|
+
(i.e. a broken wheel or binary); missing optional dependencies are
|
|
8
|
+
informational.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import platform
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
import stelling
|
|
19
|
+
from stelling._optional import (
|
|
20
|
+
_OPTIONAL,
|
|
21
|
+
TESTED_JAX_SERIES,
|
|
22
|
+
cvc5_binary,
|
|
23
|
+
jax_series_tested,
|
|
24
|
+
require,
|
|
25
|
+
version,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _selfcheck_z3() -> None:
|
|
30
|
+
z3 = require("z3")
|
|
31
|
+
x = z3.Real("x")
|
|
32
|
+
solver = z3.Solver()
|
|
33
|
+
solver.add(x != x)
|
|
34
|
+
if solver.check() != z3.unsat:
|
|
35
|
+
raise RuntimeError("z3 failed to refute `x != x`")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _selfcheck_cvc5() -> None:
|
|
39
|
+
cvc5 = require("cvc5")
|
|
40
|
+
tm = cvc5.TermManager()
|
|
41
|
+
solver = cvc5.Solver(tm)
|
|
42
|
+
x = tm.mkConst(tm.getRealSort(), "x")
|
|
43
|
+
solver.assertFormula(tm.mkTerm(cvc5.Kind.DISTINCT, x, x))
|
|
44
|
+
if not solver.checkSat().isUnsat():
|
|
45
|
+
raise RuntimeError("cvc5 failed to refute `x != x`")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_SELFCHECKS = {"z3": _selfcheck_z3, "cvc5": _selfcheck_cvc5}
|
|
49
|
+
|
|
50
|
+
_SMTLIB_PROBE = "(set-logic QF_LRA)(declare-const x Real)(assert (distinct x x))(check-sat)\n"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run(argv: list[str], **kw) -> subprocess.CompletedProcess[str]:
|
|
54
|
+
return subprocess.run(argv, capture_output=True, text=True, timeout=60, **kw)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _selfcheck_cvc5_binary(path: str) -> None:
|
|
58
|
+
proc = _run([path, "--lang", "smt2", "-"], input=_SMTLIB_PROBE)
|
|
59
|
+
verdict = proc.stdout.strip()
|
|
60
|
+
if verdict != "unsat":
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
f"expected `unsat` for `x != x`, got {verdict!r} (stderr: {proc.stderr.strip()!r})"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _cvc5_binary_version(path: str) -> str:
|
|
67
|
+
# first line is `cvc5 1.3.4 [git ...]` (older builds: `This is cvc5 version 1.3.4`)
|
|
68
|
+
match = re.search(r"^(?:This is )?cvc5(?: version)? (\S+)", _run([path, "--version"]).stdout, re.MULTILINE)
|
|
69
|
+
return match.group(1) if match else "unknown"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _cvc5_binary_features(path: str) -> list[str]:
|
|
73
|
+
"""Optional components compiled into the binary, per --show-config."""
|
|
74
|
+
config = _run([path, "--show-config"]).stdout
|
|
75
|
+
interesting = ("cln", "cocoa", "glpk", "poly", "cryptominisat", "kissat")
|
|
76
|
+
return [f for f in interesting if re.search(rf"^{f}\s*:\s*yes", config, re.IGNORECASE | re.MULTILINE)]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main() -> int:
|
|
80
|
+
print(f"stelling {stelling.__version__} on Python {platform.python_version()}")
|
|
81
|
+
failures = 0
|
|
82
|
+
for name, spec in _OPTIONAL.items():
|
|
83
|
+
installed = version(name)
|
|
84
|
+
if installed is None:
|
|
85
|
+
line = f' {name:<9} not installed — pip install "stelling[{spec.extra}]"'
|
|
86
|
+
else:
|
|
87
|
+
line = f" {name:<9} {installed}"
|
|
88
|
+
if name == "jax" and not jax_series_tested(installed):
|
|
89
|
+
line += f" [untested series — stelling is tested against jax {', '.join(TESTED_JAX_SERIES)}.x]"
|
|
90
|
+
selfcheck = _SELFCHECKS.get(name)
|
|
91
|
+
if selfcheck is not None:
|
|
92
|
+
try:
|
|
93
|
+
selfcheck()
|
|
94
|
+
line += " [selfcheck: ok]"
|
|
95
|
+
except Exception as e: # noqa: BLE001 — report broken wheels, don't crash
|
|
96
|
+
failures += 1
|
|
97
|
+
line += f" [selfcheck FAILED: {e}]"
|
|
98
|
+
print(line)
|
|
99
|
+
|
|
100
|
+
# External cvc5 binary: the transport for full-featured (e.g. GPL) builds.
|
|
101
|
+
path = cvc5_binary()
|
|
102
|
+
if path is None:
|
|
103
|
+
print(f" {'cvc5-bin':<9} none — optional; set STELLING_CVC5 or put `cvc5` on PATH")
|
|
104
|
+
else:
|
|
105
|
+
try:
|
|
106
|
+
line = f" {'cvc5-bin':<9} {_cvc5_binary_version(path)} ({path})"
|
|
107
|
+
_selfcheck_cvc5_binary(path)
|
|
108
|
+
line += " [selfcheck: ok]"
|
|
109
|
+
features = _cvc5_binary_features(path)
|
|
110
|
+
if features:
|
|
111
|
+
line += f" [built with: {', '.join(features)}]"
|
|
112
|
+
except Exception as e: # noqa: BLE001
|
|
113
|
+
failures += 1
|
|
114
|
+
line = f" {'cvc5-bin':<9} {path} [selfcheck FAILED: {e}]"
|
|
115
|
+
print(line)
|
|
116
|
+
return 1 if failures else 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
sys.exit(main())
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Nicholas Ehsan Roy
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""What a declared bound's VALUE is, decided exactly or not at all.
|
|
5
|
+
|
|
6
|
+
The declaration layer records bounds as binary64 in the IR, and this
|
|
7
|
+
layer's defect history (three "a case the author did not enumerate"
|
|
8
|
+
escapes, ending in a measured false VERIFIED) is one pattern repeated:
|
|
9
|
+
storability was judged by the bound's TYPE while its VALUE went through
|
|
10
|
+
``float()``, a conversion that silently rounds exactly the values the
|
|
11
|
+
judgment exists to protect.
|
|
12
|
+
|
|
13
|
+
This module replaces the type question with a value question, asked in a
|
|
14
|
+
single exact domain: every accepted spelling is mapped to the exact
|
|
15
|
+
number it denotes — a :class:`fractions.Fraction` when finite, the
|
|
16
|
+
binary64 ``±inf``/``nan`` when not — and every downstream judgment (is
|
|
17
|
+
it storable? does rounding narrow or widen? does the declared interval
|
|
18
|
+
hold a dtype value?) is made by exact rational comparison. Spelling
|
|
19
|
+
independence is then structural rather than enumerated: two spellings of
|
|
20
|
+
one value map to one rational, so no route and no guard can tell them
|
|
21
|
+
apart.
|
|
22
|
+
|
|
23
|
+
THE FAMILY IS CLOSED, AND EVERY SPELLING OUTSIDE IT IS COVERED BY
|
|
24
|
+
REFUSAL. Accepted: python ``int``/``bool``/``float``; numpy integer,
|
|
25
|
+
floating (every width, ``longdouble`` included), and bool scalars;
|
|
26
|
+
``decimal.Decimal``; ``fractions.Fraction``; 0-d numpy arrays of those.
|
|
27
|
+
Anything else — ``str``, ``complex``, a jax array, an ``ml_dtypes``
|
|
28
|
+
scalar, any third-party number, any object with a ``__float__`` — makes
|
|
29
|
+
:func:`declared_bound_value` return None, and the declaration layer
|
|
30
|
+
refuses it loudly, naming this family. That is the whole coverage story
|
|
31
|
+
for spellings nobody thought of: they are refused BY DEFAULT, before any
|
|
32
|
+
conversion, so a new numeric type cannot ride in through ``float()`` and
|
|
33
|
+
round on the way in. (A ``str`` like ``'0.1'`` denotes a decidable
|
|
34
|
+
value, so its refusal is POLICY rather than necessity: accepting text
|
|
35
|
+
would reopen the family this module exists to close. Respell it
|
|
36
|
+
``Decimal('0.1')`` for the exact decimal, or ``0.1`` for the binary64.)
|
|
37
|
+
|
|
38
|
+
jax-free and numpy-lazy, so :mod:`stelling.contracts` can validate at
|
|
39
|
+
authoring time in a bare environment: numpy is recognized when it is
|
|
40
|
+
importable and irrelevant when it is not — a numpy-spelled bound cannot
|
|
41
|
+
exist in a process that has no numpy.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import math
|
|
47
|
+
from decimal import Decimal
|
|
48
|
+
from fractions import Fraction
|
|
49
|
+
|
|
50
|
+
__all__ = ["ACCEPTED_SPELLINGS", "binary64_image", "declared_bound_value"]
|
|
51
|
+
|
|
52
|
+
# The one sentence every unknown-spelling refusal quotes, so the family
|
|
53
|
+
# is named identically at every route into the layer.
|
|
54
|
+
ACCEPTED_SPELLINGS = (
|
|
55
|
+
"a python int, float, or bool; a numpy integer, floating, or bool "
|
|
56
|
+
"scalar; a decimal.Decimal; a fractions.Fraction; or a 0-d numpy "
|
|
57
|
+
"array of one of those (np.asarray(x) converts a concrete jax scalar)"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _np():
|
|
62
|
+
try:
|
|
63
|
+
import numpy
|
|
64
|
+
except ImportError: # pragma: no cover — every tested venv has numpy
|
|
65
|
+
return None
|
|
66
|
+
return numpy
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def declared_bound_value(raw):
|
|
70
|
+
"""The exact value ``raw`` declares: a :class:`Fraction` for a
|
|
71
|
+
finite nonzero value; a FLOAT for the values binary64 holds exactly
|
|
72
|
+
but Fraction cannot represent faithfully — ``±inf``, ``nan``, and
|
|
73
|
+
the signed zeros (a Fraction has no ``-0``, and the recorded param
|
|
74
|
+
must keep the zero's sign the parent recorded); or **None** for a
|
|
75
|
+
spelling outside the accepted family — the caller refuses a None;
|
|
76
|
+
nothing here or downstream ever converts one."""
|
|
77
|
+
# bool before int (bool is an int subclass); both are exact already
|
|
78
|
+
if isinstance(raw, bool):
|
|
79
|
+
return Fraction(int(raw))
|
|
80
|
+
if isinstance(raw, int):
|
|
81
|
+
return Fraction(raw)
|
|
82
|
+
if isinstance(raw, float):
|
|
83
|
+
# np.float64 subclasses float and lands here; it IS a binary64,
|
|
84
|
+
# so this branch and the np.floating one below agree on it. Two
|
|
85
|
+
# value classes go through float() instead of Fraction: the
|
|
86
|
+
# non-finite ones (nothing to round about ±inf/nan, and float()
|
|
87
|
+
# NORMALIZES np.float64('inf') so the recorded param keeps the
|
|
88
|
+
# python-float type the parent recorded) and ZERO, whose IEEE
|
|
89
|
+
# sign a Fraction cannot carry — classifying -0.0 through
|
|
90
|
+
# Fraction recorded +0.0 where the parent recorded -0.0, moving
|
|
91
|
+
# recorded params and the query content hash for a spelling
|
|
92
|
+
# class disclosed as unmoved (measured; blinded lens, repair
|
|
93
|
+
# round 1). float() of a zero is exact and keeps the sign.
|
|
94
|
+
if math.isfinite(raw) and raw != 0.0:
|
|
95
|
+
return Fraction(raw)
|
|
96
|
+
return float(raw)
|
|
97
|
+
if isinstance(raw, Fraction):
|
|
98
|
+
return raw
|
|
99
|
+
if isinstance(raw, Decimal):
|
|
100
|
+
if raw.is_nan():
|
|
101
|
+
return math.nan
|
|
102
|
+
if raw.is_infinite():
|
|
103
|
+
return math.inf if raw > 0 else -math.inf
|
|
104
|
+
if raw.is_zero():
|
|
105
|
+
# Decimal('-0') is a signed zero too, and the parent's
|
|
106
|
+
# float() image recorded -0.0 for it (measured); keep that
|
|
107
|
+
return float(raw)
|
|
108
|
+
return Fraction(raw)
|
|
109
|
+
np = _np()
|
|
110
|
+
if np is not None:
|
|
111
|
+
if isinstance(raw, np.bool_):
|
|
112
|
+
return Fraction(int(raw))
|
|
113
|
+
if isinstance(raw, np.integer) and raw.dtype.kind in "iu":
|
|
114
|
+
# numpy's scalar lattice is not a number lattice:
|
|
115
|
+
# np.timedelta64 IS an np.integer subclass (measured; blinded
|
|
116
|
+
# lens, repair rounds 1-2), so the isinstance test alone let
|
|
117
|
+
# datetime arithmetic in as bounds: a tick count was silently
|
|
118
|
+
# ADMITTED with its unit discarded — recorded as a plain
|
|
119
|
+
# integer — for the generic form and every unit whose value
|
|
120
|
+
# int() cannot turn into a datetime.timedelta (as/fs/ps/ns
|
|
121
|
+
# and the calendar units M/Y; measured per unit), while the
|
|
122
|
+
# timedelta-representable units (us through W) and NaT
|
|
123
|
+
# crashed in int() with a bare TypeError, on every route. The
|
|
124
|
+
# dtype-kind conjunct asks
|
|
125
|
+
# numpy's VALUE system (kind 'i'/'u' means integer) rather
|
|
126
|
+
# than its class tree, so timedelta64 (kind 'm'),
|
|
127
|
+
# datetime64 (kind 'M') and anything else the lattice grafts
|
|
128
|
+
# onto an integer parent falls through to the default
|
|
129
|
+
# refusal. np.floating below needs no such conjunct: every
|
|
130
|
+
# np.floating subclass this numpy has is kind 'f', and an
|
|
131
|
+
# unexercisable guard would be an unpinnable half.
|
|
132
|
+
return Fraction(int(raw))
|
|
133
|
+
if isinstance(raw, np.floating):
|
|
134
|
+
# as_integer_ratio is exact at every numpy width, the
|
|
135
|
+
# longdouble 64-bit significand included; float(raw) is the
|
|
136
|
+
# rounding this module exists to avoid, and it is also
|
|
137
|
+
# WRONG here — float(np.longdouble('1e400')) silently
|
|
138
|
+
# returns inf for a finite declared value
|
|
139
|
+
if np.isnan(raw):
|
|
140
|
+
return math.nan
|
|
141
|
+
if np.isinf(raw):
|
|
142
|
+
return math.inf if raw > 0 else -math.inf
|
|
143
|
+
if raw == 0:
|
|
144
|
+
# ±0.0 at any width: float() is exact and keeps the
|
|
145
|
+
# sign the parent recorded; as_integer_ratio drops it
|
|
146
|
+
return float(raw)
|
|
147
|
+
return Fraction(*raw.as_integer_ratio())
|
|
148
|
+
if isinstance(raw, np.ndarray) and raw.ndim == 0:
|
|
149
|
+
inner = raw[()]
|
|
150
|
+
# a 0-d array unwraps to its numpy scalar; a dtype the
|
|
151
|
+
# family does not hold (complex, datetime, object) falls
|
|
152
|
+
# through to None exactly as the bare scalar would
|
|
153
|
+
if not isinstance(inner, np.ndarray):
|
|
154
|
+
return declared_bound_value(inner)
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def binary64_image(v):
|
|
159
|
+
"""The binary64 the IR would record for exact value ``v`` (an output
|
|
160
|
+
of :func:`declared_bound_value`): round-to-nearest, ties-to-even,
|
|
161
|
+
overflowing to the infinity of ``v``'s sign.
|
|
162
|
+
|
|
163
|
+
Agrees with ``float(raw)`` everywhere ``float(raw)`` returns a
|
|
164
|
+
finite answer (both are correct nearest-even rounding; measured over
|
|
165
|
+
the spelling grid in tests), but is TOTAL where ``float`` is not:
|
|
166
|
+
``float(10**400)`` raises OverflowError while the longdouble
|
|
167
|
+
spelling of the same magnitude silently returned inf — one value,
|
|
168
|
+
two behaviours. Here the overflow that makes ``float`` raise is
|
|
169
|
+
caught and mapped to the signed infinity the rounding denotes, and
|
|
170
|
+
the caller judges that image (it is non-finite for a finite declared
|
|
171
|
+
value exactly when the value cannot be recorded at all).
|
|
172
|
+
|
|
173
|
+
CPython's int/int true division is correctly rounded, which is what
|
|
174
|
+
makes ``numerator / denominator`` the exact rounding rather than an
|
|
175
|
+
approximation of it.
|
|
176
|
+
"""
|
|
177
|
+
if isinstance(v, float):
|
|
178
|
+
# ±inf, nan, and the signed zeros: binary64 holds each exactly,
|
|
179
|
+
# nothing to round (the zero's sign survives precisely because
|
|
180
|
+
# it never went through Fraction)
|
|
181
|
+
return v
|
|
182
|
+
try:
|
|
183
|
+
return v.numerator / v.denominator
|
|
184
|
+
except OverflowError:
|
|
185
|
+
return math.inf if v > 0 else -math.inf
|
stelling/_cvc5_driver.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Nicholas Ehsan Roy
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Child-process driver for the cvc5 wheel transport.
|
|
5
|
+
|
|
6
|
+
``stelling.solvers`` runs this module as ``python -m stelling._cvc5_driver``
|
|
7
|
+
with an SMT-LIB2 script on stdin. It feeds the script through the wheel's
|
|
8
|
+
own SMT-LIB2 parser (``cvc5.InputParser``, SMT_LIB_2_6 string input) —
|
|
9
|
+
the ``get-model`` command is realized through the model API
|
|
10
|
+
(``getValue``), everything else is invoked as parsed — and reports on
|
|
11
|
+
stdout in a line protocol::
|
|
12
|
+
|
|
13
|
+
version <backend version>
|
|
14
|
+
answer <sat|unsat|unknown>
|
|
15
|
+
value <name> <exact rational p/q> (sat only, one per declared const)
|
|
16
|
+
opaque <name> <raw term text> (sat, non-rational model value)
|
|
17
|
+
end <count of value+opaque lines written>
|
|
18
|
+
|
|
19
|
+
``end <count>`` is the terminator, and the parent requires it to be the
|
|
20
|
+
**last line** of stdout and to state the number of model lines the parent
|
|
21
|
+
actually parsed. A bare token would only say "the driver reached its last
|
|
22
|
+
statement"; the count says "and it wrote exactly this much model", which is
|
|
23
|
+
what *complete* has to mean if a crashed or truncated run is to be refused
|
|
24
|
+
(``solvers._run_cvc5_wheel``). Driver and parent ship in the same package
|
|
25
|
+
and are read together — change one and you must change the other; a
|
|
26
|
+
mismatch degrades every run to UNKNOWN with the terminator quoted, which
|
|
27
|
+
is the safe direction but is still a break. **That is a claim about the
|
|
28
|
+
PAIR, and its two halves are not equal.** A stale DRIVER writing past this
|
|
29
|
+
whitelist is caught by the parent for nine of the ten separators and not
|
|
30
|
+
for the tenth, which is ``\\n``, the protocol's own record boundary
|
|
31
|
+
(measured, real children, real bytes: ``scratchpad/probe_cvc5_backstop.py``
|
|
32
|
+
part A, and
|
|
33
|
+
``tests/test_solver_audit_findings.py::test_f4wheel3_the_reader_now_refuses_nine_of_the_ten_separators``).
|
|
34
|
+
A stale PARENT is not caught here at all. The whitelist is what makes the
|
|
35
|
+
sentence true for all ten in the direction this file controls.
|
|
36
|
+
|
|
37
|
+
THE PROTOCOL'S ALPHABET, and why it is a whitelist. Every field below is
|
|
38
|
+
written through :func:`_token` / :func:`_tail`, which pass **printable
|
|
39
|
+
ASCII and nothing else**; the only ``\\n`` on this stdout is the one
|
|
40
|
+
``print`` puts at the end of a record. This used to be a blacklist —
|
|
41
|
+
``str.replace("\\n", " ")`` on the model text only — and it was too narrow
|
|
42
|
+
twice over:
|
|
43
|
+
|
|
44
|
+
* ``str.splitlines()``, which the parent used to read with, breaks on ten
|
|
45
|
+
characters, not one (measured: U+000A U+000B U+000C U+000D U+001C U+001D
|
|
46
|
+
U+001E U+0085 U+2028 U+2029). A model value carrying one of those was
|
|
47
|
+
ONE line to this writer and TWO to that reader, which let the payload
|
|
48
|
+
forge the terminator.
|
|
49
|
+
* ``\\r`` used to be worse than the rest and unfixable downstream: the
|
|
50
|
+
parent captured with ``text=True``, so Python's universal-newline
|
|
51
|
+
decoding turned a ``\\r`` into a real ``\\n`` **before the parent got to
|
|
52
|
+
split anything** (measured), and no reader-side rule could see it.
|
|
53
|
+
**THAT SENTENCE DESCRIBED A PARENT THIS PACKAGE NO LONGER HAS**
|
|
54
|
+
(2026-08-09): the parent reads bytes and applies the one translation
|
|
55
|
+
itself (``solvers._decode_child_stream``), so a bare ``\\r`` now reaches
|
|
56
|
+
its alphabet check and is refused there — eight of the ten backstopped
|
|
57
|
+
became nine. **This whitelist is still the load-bearing half and is not
|
|
58
|
+
weakened by that.** The reader's share is what a STALE parent-and-driver
|
|
59
|
+
pair degrades to; the boundary for a matched pair is created here, and
|
|
60
|
+
``\\n`` and ``\\r\\n`` are created here or nowhere at all, because both
|
|
61
|
+
are real record boundaries that no reader can tell from two records.
|
|
62
|
+
|
|
63
|
+
A whitelist rather than a wider blacklist because the blacklist's contents
|
|
64
|
+
are not ours: ``str.splitlines()`` may learn a new separator, and the io
|
|
65
|
+
layer may learn a new translation. Printable ASCII cannot become a line
|
|
66
|
+
boundary under either.
|
|
67
|
+
|
|
68
|
+
:func:`_token` additionally escapes the space, because ``value`` and
|
|
69
|
+
``opaque`` lines are read with ``split(maxsplit=2)`` — a space inside a
|
|
70
|
+
NAME would shift the value into the name's field, which is the same
|
|
71
|
+
writer/reader disagreement one delimiter down.
|
|
72
|
+
|
|
73
|
+
Why a child process at all, measured on cvc5 1.3.4: the wheel's
|
|
74
|
+
``checkSat`` holds the GIL for the entire check, so an in-process thread
|
|
75
|
+
guard can never fire, and the script-level ``:tlimit`` does not reliably
|
|
76
|
+
preempt the coverings solver — the parent's subprocess timeout is the
|
|
77
|
+
wall-clock guard that actually binds. Any internal failure prints
|
|
78
|
+
``error <reason>`` and exits 0; the parent degrades it to UNKNOWN (the
|
|
79
|
+
guard rule: solver failures are quoted reasons, never crashes).
|
|
80
|
+
|
|
81
|
+
Stdlib-only at import time; cvc5 is imported inside :func:`main` via
|
|
82
|
+
``stelling._optional``.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
from __future__ import annotations
|
|
86
|
+
|
|
87
|
+
import sys
|
|
88
|
+
|
|
89
|
+
from stelling._optional import require
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _esc(text: str, keep_space: bool) -> str:
|
|
93
|
+
lo = 0x20 if keep_space else 0x21
|
|
94
|
+
return "".join(
|
|
95
|
+
c if lo <= ord(c) <= 0x7E else f"\\u{{{ord(c):x}}}" for c in text
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _token(text: str) -> str:
|
|
100
|
+
"""One whitespace-free field: no line boundary AND no field boundary."""
|
|
101
|
+
return _esc(text, keep_space=False)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _tail(text: str) -> str:
|
|
105
|
+
"""A record's free-text last field: spaces are content, everything
|
|
106
|
+
outside printable ASCII is escaped."""
|
|
107
|
+
return _esc(text, keep_space=True)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main() -> int:
|
|
111
|
+
out = sys.stdout
|
|
112
|
+
try:
|
|
113
|
+
cvc5 = require("cvc5")
|
|
114
|
+
script = sys.stdin.read()
|
|
115
|
+
tm = cvc5.TermManager()
|
|
116
|
+
solver = cvc5.Solver(tm)
|
|
117
|
+
version = solver.getVersion()
|
|
118
|
+
if isinstance(version, bytes):
|
|
119
|
+
version = version.decode("utf-8", "replace")
|
|
120
|
+
print(f"version {_token(str(version))}", file=out)
|
|
121
|
+
parser = cvc5.InputParser(solver)
|
|
122
|
+
parser.setStringInput(
|
|
123
|
+
cvc5.InputLanguage.SMT_LIB_2_6, script, "stelling-escalation"
|
|
124
|
+
)
|
|
125
|
+
sm = parser.getSymbolManager()
|
|
126
|
+
answer = ""
|
|
127
|
+
while True:
|
|
128
|
+
cmd = parser.nextCommand()
|
|
129
|
+
if cmd.isNull():
|
|
130
|
+
break
|
|
131
|
+
if str(cmd).strip().startswith("(get-model"):
|
|
132
|
+
continue # realized via the model API below
|
|
133
|
+
result = cmd.invoke(solver, sm)
|
|
134
|
+
if result:
|
|
135
|
+
token = result.strip()
|
|
136
|
+
if token in ("sat", "unsat", "unknown"):
|
|
137
|
+
answer = token
|
|
138
|
+
if not answer:
|
|
139
|
+
print("error script produced no check-sat answer", file=out)
|
|
140
|
+
return 0
|
|
141
|
+
print(f"answer {answer}", file=out)
|
|
142
|
+
written = 0
|
|
143
|
+
if answer == "sat":
|
|
144
|
+
for term in sm.getDeclaredTerms():
|
|
145
|
+
value = solver.getValue(term)
|
|
146
|
+
name = _token(str(term))
|
|
147
|
+
if value.isRealValue():
|
|
148
|
+
print(f"value {name} {_tail(str(value.getRealValue()))}", file=out)
|
|
149
|
+
else:
|
|
150
|
+
print(f"opaque {name} {_tail(str(value))}", file=out)
|
|
151
|
+
written += 1
|
|
152
|
+
print(f"end {written}", file=out)
|
|
153
|
+
return 0
|
|
154
|
+
except Exception as e: # noqa: BLE001 — the parent quotes this, never crashes
|
|
155
|
+
print(f"error {_tail(f'{type(e).__name__}: {e}')}", file=out)
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
sys.exit(main())
|