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 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
@@ -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())