nativegate 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.
- nativegate/__init__.py +1 -0
- nativegate/__main__.py +4 -0
- nativegate/buildinfo.py +344 -0
- nativegate/cli.py +2007 -0
- nativegate/config.py +991 -0
- nativegate/declared_invariants.py +565 -0
- nativegate/discovery.py +167 -0
- nativegate/driverbuild.py +626 -0
- nativegate/drivers/__init__.py +5 -0
- nativegate/drivers/cpp.py +616 -0
- nativegate/drivers/fortran.py +507 -0
- nativegate/generators/__init__.py +0 -0
- nativegate/generators/cmake_gen.py +101 -0
- nativegate/generators/docker_gen.py +614 -0
- nativegate/generators/error_gen.py +104 -0
- nativegate/generators/f2py_gen.py +91 -0
- nativegate/generators/gateway_gen.py +110 -0
- nativegate/generators/golden_gen.py +50 -0
- nativegate/generators/k8s_gen.py +212 -0
- nativegate/generators/mcp_gen.py +281 -0
- nativegate/generators/middleware_gen.py +717 -0
- nativegate/generators/pybind_gen.py +406 -0
- nativegate/generators/pyproject_gen.py +61 -0
- nativegate/generators/python_pkg_gen.py +1164 -0
- nativegate/generators/test_gen.py +160 -0
- nativegate/golden.py +747 -0
- nativegate/invariants.py +532 -0
- nativegate/ir.py +789 -0
- nativegate/lattice.py +350 -0
- nativegate/locking.py +216 -0
- nativegate/oracle.py +904 -0
- nativegate/parsers/__init__.py +0 -0
- nativegate/parsers/cpp.py +105 -0
- nativegate/parsers/cpp_ast.py +1652 -0
- nativegate/parsers/cpp_regex.py +812 -0
- nativegate/parsers/fixed_form.py +868 -0
- nativegate/parsers/fortran.py +157 -0
- nativegate/parsers/fortran_fparser.py +1116 -0
- nativegate/parsers/fortran_regex.py +686 -0
- nativegate/preprocess.py +335 -0
- nativegate/structural_invariants.py +762 -0
- nativegate/suggest.py +208 -0
- nativegate/templates/__init__.py +20 -0
- nativegate/templates/golden_test_template.py +248 -0
- nativegate/wire.py +438 -0
- nativegate-0.1.0.dist-info/METADATA +547 -0
- nativegate-0.1.0.dist-info/RECORD +50 -0
- nativegate-0.1.0.dist-info/WHEEL +5 -0
- nativegate-0.1.0.dist-info/entry_points.txt +3 -0
- nativegate-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
"""T10 — structural invariants: `finite`, `total`, `no_error_flag`,
|
|
2
|
+
`idempotent`, `order_independent` (design-verification-layers.md section 3,
|
|
3
|
+
in particular 3.2's structural table and 3.5's mutator scoping / fresh
|
|
4
|
+
process / setup replay).
|
|
5
|
+
|
|
6
|
+
These five properties need no domain declaration beyond `state:` (T8's
|
|
7
|
+
`StateConfig`) and no lattice authoring beyond `ranges:` -- they fall
|
|
8
|
+
straight out of the IR plus T9's lattice (`lattice.build_entry_lattice`).
|
|
9
|
+
That is what makes them "structural" rather than "declared" (spec section
|
|
10
|
+
3.2's own distinction; the declared vocabulary -- `bounds`, `monotone`,
|
|
11
|
+
`sum_to_one`, ... -- is T11's job).
|
|
12
|
+
|
|
13
|
+
Two evaluation strategies, per spec section 3.5:
|
|
14
|
+
|
|
15
|
+
* `finite`, `total`, `no_error_flag` run **in the already-imported
|
|
16
|
+
process** the caller hands in (`package`), because nothing about them
|
|
17
|
+
needs isolation from prior calls -- they are checking the current call's
|
|
18
|
+
own outcome, not whether a *previous* call leaked into it. Each point is
|
|
19
|
+
still preceded by a fresh replay of the declared `setup` sequence (spec
|
|
20
|
+
section 3.5: "every property evaluation runs after the `setup` sequence,
|
|
21
|
+
replayed with the arguments recorded in `golden.json`").
|
|
22
|
+
* `idempotent` and `order_independent` run in a **fresh subprocess per
|
|
23
|
+
sequence**, because what they are testing IS cross-call leakage --
|
|
24
|
+
running them in the caller's own process could not tell "no hidden
|
|
25
|
+
state" from "hidden state, but this interpreter happens to already be in
|
|
26
|
+
the right state from an earlier check". See "The subprocess worker"
|
|
27
|
+
below for the wire format between parent and worker.
|
|
28
|
+
|
|
29
|
+
Mutator scoping (spec section 3.5)
|
|
30
|
+
-----------------------------------
|
|
31
|
+
|
|
32
|
+
`idempotent` is checked for every entry point NOT in `state.mutating`;
|
|
33
|
+
`order_independent` uses only non-`mutating` routines as the interposed
|
|
34
|
+
`g`, and checks every non-`mutating` `f`. Applying either property to a
|
|
35
|
+
declared mutator (`pvt_set_fluid` on petro_api) would condemn the API's own
|
|
36
|
+
contract -- that state IS the point of the routine -- so mutators are
|
|
37
|
+
excluded from both checks entirely, on both sides of the pairing.
|
|
38
|
+
|
|
39
|
+
An *undeclared* mutator does not escape this: it still gets used as `g`
|
|
40
|
+
against every non-mutating `f`, and if it actually mutates shared state,
|
|
41
|
+
`order_independent` fails and says so -- see `_ORDER_INDEPENDENT_HINT`,
|
|
42
|
+
whose wording ("the fix ... is adding the routine to `mutating` in
|
|
43
|
+
review") is load-bearing per spec section 3.5's own text and is asserted
|
|
44
|
+
verbatim in tests.
|
|
45
|
+
|
|
46
|
+
`error_flag` exemption (design decision, not literal spec text)
|
|
47
|
+
------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
The declared `state.error_flag` accessor (T8's `StateConfig.error_flag` --
|
|
50
|
+
e.g. petro_api's `last_error`/`PVTERR`, which reads AND CLEARS the stored
|
|
51
|
+
error code) is excluded from `idempotent` the same way a `mutating` routine
|
|
52
|
+
is, and excluded from being chosen as the interposed `g` routine in
|
|
53
|
+
`order_independent` (it may still appear as the `f` under test, same as any
|
|
54
|
+
other non-mutating routine, since nothing about clear-on-read makes *that*
|
|
55
|
+
suspect). Calling a clear-on-read accessor twice in a row legitimately
|
|
56
|
+
returns different bits on the second call -- the first call returns
|
|
57
|
+
whatever was stored, the second returns the cleared value -- so checking
|
|
58
|
+
`idempotent` against it literally, or using it to perturb another routine's
|
|
59
|
+
state in `order_independent`, would fail on every run for a reason that has
|
|
60
|
+
nothing to do with an actual bug. `design-verification-layers.md` §3.5 does
|
|
61
|
+
not say this explicitly; this is the same "the check would condemn the
|
|
62
|
+
API's own contract" reasoning already applied to `mutating` above, extended
|
|
63
|
+
to a second kind of intentional statefulness. See `StateConfig`'s docstring
|
|
64
|
+
in `config.py` for the mirrored note there.
|
|
65
|
+
|
|
66
|
+
The subprocess worker
|
|
67
|
+
----------------------
|
|
68
|
+
|
|
69
|
+
`idempotent`/`order_independent` need a scripted call sequence executed in
|
|
70
|
+
a brand-new interpreter, with the result of specific calls reported back.
|
|
71
|
+
Rather than reusing T2's wire protocol (`wire.py`) -- built for *bits from
|
|
72
|
+
native driver stdout*, one value per line, no notion of "run these N calls
|
|
73
|
+
in order and tell me about calls 2 and 4" -- this module defines its own,
|
|
74
|
+
much smaller, JSON-in/JSON-out protocol:
|
|
75
|
+
|
|
76
|
+
* **in**: a JSON file (path passed as argv) shaped
|
|
77
|
+
``{"sys_path": [...], "module": "...", "calls": [{"name": ..., "arguments":
|
|
78
|
+
[...]}, ...], "observe": [<index>, ...]}`` -- `sys_path` entries are
|
|
79
|
+
inserted so `module` (an extension or a plain fixture module) can be
|
|
80
|
+
imported, `calls` run in order exactly as `golden.invoke` would run them,
|
|
81
|
+
and `observe` names which calls' results to report back.
|
|
82
|
+
* **out**: one line of JSON on stdout,
|
|
83
|
+
``{"ok": true, "observations": [{"index": ..., "value": ...}, ...]}`` on
|
|
84
|
+
success or ``{"ok": false, "error": "..."}`` on any exception -- printed
|
|
85
|
+
once, at the end, so a stray print from imported code cannot be mistaken
|
|
86
|
+
for the payload (the parent only parses the *last* non-empty stdout
|
|
87
|
+
line).
|
|
88
|
+
|
|
89
|
+
JSON was chosen over reusing the wire line format because the worker's
|
|
90
|
+
payload is a scripted call sequence and a per-call result map, not a flat
|
|
91
|
+
stream of `<key, slot, value>` triples with no notion of "run twice, tell
|
|
92
|
+
me about the second run"; forcing that shape onto T2's protocol would have
|
|
93
|
+
meant inventing new slot/key conventions with no native-code counterpart to
|
|
94
|
+
keep them honest. Bitwise comparison of the reported values still goes
|
|
95
|
+
through T2's `wire.pack_float` (see `_values_equal`) -- only the envelope
|
|
96
|
+
around the values is new, not the comparison itself.
|
|
97
|
+
|
|
98
|
+
Every subprocess launch runs under `buildinfo.pinned_environment()` (spec
|
|
99
|
+
section 2.8 / section 4 rule 3), merged over the parent's own environment
|
|
100
|
+
so the worker can still be found on `sys.path`/`PYTHONPATH`.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
from __future__ import annotations
|
|
104
|
+
|
|
105
|
+
import importlib
|
|
106
|
+
import json
|
|
107
|
+
import math
|
|
108
|
+
import os
|
|
109
|
+
import subprocess
|
|
110
|
+
import sys
|
|
111
|
+
import tempfile
|
|
112
|
+
from dataclasses import dataclass
|
|
113
|
+
from pathlib import Path
|
|
114
|
+
from typing import TYPE_CHECKING, Sequence
|
|
115
|
+
|
|
116
|
+
from . import buildinfo, golden, lattice, wire
|
|
117
|
+
|
|
118
|
+
if TYPE_CHECKING:
|
|
119
|
+
from .config import VerificationConfig
|
|
120
|
+
from .ir import ModuleIR
|
|
121
|
+
|
|
122
|
+
# spec section 3.5: the byte-for-byte guidance a legitimate order_independent
|
|
123
|
+
# failure calls for. Quoted (paraphrased, not verbatim, since the spec gives
|
|
124
|
+
# guidance rather than a fixed string) in every such failure's message, and
|
|
125
|
+
# asserted against directly in tests.
|
|
126
|
+
_ORDER_INDEPENDENT_HINT = (
|
|
127
|
+
"this looks like an UNDECLARED mutator: if {g!r} is genuinely supposed "
|
|
128
|
+
"to change state that {f!r} reads, the fix is adding {g!r} to "
|
|
129
|
+
"`state.mutating` in nativegate.yaml (in review) -- not changing this "
|
|
130
|
+
"check."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class WorkerError(RuntimeError):
|
|
135
|
+
"""The fresh-process worker could not be run, or crashed outright.
|
|
136
|
+
|
|
137
|
+
Distinct from a *property* failure (a `PointFailure`): this is "the
|
|
138
|
+
harness could not even ask the question at this point" -- e.g. the
|
|
139
|
+
worker process itself failed to start, or produced no parseable
|
|
140
|
+
output -- and is surfaced as a failure of the property being checked
|
|
141
|
+
(see `_check_idempotent`/`_check_order_independent`), never silently
|
|
142
|
+
skipped.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# --- results -----------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class PointFailure:
|
|
151
|
+
"""One lattice point (or worker run) at which a property failed."""
|
|
152
|
+
|
|
153
|
+
point: str
|
|
154
|
+
detail: str
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class PropertyOutcome:
|
|
159
|
+
"""One property's result for one entry-point key."""
|
|
160
|
+
|
|
161
|
+
property: str
|
|
162
|
+
function: str
|
|
163
|
+
points_checked: int
|
|
164
|
+
failures: tuple[PointFailure, ...] = ()
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def status(self) -> str:
|
|
168
|
+
return "pass" if not self.failures else "fail"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class StructuralInvariantResults:
|
|
173
|
+
outcomes: tuple[PropertyOutcome, ...] = ()
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def passed(self) -> bool:
|
|
177
|
+
return all(o.status == "pass" for o in self.outcomes)
|
|
178
|
+
|
|
179
|
+
def failures(self) -> list[PropertyOutcome]:
|
|
180
|
+
return [o for o in self.outcomes if o.status == "fail"]
|
|
181
|
+
|
|
182
|
+
def by_property(self, name: str) -> list[PropertyOutcome]:
|
|
183
|
+
return [o for o in self.outcomes if o.property == name]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# --- how the fresh-process worker imports the target --------------------
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class SubprocessTarget:
|
|
191
|
+
"""How `idempotent`/`order_independent`'s worker imports the module
|
|
192
|
+
under test in a fresh interpreter.
|
|
193
|
+
|
|
194
|
+
`module_name` is passed to `importlib.import_module` inside the
|
|
195
|
+
worker; `sys_path` are directories inserted (at the front) before that
|
|
196
|
+
import, so a built extension or a plain fixture module that is not
|
|
197
|
+
already importable can still be found. `attr_path` is an optional chain
|
|
198
|
+
of attribute lookups applied after the import -- f2py nests routines
|
|
199
|
+
under `<extension>.<fortran module name>.<routine>` (see
|
|
200
|
+
`ir.FunctionDef.fortran_module`'s docstring) rather than at the
|
|
201
|
+
extension's top level, so a caller whose entry points live under a
|
|
202
|
+
`module ... end module` block passes e.g. `("petro_api",)` here to reach
|
|
203
|
+
them, the same way `services/petro_api/python/petro_api/__init__.py`'s
|
|
204
|
+
generated flattening does.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
module_name: str
|
|
208
|
+
sys_path: tuple[str, ...] = ()
|
|
209
|
+
attr_path: tuple[str, ...] = ()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# --- point selection ------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _points_for(entry_lattice: "lattice.EntryLattice", points_limit: int | None):
|
|
216
|
+
"""Every lattice point for one entry, plus the base (recorded) point
|
|
217
|
+
itself -- spec section 3.4 sweeps `[lo, hi]` inclusive, which does not
|
|
218
|
+
generally include the golden.json base value, and the base value is the
|
|
219
|
+
one call every one of these properties should certainly hold at.
|
|
220
|
+
|
|
221
|
+
`points_limit` caps the number of points actually evaluated (kept in
|
|
222
|
+
the same order they are generated: base, then sweeps, then corners,
|
|
223
|
+
then scatter) -- a knob for keeping subprocess-per-point checks
|
|
224
|
+
(`idempotent`/`order_independent`) affordable in tests; production runs
|
|
225
|
+
pass `None` (no cap) so every declared point is genuinely checked, per
|
|
226
|
+
spec section 3.4's "at every lattice point".
|
|
227
|
+
"""
|
|
228
|
+
points = [
|
|
229
|
+
lattice.LatticePoint(arguments=entry_lattice.base_arguments, origin="base")
|
|
230
|
+
]
|
|
231
|
+
for sweep in entry_lattice.sweeps:
|
|
232
|
+
points.extend(sweep.points)
|
|
233
|
+
points.extend(entry_lattice.corners)
|
|
234
|
+
points.extend(entry_lattice.scatter)
|
|
235
|
+
if points_limit is not None:
|
|
236
|
+
points = points[:points_limit]
|
|
237
|
+
return points
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _describe_point(point: "lattice.LatticePoint") -> str:
|
|
241
|
+
if point.origin == "sweep":
|
|
242
|
+
return f"{point.parameter}[{point.index}] = {point.arguments!r} (sweep)"
|
|
243
|
+
return f"{point.origin}: {point.arguments!r}"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def build_lattices(
|
|
247
|
+
document: dict,
|
|
248
|
+
functions_by_name: dict,
|
|
249
|
+
verification: "VerificationConfig",
|
|
250
|
+
*,
|
|
251
|
+
n: int = lattice.DEFAULT_SWEEP_POINTS,
|
|
252
|
+
) -> dict[str, "lattice.EntryLattice"]:
|
|
253
|
+
"""T9's per-entry lattice for every golden.json entry whose function is
|
|
254
|
+
in `functions_by_name` and whose recorded argument count matches the
|
|
255
|
+
function's non-`intent(out)` parameter count.
|
|
256
|
+
|
|
257
|
+
An entry that does not match (arity mismatch, or names a function not
|
|
258
|
+
present in this module's IR) is silently excluded here -- diagnosing
|
|
259
|
+
*that* is golden's/the IR's job, not this module's; it simply is not
|
|
260
|
+
counted for structural checking, the same way an uncovered parameter
|
|
261
|
+
is not swept.
|
|
262
|
+
|
|
263
|
+
`verification.lattice` (T8's `LatticeConfig` -- `corners:`/`scatter:`
|
|
264
|
+
declared in `nativegate.yaml`) is consulted per entry: a function's
|
|
265
|
+
declared corners (`verification.lattice.corners.get(name, ())`) and the
|
|
266
|
+
single declared scatter seed/count are threaded straight into
|
|
267
|
+
`lattice.build_entry_lattice`, so a declared `lattice:` block actually
|
|
268
|
+
changes which points get checked here -- not only what gets reported in
|
|
269
|
+
`invariants.json` (see `invariants.py`'s module docstring).
|
|
270
|
+
"""
|
|
271
|
+
lattices: dict[str, "lattice.EntryLattice"] = {}
|
|
272
|
+
for key, entry in document.get("entries", {}).items():
|
|
273
|
+
name = entry.get("name") or key
|
|
274
|
+
fn = functions_by_name.get(name)
|
|
275
|
+
if fn is None:
|
|
276
|
+
continue
|
|
277
|
+
parameter_names = [p.name for p in fn.parameters if p.intent != "out"]
|
|
278
|
+
arguments = entry.get("arguments") or []
|
|
279
|
+
if len(parameter_names) != len(arguments):
|
|
280
|
+
continue
|
|
281
|
+
try:
|
|
282
|
+
lattices[key] = lattice.build_entry_lattice(
|
|
283
|
+
entry,
|
|
284
|
+
parameter_names,
|
|
285
|
+
verification.ranges,
|
|
286
|
+
n=n,
|
|
287
|
+
corners=verification.lattice.corners.get(name, ()),
|
|
288
|
+
scatter_seed=verification.lattice.scatter.seed,
|
|
289
|
+
scatter_count=verification.lattice.scatter.count,
|
|
290
|
+
)
|
|
291
|
+
except ValueError:
|
|
292
|
+
continue
|
|
293
|
+
return lattices
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# --- setup replay (in-process) --------------------------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _replay_setup(package, document: dict, setup_names: Sequence[str]) -> None:
|
|
300
|
+
"""Replay `state.setup`, in order, with golden.json's recorded
|
|
301
|
+
arguments for each -- spec section 3.5: "every property evaluation runs
|
|
302
|
+
after the `setup` sequence, replayed with the arguments recorded in
|
|
303
|
+
`golden.json`"."""
|
|
304
|
+
for name in setup_names:
|
|
305
|
+
entry = document.get("entries", {}).get(name)
|
|
306
|
+
if entry is None:
|
|
307
|
+
raise KeyError(
|
|
308
|
+
f"state.setup names {name!r}, which has no golden.json entry "
|
|
309
|
+
"to replay it with"
|
|
310
|
+
)
|
|
311
|
+
golden.invoke(entry, package)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _call_at_point(package, entry: dict, point: "lattice.LatticePoint"):
|
|
315
|
+
"""Call `entry`'s routine with `point`'s arguments substituted for the
|
|
316
|
+
recorded ones, in-process, returning the JSON-able result. Reuses
|
|
317
|
+
`golden.invoke` (rather than re-deriving f2py/pybind11 calling
|
|
318
|
+
conventions here) so method/static/function/constructor dispatch and
|
|
319
|
+
array materialisation stay in exactly one place."""
|
|
320
|
+
synthetic = {**entry, "arguments": list(point.arguments)}
|
|
321
|
+
result, _effects = golden.invoke(synthetic, package)
|
|
322
|
+
return result
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _is_finite(value) -> bool:
|
|
326
|
+
if value is None or isinstance(value, (bool, int, str)):
|
|
327
|
+
return True
|
|
328
|
+
if isinstance(value, float):
|
|
329
|
+
return math.isfinite(value)
|
|
330
|
+
if isinstance(value, list):
|
|
331
|
+
return all(_is_finite(v) for v in value)
|
|
332
|
+
return True
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# --- finite / total / no_error_flag (in-process) --------------------------
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _check_finite_and_total(
|
|
339
|
+
document: dict,
|
|
340
|
+
functions_by_name: dict,
|
|
341
|
+
verification: "VerificationConfig",
|
|
342
|
+
package,
|
|
343
|
+
lattices: dict[str, "lattice.EntryLattice"],
|
|
344
|
+
points_limit: int | None,
|
|
345
|
+
) -> list[PropertyOutcome]:
|
|
346
|
+
outcomes: list[PropertyOutcome] = []
|
|
347
|
+
for key, entry in document.get("entries", {}).items():
|
|
348
|
+
name = entry.get("name") or key
|
|
349
|
+
entry_lattice = lattices.get(key)
|
|
350
|
+
if functions_by_name.get(name) is None or entry_lattice is None:
|
|
351
|
+
continue
|
|
352
|
+
finite_failures: list[PointFailure] = []
|
|
353
|
+
total_failures: list[PointFailure] = []
|
|
354
|
+
checked = 0
|
|
355
|
+
for point in _points_for(entry_lattice, points_limit):
|
|
356
|
+
checked += 1
|
|
357
|
+
desc = _describe_point(point)
|
|
358
|
+
try:
|
|
359
|
+
_replay_setup(package, document, verification.state.setup)
|
|
360
|
+
result = _call_at_point(package, entry, point)
|
|
361
|
+
except Exception as exc: # noqa: BLE001 - recorded as a failure, not swallowed
|
|
362
|
+
total_failures.append(
|
|
363
|
+
PointFailure(desc, f"{type(exc).__name__}: {exc}")
|
|
364
|
+
)
|
|
365
|
+
continue
|
|
366
|
+
if not _is_finite(result):
|
|
367
|
+
finite_failures.append(
|
|
368
|
+
PointFailure(desc, f"non-finite result: {result!r}")
|
|
369
|
+
)
|
|
370
|
+
outcomes.append(PropertyOutcome("finite", key, checked, tuple(finite_failures)))
|
|
371
|
+
outcomes.append(PropertyOutcome("total", key, checked, tuple(total_failures)))
|
|
372
|
+
return outcomes
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _check_no_error_flag(
|
|
376
|
+
document: dict,
|
|
377
|
+
functions_by_name: dict,
|
|
378
|
+
verification: "VerificationConfig",
|
|
379
|
+
package,
|
|
380
|
+
lattices: dict[str, "lattice.EntryLattice"],
|
|
381
|
+
points_limit: int | None,
|
|
382
|
+
) -> list[PropertyOutcome]:
|
|
383
|
+
error_flag = verification.state.error_flag
|
|
384
|
+
if not error_flag:
|
|
385
|
+
return []
|
|
386
|
+
error_flag_entry_key = None
|
|
387
|
+
for key, entry in document.get("entries", {}).items():
|
|
388
|
+
if (entry.get("name") or key) == error_flag:
|
|
389
|
+
error_flag_entry_key = key
|
|
390
|
+
break
|
|
391
|
+
if error_flag_entry_key is None:
|
|
392
|
+
raise KeyError(
|
|
393
|
+
f"state.error_flag names {error_flag!r}, which has no golden.json entry"
|
|
394
|
+
)
|
|
395
|
+
error_flag_entry = document["entries"][error_flag_entry_key]
|
|
396
|
+
|
|
397
|
+
outcomes: list[PropertyOutcome] = []
|
|
398
|
+
for key, entry in document.get("entries", {}).items():
|
|
399
|
+
name = entry.get("name") or key
|
|
400
|
+
if name == error_flag:
|
|
401
|
+
continue # the accessor is not checked against itself
|
|
402
|
+
entry_lattice = lattices.get(key)
|
|
403
|
+
if functions_by_name.get(name) is None or entry_lattice is None:
|
|
404
|
+
continue
|
|
405
|
+
failures: list[PointFailure] = []
|
|
406
|
+
checked = 0
|
|
407
|
+
for point in _points_for(entry_lattice, points_limit):
|
|
408
|
+
checked += 1
|
|
409
|
+
desc = _describe_point(point)
|
|
410
|
+
try:
|
|
411
|
+
_replay_setup(package, document, verification.state.setup)
|
|
412
|
+
_call_at_point(package, entry, point)
|
|
413
|
+
flag_value, _ = golden.invoke(error_flag_entry, package)
|
|
414
|
+
except Exception as exc: # noqa: BLE001
|
|
415
|
+
failures.append(PointFailure(desc, f"{type(exc).__name__}: {exc}"))
|
|
416
|
+
continue
|
|
417
|
+
if flag_value:
|
|
418
|
+
failures.append(
|
|
419
|
+
PointFailure(
|
|
420
|
+
desc,
|
|
421
|
+
f"{error_flag}() reported {flag_value!r} (truthy/nonzero) "
|
|
422
|
+
f"after calling {name}({point.arguments!r})",
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
outcomes.append(PropertyOutcome("no_error_flag", key, checked, tuple(failures)))
|
|
426
|
+
return outcomes
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# --- the fresh-process worker (idempotent / order_independent) -----------
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _setup_calls(document: dict, verification: "VerificationConfig") -> list[dict]:
|
|
433
|
+
calls = []
|
|
434
|
+
for name in verification.state.setup:
|
|
435
|
+
entry = document.get("entries", {}).get(name)
|
|
436
|
+
if entry is None:
|
|
437
|
+
raise KeyError(
|
|
438
|
+
f"state.setup names {name!r}, which has no golden.json entry "
|
|
439
|
+
"to replay it with"
|
|
440
|
+
)
|
|
441
|
+
calls.append({"name": name, "arguments": list(entry["arguments"])})
|
|
442
|
+
return calls
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _run_worker_sequence(
|
|
446
|
+
calls: list[dict],
|
|
447
|
+
observe: list[int],
|
|
448
|
+
target: SubprocessTarget,
|
|
449
|
+
*,
|
|
450
|
+
python_executable: str | None = None,
|
|
451
|
+
timeout: float = 120.0,
|
|
452
|
+
) -> dict:
|
|
453
|
+
"""Run `calls` (a scripted call sequence) in a fresh subprocess,
|
|
454
|
+
reporting the results of the calls at `observe` positions. See the
|
|
455
|
+
module docstring's "The subprocess worker" for the wire format."""
|
|
456
|
+
python_executable = python_executable or sys.executable
|
|
457
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
458
|
+
spec_path = Path(tmp) / "spec.json"
|
|
459
|
+
spec_path.write_text(
|
|
460
|
+
json.dumps(
|
|
461
|
+
{
|
|
462
|
+
"sys_path": list(target.sys_path),
|
|
463
|
+
"module": target.module_name,
|
|
464
|
+
"attr_path": list(target.attr_path),
|
|
465
|
+
"calls": calls,
|
|
466
|
+
"observe": observe,
|
|
467
|
+
}
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
env = {**os.environ, **buildinfo.pinned_environment()}
|
|
471
|
+
try:
|
|
472
|
+
result = subprocess.run(
|
|
473
|
+
[python_executable, "-m", "nativegate.structural_invariants", "worker", str(spec_path)],
|
|
474
|
+
capture_output=True,
|
|
475
|
+
text=True,
|
|
476
|
+
env=env,
|
|
477
|
+
timeout=timeout,
|
|
478
|
+
check=False,
|
|
479
|
+
)
|
|
480
|
+
except subprocess.TimeoutExpired as exc:
|
|
481
|
+
raise WorkerError(
|
|
482
|
+
f"structural invariants worker timed out after {timeout}s"
|
|
483
|
+
) from exc
|
|
484
|
+
|
|
485
|
+
if result.returncode not in (0, 1):
|
|
486
|
+
raise WorkerError(
|
|
487
|
+
f"structural invariants worker crashed (exit {result.returncode}):\n"
|
|
488
|
+
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
|
489
|
+
)
|
|
490
|
+
stdout = result.stdout.strip()
|
|
491
|
+
if not stdout:
|
|
492
|
+
raise WorkerError(
|
|
493
|
+
f"structural invariants worker produced no output (exit "
|
|
494
|
+
f"{result.returncode}):\nstderr:\n{result.stderr}"
|
|
495
|
+
)
|
|
496
|
+
try:
|
|
497
|
+
return json.loads(stdout.splitlines()[-1])
|
|
498
|
+
except json.JSONDecodeError as exc:
|
|
499
|
+
raise WorkerError(
|
|
500
|
+
f"structural invariants worker produced unparseable output: {stdout!r}"
|
|
501
|
+
) from exc
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _values_equal(a, b) -> bool:
|
|
505
|
+
"""Bitwise-equal for floats (via `wire.pack_float`, per spec section
|
|
506
|
+
3.2/4 rule 1 -- "bits, not decimals"), recursively for lists, exact `==`
|
|
507
|
+
otherwise (int/bool/str/None -- there is no bit channel for these, but
|
|
508
|
+
the same no-tolerance principle applies: exact equality or nothing)."""
|
|
509
|
+
if isinstance(a, float) or isinstance(b, float):
|
|
510
|
+
if not (isinstance(a, float) and isinstance(b, float)):
|
|
511
|
+
return a == b
|
|
512
|
+
return wire.pack_float(a) == wire.pack_float(b)
|
|
513
|
+
if isinstance(a, list) and isinstance(b, list):
|
|
514
|
+
return len(a) == len(b) and all(_values_equal(x, y) for x, y in zip(a, b))
|
|
515
|
+
return a == b
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _check_idempotent(
|
|
519
|
+
document: dict,
|
|
520
|
+
verification: "VerificationConfig",
|
|
521
|
+
target: SubprocessTarget,
|
|
522
|
+
lattices: dict[str, "lattice.EntryLattice"],
|
|
523
|
+
points_limit: int | None,
|
|
524
|
+
python_executable: str | None,
|
|
525
|
+
) -> list[PropertyOutcome]:
|
|
526
|
+
mutating = set(verification.state.mutating)
|
|
527
|
+
# `error_flag` is excluded from `idempotent` the same way `mutating`
|
|
528
|
+
# routines are -- see the module docstring's "error_flag exemption".
|
|
529
|
+
if verification.state.error_flag:
|
|
530
|
+
mutating = mutating | {verification.state.error_flag}
|
|
531
|
+
setup_calls = _setup_calls(document, verification)
|
|
532
|
+
base_index = len(setup_calls)
|
|
533
|
+
outcomes: list[PropertyOutcome] = []
|
|
534
|
+
|
|
535
|
+
for key, entry in document.get("entries", {}).items():
|
|
536
|
+
name = entry.get("name") or key
|
|
537
|
+
entry_lattice = lattices.get(key)
|
|
538
|
+
if name in mutating or entry_lattice is None:
|
|
539
|
+
continue
|
|
540
|
+
failures: list[PointFailure] = []
|
|
541
|
+
checked = 0
|
|
542
|
+
for point in _points_for(entry_lattice, points_limit):
|
|
543
|
+
checked += 1
|
|
544
|
+
desc = _describe_point(point)
|
|
545
|
+
calls = setup_calls + [
|
|
546
|
+
{"name": name, "arguments": list(point.arguments)},
|
|
547
|
+
{"name": name, "arguments": list(point.arguments)},
|
|
548
|
+
]
|
|
549
|
+
try:
|
|
550
|
+
payload = _run_worker_sequence(
|
|
551
|
+
calls, [base_index, base_index + 1], target,
|
|
552
|
+
python_executable=python_executable,
|
|
553
|
+
)
|
|
554
|
+
except WorkerError as exc:
|
|
555
|
+
failures.append(PointFailure(desc, str(exc)))
|
|
556
|
+
continue
|
|
557
|
+
if not payload.get("ok"):
|
|
558
|
+
failures.append(PointFailure(desc, payload.get("error", "worker failed")))
|
|
559
|
+
continue
|
|
560
|
+
values = {o["index"]: o["value"] for o in payload["observations"]}
|
|
561
|
+
first, second = values.get(base_index), values.get(base_index + 1)
|
|
562
|
+
if not _values_equal(first, second):
|
|
563
|
+
failures.append(
|
|
564
|
+
PointFailure(
|
|
565
|
+
desc,
|
|
566
|
+
f"{name}({point.arguments!r}) called twice in a row, in a "
|
|
567
|
+
f"fresh process, returned different bits: first={first!r} "
|
|
568
|
+
f"second={second!r}",
|
|
569
|
+
)
|
|
570
|
+
)
|
|
571
|
+
outcomes.append(PropertyOutcome("idempotent", key, checked, tuple(failures)))
|
|
572
|
+
return outcomes
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _check_order_independent(
|
|
576
|
+
document: dict,
|
|
577
|
+
verification: "VerificationConfig",
|
|
578
|
+
target: SubprocessTarget,
|
|
579
|
+
lattices: dict[str, "lattice.EntryLattice"],
|
|
580
|
+
points_limit: int | None,
|
|
581
|
+
python_executable: str | None,
|
|
582
|
+
) -> list[PropertyOutcome]:
|
|
583
|
+
mutating = set(verification.state.mutating)
|
|
584
|
+
# `error_flag` is excluded from being chosen as the interposed `g`
|
|
585
|
+
# routine -- but NOT from being the `f` under test -- the same
|
|
586
|
+
# asymmetric treatment described in the module docstring's "error_flag
|
|
587
|
+
# exemption" (a clear-on-read accessor is a bad *perturbation*, since
|
|
588
|
+
# calling it as `g` would itself clear state as a side effect
|
|
589
|
+
# unrelated to whatever `f` is being tested; there is nothing
|
|
590
|
+
# suspect about checking `f`'s own order-independence).
|
|
591
|
+
g_excluded = mutating | ({verification.state.error_flag} if verification.state.error_flag else set())
|
|
592
|
+
setup_calls = _setup_calls(document, verification)
|
|
593
|
+
base_index = len(setup_calls)
|
|
594
|
+
|
|
595
|
+
entries = document.get("entries", {})
|
|
596
|
+
non_mutating: list[tuple[str, dict]] = [
|
|
597
|
+
(entry.get("name") or key, entry)
|
|
598
|
+
for key, entry in entries.items()
|
|
599
|
+
if (entry.get("name") or key) not in g_excluded
|
|
600
|
+
]
|
|
601
|
+
|
|
602
|
+
outcomes: list[PropertyOutcome] = []
|
|
603
|
+
for key, entry in entries.items():
|
|
604
|
+
f_name = entry.get("name") or key
|
|
605
|
+
entry_lattice = lattices.get(key)
|
|
606
|
+
if f_name in mutating or entry_lattice is None:
|
|
607
|
+
continue
|
|
608
|
+
failures: list[PointFailure] = []
|
|
609
|
+
checked = 0
|
|
610
|
+
for point in _points_for(entry_lattice, points_limit):
|
|
611
|
+
checked += 1
|
|
612
|
+
desc = _describe_point(point)
|
|
613
|
+
|
|
614
|
+
baseline_calls = setup_calls + [
|
|
615
|
+
{"name": f_name, "arguments": list(point.arguments)},
|
|
616
|
+
{"name": f_name, "arguments": list(point.arguments)},
|
|
617
|
+
]
|
|
618
|
+
try:
|
|
619
|
+
baseline_payload = _run_worker_sequence(
|
|
620
|
+
baseline_calls, [base_index + 1], target,
|
|
621
|
+
python_executable=python_executable,
|
|
622
|
+
)
|
|
623
|
+
except WorkerError as exc:
|
|
624
|
+
failures.append(PointFailure(desc, f"baseline: {exc}"))
|
|
625
|
+
continue
|
|
626
|
+
if not baseline_payload.get("ok"):
|
|
627
|
+
failures.append(
|
|
628
|
+
PointFailure(desc, f"baseline: {baseline_payload.get('error')}")
|
|
629
|
+
)
|
|
630
|
+
continue
|
|
631
|
+
baseline_values = {o["index"]: o["value"] for o in baseline_payload["observations"]}
|
|
632
|
+
baseline_second = baseline_values.get(base_index + 1)
|
|
633
|
+
|
|
634
|
+
for g_name, g_entry in non_mutating:
|
|
635
|
+
g_arguments = list(g_entry["arguments"])
|
|
636
|
+
perturbed_calls = setup_calls + [
|
|
637
|
+
{"name": f_name, "arguments": list(point.arguments)},
|
|
638
|
+
{"name": g_name, "arguments": g_arguments},
|
|
639
|
+
{"name": f_name, "arguments": list(point.arguments)},
|
|
640
|
+
]
|
|
641
|
+
second_f_index = base_index + 2
|
|
642
|
+
try:
|
|
643
|
+
payload = _run_worker_sequence(
|
|
644
|
+
perturbed_calls, [second_f_index], target,
|
|
645
|
+
python_executable=python_executable,
|
|
646
|
+
)
|
|
647
|
+
except WorkerError as exc:
|
|
648
|
+
failures.append(PointFailure(desc, f"g={g_name}: {exc}"))
|
|
649
|
+
continue
|
|
650
|
+
if not payload.get("ok"):
|
|
651
|
+
failures.append(
|
|
652
|
+
PointFailure(desc, f"g={g_name}: {payload.get('error')}")
|
|
653
|
+
)
|
|
654
|
+
continue
|
|
655
|
+
values = {o["index"]: o["value"] for o in payload["observations"]}
|
|
656
|
+
perturbed_second = values.get(second_f_index)
|
|
657
|
+
if not _values_equal(baseline_second, perturbed_second):
|
|
658
|
+
hint = _ORDER_INDEPENDENT_HINT.format(g=g_name, f=f_name)
|
|
659
|
+
failures.append(
|
|
660
|
+
PointFailure(
|
|
661
|
+
desc,
|
|
662
|
+
f"{f_name}({point.arguments!r}); {g_name}({g_arguments!r}); "
|
|
663
|
+
f"{f_name}({point.arguments!r}) != "
|
|
664
|
+
f"{f_name}({point.arguments!r}); {f_name}({point.arguments!r}) "
|
|
665
|
+
f"(baseline second call={baseline_second!r}, "
|
|
666
|
+
f"perturbed second call={perturbed_second!r}) -- {hint}",
|
|
667
|
+
)
|
|
668
|
+
)
|
|
669
|
+
outcomes.append(PropertyOutcome("order_independent", key, checked, tuple(failures)))
|
|
670
|
+
return outcomes
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
# --- the public entry point ------------------------------------------------
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def run_structural_invariants(
|
|
677
|
+
module: "ModuleIR",
|
|
678
|
+
document: dict,
|
|
679
|
+
verification: "VerificationConfig",
|
|
680
|
+
package,
|
|
681
|
+
target: SubprocessTarget,
|
|
682
|
+
*,
|
|
683
|
+
lattices: dict[str, "lattice.EntryLattice"] | None = None,
|
|
684
|
+
n: int = lattice.DEFAULT_SWEEP_POINTS,
|
|
685
|
+
points_limit: int | None = None,
|
|
686
|
+
python_executable: str | None = None,
|
|
687
|
+
) -> StructuralInvariantResults:
|
|
688
|
+
"""Every structural property (spec section 3.2's table) for every
|
|
689
|
+
golden.json entry whose function is in `module` and has a lattice.
|
|
690
|
+
|
|
691
|
+
`package` is the already-imported extension, used for the in-process
|
|
692
|
+
checks (`finite`/`total`/`no_error_flag`); `target` tells the fresh-
|
|
693
|
+
process worker how to import the (same, or equivalent) module for
|
|
694
|
+
`idempotent`/`order_independent` -- kept separate from `package`
|
|
695
|
+
because the in-process checks need a live object and the subprocess
|
|
696
|
+
checks need an *importable name*, and the two are not always the same
|
|
697
|
+
thing (a package already imported under a temporary name, e.g.).
|
|
698
|
+
|
|
699
|
+
`points_limit` caps the number of lattice points evaluated per
|
|
700
|
+
property per function -- primarily for `idempotent`/`order_independent`,
|
|
701
|
+
whose cost is one-or-more fresh subprocess launches *per point*; pass
|
|
702
|
+
`None` (the default) for full spec-mandated coverage ("at every lattice
|
|
703
|
+
point"), or a small integer to keep test runtime bounded.
|
|
704
|
+
"""
|
|
705
|
+
functions_by_name = {fn.name: fn for fn in module.functions}
|
|
706
|
+
if lattices is None:
|
|
707
|
+
lattices = build_lattices(document, functions_by_name, verification, n=n)
|
|
708
|
+
|
|
709
|
+
outcomes: list[PropertyOutcome] = []
|
|
710
|
+
outcomes += _check_finite_and_total(
|
|
711
|
+
document, functions_by_name, verification, package, lattices, points_limit
|
|
712
|
+
)
|
|
713
|
+
outcomes += _check_no_error_flag(
|
|
714
|
+
document, functions_by_name, verification, package, lattices, points_limit
|
|
715
|
+
)
|
|
716
|
+
outcomes += _check_idempotent(
|
|
717
|
+
document, verification, target, lattices, points_limit, python_executable
|
|
718
|
+
)
|
|
719
|
+
outcomes += _check_order_independent(
|
|
720
|
+
document, verification, target, lattices, points_limit, python_executable
|
|
721
|
+
)
|
|
722
|
+
return StructuralInvariantResults(tuple(outcomes))
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
# --- the worker process entry point ----------------------------------------
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _worker_main(argv: list[str]) -> int:
|
|
729
|
+
if len(argv) < 2 or argv[0] != "worker":
|
|
730
|
+
sys.stderr.write(
|
|
731
|
+
"usage: python -m nativegate.structural_invariants worker <spec.json>\n"
|
|
732
|
+
)
|
|
733
|
+
return 2
|
|
734
|
+
|
|
735
|
+
spec = json.loads(Path(argv[1]).read_text())
|
|
736
|
+
for path in spec.get("sys_path", []):
|
|
737
|
+
if path not in sys.path:
|
|
738
|
+
sys.path.insert(0, path)
|
|
739
|
+
|
|
740
|
+
try:
|
|
741
|
+
package = importlib.import_module(spec["module"])
|
|
742
|
+
for attr in spec.get("attr_path", []):
|
|
743
|
+
package = getattr(package, attr)
|
|
744
|
+
observe = set(spec.get("observe", []))
|
|
745
|
+
observations = []
|
|
746
|
+
for index, call in enumerate(spec["calls"]):
|
|
747
|
+
arguments = [golden._materialise(a, package) for a in call["arguments"]]
|
|
748
|
+
fn = getattr(package, call["name"])
|
|
749
|
+
result = golden._jsonable(fn(*arguments))
|
|
750
|
+
if index in observe:
|
|
751
|
+
observations.append({"index": index, "value": result})
|
|
752
|
+
payload = {"ok": True, "observations": observations}
|
|
753
|
+
except Exception as exc: # noqa: BLE001 - reported to the parent, not raised here
|
|
754
|
+
payload = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
755
|
+
|
|
756
|
+
sys.stdout.write(json.dumps(payload) + "\n")
|
|
757
|
+
sys.stdout.flush()
|
|
758
|
+
return 0 if payload["ok"] else 1
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
if __name__ == "__main__":
|
|
762
|
+
raise SystemExit(_worker_main(sys.argv[1:]))
|